diff options
| author | 2026-10-12 08:46:23 +0330 | |
|---|---|---|
| committer | 2026-10-12 08:46:23 +0330 | |
| commit | ecc350ca5c2eb50be8309c5f14f8bf0d87db9a02 (patch) | |
| tree | 70e0af76a1b13affff7c6cc8aac7b3e6ef960478 /editors/vscode/client/extension.js | |
a
Diffstat (limited to 'editors/vscode/client/extension.js')
| -rw-r--r-- | editors/vscode/client/extension.js | 537 |
1 files changed, 537 insertions, 0 deletions
diff --git a/editors/vscode/client/extension.js b/editors/vscode/client/extension.js new file mode 100644 index 0000000..c51b12e --- /dev/null +++ b/editors/vscode/client/extension.js @@ -0,0 +1,537 @@ +// vs code client for the zc language server. the server is the real compiler +// running in check mode, so diagnostics, hints and highlighting follow what the +// compiler does, including syntax a program adds to itself with parse words. +// +// everything the client does is written to the output channel "ZC Language +// Server" (View > Output, then pick it from the list). zc.trace.server picks how +// much: "off" logs only problems, "messages" (the default) logs each request and +// response with its time, and "verbose" also logs the bodies. the command +// "zc: show language server log" opens the channel and "zc: restart language +// server" starts the server again. + +const vscode = require('vscode'); +const cp = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { vmProcess } = require('./vmproc'); +const { wasmProcess } = require('./wasmproc'); +const { DiagCache, diagKey, fileStamp, sha256 } = require('./diagcache'); +const { hoverContents } = require('./hover'); + +const CHANNEL = 'ZC Language Server'; +const SLOW_MS = 2000; // a request that takes longer than this is logged as slow +const INIT_WAIT_MS = 15000; // no answer to initialize by then is logged as a stall +const CACHE_STATE = 'zc.diagnosticCache'; // the globalState entry the diagnostics cache is saved in + +let out = null; +let trace = 'messages'; +let proc = null; +let generation = 0; // each spawned server has its own; a stale exit is ignored +let stopping = false; // set while the server is being shut down on purpose +let ready = false; +let diagnostics = null; +let nextId = 1; +let nextDocVersion = 1; // unique across close and reopen, even when the editor resets its version +let restarts = 0; +const pending = new Map(); +// the diagnostics cache (client/diagcache.js). the server is asked only when a +// document's key is not in the cache, and the state below says what it holds +let store = null; // the extension's globalState, where the cache is saved +let cache = new DiagCache(null); +let serverStamp = 'none'; // identifies the server build; it is part of every key +const docKeys = new Map(); // uri -> key of the document as it is now (null: not cacheable) +const docDeps = new Map(); // uri -> the files the document reads, for checking it again when one changes +const serverKeys = new Map(); // uri -> key of the text the server holds +const sentKeys = new Map(); // uri -> keys sent to the server whose diagnostics have not come back +const docVersions = new Map(); // uri -> the last revision sent, including uncacheable documents +const opened = new Set(); // uris the server has open +let saveTimer = null; +let initTimer = null; +let inbox = Buffer.alloc(0); +let stats = { requests: 0, tokens: 0, hovers: 0, diagnostics: 0, errors: 0, cacheHits: 0, cacheMisses: 0 }; +const changed = new vscode.EventEmitter(); + +// the server's legend, in its order +const types = ['keyword', 'type', 'function', 'variable', 'string', 'number', 'operator', 'comment', 'parameter', 'property', 'macro', 'enumMember']; +const legend = new vscode.SemanticTokensLegend(types, []); + +function stamp() { + const d = new Date(); + return d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0'); +} + +// writes one line to the channel. level is 'problem' (always shown), 'message' +// (the default) or 'verbose' +function log(level, text) { + if (!out) return; + const rank = { off: 0, messages: 1, verbose: 2 }; + const want = { problem: 0, message: 1, verbose: 2 }[level]; + if (want > (rank[trace] ?? 1) && level !== 'problem') return; + out.appendLine(stamp() + ' ' + (level === 'problem' ? 'problem ' : '') + text); +} + +function short(s, n) { + s = String(s); + return s.length > n ? s.slice(0, n) + '...(' + s.length + ' bytes)' : s; +} + +// the native server when zc.lsp.serverPath names one; otherwise null, and the +// server runs in the extension's own vm (server/ holds it and its image) +function findServer() { + const configured = vscode.workspace.getConfiguration('zc').get('lsp.serverPath'); + if (configured && fs.existsSync(configured)) return configured; + if (configured) { + log('problem', 'zc.lsp.serverPath does not exist: ' + configured + '; using the built-in server'); + vscode.window.showWarningMessage('zc: zc.lsp.serverPath does not exist; running the server in the extension instead'); + } + return null; +} + +function send(msg) { + if (!proc || !proc.stdin.writable) { + log('problem', 'dropped ' + (msg.method || 'a reply') + ': no running server'); + return; + } + const body = Buffer.from(JSON.stringify(msg), 'utf8'); + if (msg.method) { + if (trace === 'verbose') log('verbose', '-> ' + msg.method + ' ' + short(JSON.stringify(msg.params), 300)); + else log('message', '-> ' + msg.method + (msg.id !== undefined ? ' #' + msg.id : '')); + } + proc.stdin.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\r\n\r\n'), body])); +} + +function request(method, params) { + return new Promise((resolve, reject) => { + if (!proc || !proc.stdin.writable) { reject({ message: 'server unavailable' }); return; } + const id = nextId++; + const t0 = Date.now(); + stats.requests++; + pending.set(id, { resolve, reject, method, t0 }); + send({ jsonrpc: '2.0', id, method, params }); + }); +} + +function rejectPending(message) { + for (const p of pending.values()) p.reject({ message }); + pending.clear(); +} + +// lengths are in bytes, so the buffer stays bytes until a whole message is in +function onData(chunk) { + inbox = Buffer.concat([inbox, chunk]); + while (true) { + const end = inbox.indexOf('\r\n\r\n'); + if (end < 0) return; + const m = /Content-Length:\s*(\d+)/i.exec(inbox.slice(0, end).toString('ascii')); + if (!m) { + log('problem', 'server sent bytes without a Content-Length header: ' + short(inbox.slice(0, end).toString('utf8'), 200)); + inbox = inbox.slice(end + 4); + continue; + } + const start = end + 4, n = parseInt(m[1], 10); + if (inbox.length < start + n) return; + const body = inbox.slice(start, start + n).toString('utf8'); + inbox = inbox.slice(start + n); + try { + onMessage(JSON.parse(body)); + } catch (e) { + stats.errors++; + log('problem', 'bad message from server (' + e.message + '): ' + short(body, 300)); + } + } +} + +function onMessage(msg) { + if (msg.id !== undefined && pending.has(msg.id)) { + const p = pending.get(msg.id); + pending.delete(msg.id); + const ms = Date.now() - p.t0; + if (msg.error) { + stats.errors++; + log('problem', p.method + ' #' + msg.id + ' failed after ' + ms + ' ms: ' + short(JSON.stringify(msg.error), 300)); + p.reject(msg.error); + return; + } + if (ms > SLOW_MS) log('problem', p.method + ' #' + msg.id + ' was slow: ' + ms + ' ms'); + else log('message', '<- ' + p.method + ' #' + msg.id + ' in ' + ms + ' ms'); + if (trace === 'verbose') log('verbose', '<- result ' + short(JSON.stringify(msg.result), 300)); + p.resolve(msg.result); + return; + } + if (msg.method === 'textDocument/publishDiagnostics' && msg.params) { + onPublish(msg.params.uri, msg.params.diagnostics || [], msg.params.version); + return; + } + if (msg.method) log('message', '<- notification ' + msg.method + (msg.method.startsWith('window/') ? ' ' + short(JSON.stringify(msg.params), 200) : '')); +} + +const isZc = doc => doc.languageId === 'zc'; + +function workspaceDir() { + const f = vscode.workspace.workspaceFolders; + return f && f[0] ? f[0].uri.fsPath : null; +} + +function shortKey(key) { + return key ? key.slice(0, 12) : 'none'; +} + +// the server build the cache keys are for: the native server's file, or the +// built-in server's files +function serverStampFor(bin) { + if (bin) return fileStamp(bin); + const dir = path.join(__dirname, '..', 'server'); + return sha256(['lsp.wasm', 'lsp.json', 'tinyvm.js'].map(f => fileStamp(path.join(dir, f))).join(',')); +} + +// the key of a document as it is now, and what it reads (for checking it again when one of those changes) +function keyOf(doc) { + const uri = doc.uri.toString(); + const r = diagKey(serverStamp, doc.getText(), doc.uri.fsPath, workspaceDir()); + docKeys.set(uri, r.key); + docDeps.set(uri, r.deps); + return r.key; +} + +function showDiags(uri, raw) { + const list = raw.map(d => { + const r = d.range; + const sev = d.severity === 4 ? vscode.DiagnosticSeverity.Hint + : d.severity === 3 ? vscode.DiagnosticSeverity.Information + : d.severity === 2 ? vscode.DiagnosticSeverity.Warning + : vscode.DiagnosticSeverity.Error; + const diag = new vscode.Diagnostic(new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character), d.message, sev); + diag.source = d.source || 'zc'; + return diag; + }); + stats.diagnostics += list.length; + log('message', 'diagnostics for ' + uri + ': ' + list.length); + diagnostics.set(vscode.Uri.parse(uri), list); + changed.fire(); +} + +// the server's answer for a text it was sent. it is cached under the key that text had, +// and shown only when that is still the document's key +function onPublish(uri, raw, version) { + if (!opened.has(uri)) return; + const q = sentKeys.get(uri); + const index = q ? (version === undefined ? 0 : q.findIndex(entry => entry.version === version)) : -1; + if (!q || !q.length || index < 0) { + log('message', 'unrequested diagnostics for ' + uri + ': ignored'); + return; + } + const { key, version: sentVersion } = q.splice(index, 1)[0]; + if (key && cache.put(key, raw)) saveCacheSoon(); + if (docKeys.get(uri) === key && (key || docVersions.get(uri) === sentVersion)) showDiags(uri, raw); + else log('message', 'diagnostics for ' + uri + ' under an old key: not shown'); +} + +// shows the cached diagnostics for doc when its key is in the cache; true when it did +function showCached(doc) { + const uri = doc.uri.toString(); + const key = keyOf(doc); + const hit = cache.get(key); + if (!hit) return false; + stats.cacheHits++; + log('message', 'diagnostics for ' + uri + ' from the cache (' + shortKey(key) + ')'); + showDiags(uri, hit); + return true; +} + +// sends doc's text to the server: didOpen the first time, didChange after that +function sendDoc(doc, key) { + if (!proc || !proc.stdin.writable || !ready) return false; + const uri = doc.uri.toString(); + const q = sentKeys.get(uri) || []; + const version = nextDocVersion++; + docVersions.set(uri, version); + q.push({ key, version }); + sentKeys.set(uri, q); + serverKeys.set(uri, key); + if (!opened.has(uri)) { + opened.add(uri); + send({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'zc', version, text: doc.getText() } } }); + } else { + send({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version }, contentChanges: [{ text: doc.getText() }] } }); + } + return true; +} + +// brings doc's diagnostics up to date. a key in the cache answers without the server, which +// then only hears the text when something asks it about the document (see ensureServer). +// otherwise the server gets the text and its answer is cached under the key +function syncDoc(doc) { + if (!isZc(doc)) return; + const uri = doc.uri.toString(); + if (showCached(doc)) return; + const key = docKeys.get(uri); + if (key !== null && key !== undefined && serverKeys.get(uri) === key && opened.has(uri)) return; + if (!ready) { log('message', 'not sending ' + uri + ' yet: server not ready'); return; } + stats.cacheMisses++; + sendDoc(doc, key === undefined ? keyOf(doc) : key); +} + +// the server must hold doc's current text before a request about it (hover, tokens) +function ensureServer(doc) { + if (!isZc(doc) || !ready) return false; + const uri = doc.uri.toString(); + const key = keyOf(doc); + if (key !== null && serverKeys.get(uri) === key && opened.has(uri)) return true; + return sendDoc(doc, key); +} + +// a file another open document reads changed on disk: that document is checked again +function resyncDependents(fsPath) { + for (const doc of vscode.workspace.textDocuments) { + if (!isZc(doc) || doc.uri.fsPath === fsPath) continue; + const deps = docDeps.get(doc.uri.toString()); + if (deps && deps.includes(fsPath)) { + log('message', 'included file changed: checking ' + doc.uri.toString() + ' again'); + syncDoc(doc); + } + } +} + +// the cache is written after a short pause, so typing does not write it on every key +function saveCacheSoon() { + if (!store) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = null; + try { + Promise.resolve(store.update(CACHE_STATE, cache.toJSON())).catch(e => log('problem', 'could not save the diagnostics cache: ' + e.message)); + } catch (e) { + log('problem', 'could not save the diagnostics cache: ' + e.message); + } + }, 500); +} + +// the server's answer to initialize is the first sign it works; without one the +// log says so, and the usual cause is a server that is still loading +// a native server that does not answer is stopped and the built-in server runs +// instead. a built-in server that does not answer is only logged +function watchInit(gen, native) { + if (initTimer) clearTimeout(initTimer); + initTimer = setTimeout(() => { + initTimer = null; + if (gen !== generation || ready || stopping || !proc) return; + if (native) { + log('problem', 'the native server at ' + zcServerPath() + ' did not answer initialize after ' + (INIT_WAIT_MS / 1000) + ' s; using the built-in server. clear zc.lsp.serverPath to stop seeing this'); + forceBuiltin = true; + generation++; + if (proc) { try { proc.kill(); } catch (e) { /* already gone */ } } + proc = null; + rejectPending('server replaced after initialize timeout'); + startServer(); + } else { + log('problem', 'no answer to initialize after ' + (INIT_WAIT_MS / 1000) + ' s; the server is still starting or has stalled'); + } + }, INIT_WAIT_MS); +} + +function zcServerPath() { + return vscode.workspace.getConfiguration('zc').get('lsp.serverPath') || ''; +} + +// forceBuiltin is set when a native server did not answer: the built-in one runs instead +let forceBuiltin = false; + +function startServer() { + const gen = ++generation; + stopping = false; + const bin = forceBuiltin ? null : findServer(); + ready = false; + inbox = Buffer.alloc(0); + // a new server holds no documents and has answered nothing yet + opened.clear(); + serverKeys.clear(); + sentKeys.clear(); + docVersions.clear(); + serverStamp = serverStampFor(bin); + const t0 = Date.now(); + try { + if (bin) { + log('message', 'starting the native server: ' + bin); + proc = cp.spawn(bin, [], { cwd: vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0].uri.fsPath : undefined, stdio: ['pipe', 'pipe', 'pipe'] }); + } else { + const dir = path.join(__dirname, '..', 'server'); + const wasm = path.join(dir, 'lsp.wasm'); + // the wasm server is the fast one; the javascript vm is kept for a build without it + if (fs.existsSync(wasm)) { + log('message', 'starting the built-in server (wasm) from ' + wasm); + proc = wasmProcess(wasm, { log: (level, text) => log(level, 'wasm: ' + text) }); + } else { + log('message', 'starting the built-in server (javascript vm) from ' + dir); + proc = vmProcess(dir, { log: (level, text) => log(level, 'vm: ' + text) }); + } + log('message', 'built-in server started in ' + (Date.now() - t0) + ' ms'); + } + } catch (e) { + log('problem', 'could not start the server: ' + e.stack); + vscode.window.showErrorMessage('zc: could not start the language server: ' + e.message + ' (see the ZC Language Server output)'); + return; + } + proc.stdout.on('data', data => { if (gen === generation) onData(data); }); + proc.stderr.on('data', d => log('problem', 'server stderr: ' + short(d.toString(), 500))); + proc.on('error', err => { + if (gen !== generation) return; + ready = false; + rejectPending('server process error: ' + err.message); + log('problem', 'server process error: ' + err.stack); + vscode.window.showErrorMessage('zc: could not launch the language server: ' + err.message + ' (see the ZC Language Server output)'); + }); + proc.on('exit', (code, signal) => { + if (gen !== generation) return; + if (initTimer) { clearTimeout(initTimer); initTimer = null; } + if (stopping) { log('message', 'server stopped (code ' + code + ')'); ready = false; proc = null; rejectPending('server stopped'); return; } + log('problem', 'server exited with code ' + code + (signal ? ' signal ' + signal : '') + ' after ' + (Date.now() - t0) + ' ms'); + ready = false; + proc = null; + rejectPending('server exited'); + // a crash restarts the server, and the open documents are sent again once it is up + if (restarts < 5) { + restarts++; + log('problem', 'restarting the server (' + restarts + ' of 5)'); + setTimeout(() => { if (gen === generation) startServer(); }, 300); + } else { + log('problem', 'giving up after 5 restarts; use "zc: restart language server" to try again'); + } + }); + watchInit(gen, !!bin); + request('initialize', { processId: process.pid, capabilities: {} }).then(res => { + if (gen !== generation || stopping || !proc) return; + if (initTimer) { clearTimeout(initTimer); initTimer = null; } + const caps = res && res.capabilities ? Object.keys(res.capabilities) : []; + log('message', 'server ready: capabilities ' + caps.join(', ') + ' (initialized in ' + (Date.now() - t0) + ' ms)'); + send({ jsonrpc: '2.0', method: 'initialized', params: {} }); + ready = true; + const docs = vscode.workspace.textDocuments; + log('message', 'syncing ' + docs.filter(isZc).length + ' open zc document(s)'); + docs.forEach(syncDoc); + }).catch(err => log('problem', 'initialize failed: ' + short(JSON.stringify(err), 300))); +} + +function stopServer() { + if (initTimer) { clearTimeout(initTimer); initTimer = null; } + if (!proc) { rejectPending('server stopped'); return; } + const p = proc; + log('message', 'stopping the server'); + stopping = true; + ready = false; + rejectPending('server stopping'); + request('shutdown', null).catch(() => {}); + send({ jsonrpc: '2.0', method: 'exit' }); + setTimeout(() => { if (proc === p) { proc.kill(); proc = null; ready = false; rejectPending('server stopped'); } }, 500); +} + +function activate(context) { + out = vscode.window.createOutputChannel(CHANNEL); + context.subscriptions.push(out); + trace = vscode.workspace.getConfiguration('zc').get('trace.server') || 'messages'; + log('message', 'extension activated; trace level ' + trace); + + diagnostics = vscode.languages.createDiagnosticCollection('zc'); + context.subscriptions.push(diagnostics); + store = context.globalState; + cache = new DiagCache(store ? store.get(CACHE_STATE) : null); + // the stamp is taken without findServer, which would warn about a missing server path a second time + const configured = vscode.workspace.getConfiguration('zc').get('lsp.serverPath'); + serverStamp = serverStampFor(configured && fs.existsSync(configured) ? configured : null); + // the open documents whose diagnostics are cached show them before the server starts + vscode.workspace.textDocuments.forEach(d => { if (isZc(d)) showCached(d); }); + startServer(); + + // a .zc file changed on disk (saved elsewhere, checked out, deleted): the open documents that include it are checked again + const watcher = vscode.workspace.createFileSystemWatcher('**/*.zc'); + const recheck = uri => resyncDependents(uri.fsPath); + context.subscriptions.push(watcher, watcher.onDidChange(recheck), watcher.onDidCreate(recheck), watcher.onDidDelete(recheck)); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('zc.trace.server')) { + trace = vscode.workspace.getConfiguration('zc').get('trace.server') || 'messages'; + log('message', 'trace level is now ' + trace); + } + }), + vscode.workspace.onDidOpenTextDocument(syncDoc), + vscode.workspace.onDidChangeTextDocument(e => syncDoc(e.document)), + vscode.workspace.onDidSaveTextDocument(doc => { + if (!isZc(doc)) return; + syncDoc(doc); + resyncDependents(doc.uri.fsPath); + }), + vscode.workspace.onDidCloseTextDocument(doc => { + if (!isZc(doc)) return; + const uri = doc.uri.toString(); + diagnostics.delete(doc.uri); + docKeys.delete(uri); + docDeps.delete(uri); + serverKeys.delete(uri); + sentKeys.delete(uri); + docVersions.delete(uri); + if (opened.delete(uri) && ready) send({ jsonrpc: '2.0', method: 'textDocument/didClose', params: { textDocument: { uri } } }); + }), + vscode.commands.registerCommand('zc.showLog', () => out.show(true)), + vscode.commands.registerCommand('zc.restart', () => { + log('message', 'restarting the server by request'); + restarts = 0; + stopServer(); + setTimeout(startServer, 600); + }) + ); + + // highlighting: the server's tokens, on top of the grammar in syntaxes/ + const tokens = { + onDidChangeSemanticTokens: changed.event, + async provideDocumentSemanticTokens(doc) { + if (!isZc(doc)) return null; + if (!ready) { log('message', 'no semantic tokens for ' + doc.uri.toString() + ': server not ready'); return null; } + const t0 = Date.now(); + ensureServer(doc); + try { + const res = await request('textDocument/semanticTokens/full', { textDocument: { uri: doc.uri.toString() } }); + if (!res || !res.data) { log('message', 'semantic tokens: none for ' + doc.uri.toString()); return null; } + const builder = new vscode.SemanticTokensBuilder(legend); + let line = 0, col = 0, count = 0; + for (let i = 0; i < res.data.length; i += 5) { + line += res.data[i]; + col = res.data[i] === 0 ? col + res.data[i + 1] : res.data[i + 1]; + builder.push(line, col, res.data[i + 2], res.data[i + 3], res.data[i + 4]); + count++; + } + stats.tokens += count; + log('message', 'semantic tokens: ' + count + ' for ' + doc.uri.toString() + ' in ' + (Date.now() - t0) + ' ms'); + return builder.build(); + } catch (err) { + log('problem', 'semantic tokens failed for ' + doc.uri.toString() + ': ' + short(JSON.stringify(err), 300)); + return null; + } + } + }; + context.subscriptions.push(vscode.languages.registerDocumentSemanticTokensProvider({ language: 'zc' }, tokens, legend)); + + // hover: the docs the compiler stores for a name (docs in the image) + const hover = { + async provideHover(doc, position) { + if (!isZc(doc) || !ready) return null; + ensureServer(doc); + try { + const res = await request('textDocument/hover', { textDocument: { uri: doc.uri.toString() }, position: { line: position.line, character: position.character } }); + stats.hovers++; + const contents = hoverContents(vscode, res && res.contents); + log('message', 'hover at ' + position.line + ':' + position.character + ': ' + (contents.length ? 'text' : 'nothing')); + return contents.length ? new vscode.Hover(contents) : null; + } catch (err) { + return null; + } + } + }; + context.subscriptions.push(vscode.languages.registerHoverProvider({ language: 'zc' }, hover)); +} + +function deactivate() { + log('message', 'extension deactivated: ' + JSON.stringify(stats)); + stopServer(); +} + +module.exports = { activate, deactivate }; |
