diff options
Diffstat (limited to 'editors/vscode/client')
| -rw-r--r-- | editors/vscode/client/diagcache.js | 137 | ||||
| -rw-r--r-- | editors/vscode/client/extension.js | 537 | ||||
| -rw-r--r-- | editors/vscode/client/hover.js | 21 | ||||
| -rw-r--r-- | editors/vscode/client/vmproc.js | 81 | ||||
| -rw-r--r-- | editors/vscode/client/wasmproc.js | 96 |
5 files changed, 872 insertions, 0 deletions
diff --git a/editors/vscode/client/diagcache.js b/editors/vscode/client/diagcache.js new file mode 100644 index 0000000..6e77207 --- /dev/null +++ b/editors/vscode/client/diagcache.js @@ -0,0 +1,137 @@ +// the diagnostics cache. a document's diagnostics are kept under a sha256 key +// that covers the server build, the document's path and text, and every file +// the document reads through include, transitively. the same key means the same +// tree, so the diagnostics kept for it are the ones the server would give, and +// the client can show them without asking the server again. +// +// nothing here uses vscode, so the rules can be tested with plain node +// (tests/drivers/diagcache_test.js). + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const MAX_FILES = 300; // a tree larger than this is not cached: its key would not cover all of it +const MAX_ENTRIES = 500; // tree states kept in the cache; the least recently used goes first + +function sha256(data) { + return crypto.createHash('sha256').update(data).digest('hex'); +} + +// the sha256 of a file's bytes, or 'missing' when there is no such file +function fileHash(p) { + try { + return sha256(fs.readFileSync(p)); + } catch (e) { + return 'missing'; + } +} + +// a short stamp for the server build: the sha256 of its file, or 'none' +function fileStamp(p) { + if (!p) return 'none'; + const h = fileHash(p); + return h === 'missing' ? 'none' : h; +} + +// the names a source file includes, as written in it +function includeNames(text) { + const names = []; + const tokens = /\/\/[^\n]*|\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[a-zA-Z_][a-zA-Z_0-9]*/g; + let include = false, m; + while ((m = tokens.exec(text)) !== null) { + const token = m[0]; + if (token.startsWith('//') || token.startsWith('/*')) continue; + if (include && token.startsWith('"')) { + names.push(token.slice(1, -1).replace(/\\([\\"nrt0])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t', 0: '\0' })[c] || c)); + } + include = token === 'include'; + } + return names; +} + +// every file the document reads, transitively, as path -> sha256 (or 'missing'). +// an include is tried relative to the workspace folder and to the directory of +// the file that has it, since the compiler opens it relative to the working +// directory. both candidates are listed, existing or not, so creating one +// later changes the key. complete is false when the tree is too large to cover. +function sourceTree(text, docPath, workspace) { + const files = new Map(); + const queue = [{ text, dir: path.dirname(docPath) }]; + for (let index = 0; index < queue.length; index++) { + const cur = queue[index]; + for (const name of includeNames(cur.text)) { + const bases = new Set([workspace, cur.dir].filter(Boolean)); + for (const base of bases) { + const p = path.resolve(base, name); + if (files.has(p)) continue; + if (files.size >= MAX_FILES) return { files, complete: false }; + let body = null; + try { body = fs.readFileSync(p); } catch (e) { body = null; } + if (body === null) { files.set(p, 'missing'); continue; } + files.set(p, sha256(body)); + queue.push({ text: body.toString('utf8'), dir: path.dirname(p) }); + } + } + } + return { files, complete: true }; +} + +// the key for a document and the files it reads. key is null when the tree is +// too large to cover, and then nothing is cached. deps lists every file the +// tree names (existing or not), so a change to any of them can be noticed. +function diagKey(stamp, text, docPath, workspace) { + const tree = sourceTree(text, docPath, workspace); + const deps = [...tree.files.keys()].sort(); + if (!tree.complete) return { key: null, deps }; + const parts = [stamp, docPath, sha256(text)]; + for (const p of deps) parts.push(p + '=' + tree.files.get(p)); + return { key: sha256(parts.join('\n')), deps }; +} + +// the cache itself: key -> diagnostics, least recently used first. saved is +// what a previous session stored (plain JSON), so entries carry over; a key +// from another server build never matches. past max entries the least recently +// used one is dropped. +class DiagCache { + constructor(saved, max) { + this.max = max || MAX_ENTRIES; + this.map = new Map(); + if (saved && typeof saved === 'object') { + for (const key of Object.keys(saved)) { + if (Array.isArray(saved[key])) this.map.set(key, saved[key]); + } + } + while (this.map.size > this.max) this.map.delete(this.map.keys().next().value); + } + + // the diagnostics kept under key, or null; using them makes them the newest + get(key) { + if (key === null || key === undefined || !this.map.has(key)) return null; + const diags = this.map.get(key); + this.map.delete(key); + this.map.set(key, diags); + return diags; + } + + // keeps diags under key; returns false when there is no key to keep them under + put(key, diags) { + if (key === null || key === undefined) return false; + this.map.delete(key); + this.map.set(key, diags); + while (this.map.size > this.max) this.map.delete(this.map.keys().next().value); + return true; + } + + size() { + return this.map.size; + } + + toJSON() { + const out = {}; + for (const [key, diags] of this.map) out[key] = diags; + return out; + } +} + +module.exports = { sha256, fileHash, fileStamp, includeNames, sourceTree, diagKey, DiagCache, MAX_FILES }; 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 }; diff --git a/editors/vscode/client/hover.js b/editors/vscode/client/hover.js new file mode 100644 index 0000000..24181c5 --- /dev/null +++ b/editors/vscode/client/hover.js @@ -0,0 +1,21 @@ +// Convert protocol hover contents to VS Code MarkdownString objects. +// MarkupContent, legacy MarkedString, and arrays are all valid LSP responses. +function hoverContents(vscode, contents) { + const entries = Array.isArray(contents) ? contents : [contents]; + const result = []; + for (const entry of entries) { + if (!entry) continue; + const value = typeof entry === 'string' ? entry : entry.value; + if (typeof value !== 'string' || !value) continue; + const md = new vscode.MarkdownString(); + if (typeof entry === 'object' && entry.kind === 'plaintext') md.appendText(value); + else if (typeof entry === 'object' && typeof entry.language === 'string') md.appendCodeblock(value, entry.language); + else md.appendMarkdown(value); + md.supportHtml = false; + md.isTrusted = false; + result.push(md); + } + return result; +} + +module.exports = { hoverContents }; diff --git a/editors/vscode/client/vmproc.js b/editors/vscode/client/vmproc.js new file mode 100644 index 0000000..d4806e4 --- /dev/null +++ b/editors/vscode/client/vmproc.js @@ -0,0 +1,81 @@ +// runs the language server inside the extension, in the tinyvm javascript vm, +// so nothing has to be built or installed for the server to start. it presents +// the same surface the client uses on a child process: stdin to write, stdout +// and stderr to listen to, an exit event and kill(). +// +// the vm is streaming: the server runs until it wants input, the client's bytes +// are fed in and the server goes on from there. each step is synchronous, so a +// long check holds the extension host for as long as it takes. +// +// opts.log(level, text) gets what the vm does: the image it loads, each halt, +// and the messages the server writes to its error stream. +const path = require('path'); +const { EventEmitter } = require('events'); + +function vmProcess(dir, opts = {}) { + const log = opts.log || (() => {}); + const { TinyVM } = require(path.join(dir, 'tinyvm.js')); + const image = require(path.join(dir, 'lsp.json')); + const proc = new EventEmitter(); + const out = new EventEmitter(); + const err = new EventEmitter(); + const pending = []; + let dead = false; + let bytesIn = 0, bytesOut = 0, steps = 0; + + const vm = new TinyVM({ + streaming: true, + memoryWords: image.layout.HEAP_HI, // the initial heap; the machine grows on the heap's request + layout: image.layout, + onOutput: b => { pending.push(b); }, + onMessage: m => { + log('problem', 'server says: ' + String(m).slice(0, 500)); + err.emit('data', Buffer.from(m, 'utf8')); + }, + }); + vm.loadGenome(image.words, image.gorg); + log('message', 'loaded the image: ' + image.words.length + ' words, code at ' + image.gorg); + + const flush = () => { + if (pending.length) { + const chunk = Buffer.from(pending); + bytesOut += chunk.length; + pending.length = 0; + out.emit('data', chunk); + } + }; + const after = status => { + steps++; + flush(); + if (status !== 3 && !dead) { // it halted: the server has exited + dead = true; + log(status === 0 ? 'message' : 'problem', 'the vm halted with status ' + status + ' after ' + steps + ' steps, ' + bytesIn + ' bytes in, ' + bytesOut + ' out'); + proc.emit('exit', status); + } + }; + + proc.stdin = { + writable: true, + write(buf) { + if (dead) { log('problem', 'write to a halted vm dropped (' + buf.length + ' bytes)'); return false; } + bytesIn += buf.length; + vm.feed(buf); + after(vm.resume()); + return true; + }, + }; + proc.stdout = out; + proc.stderr = err; + proc.kill = () => { + if (dead) return; + dead = true; + log('message', 'vm killed after ' + steps + ' steps'); + proc.emit('exit', null); + }; + const t0 = Date.now(); + after(vm.run(image.gorg)); + log('message', 'vm ran to its first wait in ' + (Date.now() - t0) + ' ms'); + return proc; +} + +module.exports = { vmProcess }; diff --git a/editors/vscode/client/wasmproc.js b/editors/vscode/client/wasmproc.js new file mode 100644 index 0000000..0aa674a --- /dev/null +++ b/editors/vscode/client/wasmproc.js @@ -0,0 +1,96 @@ +// runs the language server as WebAssembly (server/lsp.wasm, built by +// src/tools/mklspwasm.sh): the microcore with the server's image inside. it presents +// the same surface as vmProcess: stdin to write, stdout and stderr to listen to, +// an exit event and kill(). it is much faster than the javascript vm, because +// the server's 64-bit words are native here and not bigints. +// +// the module is synchronous to load and to run, so the server starts in the +// extension host's own thread. a step runs until the server waits for input, +// then returns, the way vmProcess does. +// +// opts.log(level, text) gets what happens: the module loaded, each halt, and the +// messages the server writes to its error stream. +const fs = require('fs'); +const { EventEmitter } = require('events'); + +const CHUNK = 65536; // the most a single feed can carry (wasm_chunk_cap) + +function wasmProcess(file, opts = {}) { + const log = opts.log || (() => {}); + const t0 = Date.now(); + const bytes = fs.readFileSync(file); + const mod = new WebAssembly.Module(bytes); + log('message', 'compiled ' + file + ' (' + bytes.length + ' bytes) in ' + (Date.now() - t0) + ' ms'); + + const proc = new EventEmitter(); + const out = new EventEmitter(); + const err = new EventEmitter(); + let outBytes = [], msgBytes = []; + let dead = false, steps = 0, bytesIn = 0, bytesOut = 0; + + const inst = new WebAssembly.Instance(mod, { + env: { + js_putc: c => { outBytes.push(c & 255); }, + js_msg: c => { msgBytes.push(c & 255); }, + }, + }); + const x = inst.exports; + if (x.wasm_chunk_cap() < CHUNK) throw new Error('lsp.wasm has a smaller input chunk than this host expects'); + + const flush = () => { + if (outBytes.length) { + const chunk = Buffer.from(outBytes); + outBytes = []; + bytesOut += chunk.length; + out.emit('data', chunk); + } + if (msgBytes.length) { + const text = Buffer.from(msgBytes).toString('utf8'); + msgBytes = []; + log('problem', 'server says: ' + text.trim().slice(0, 500)); + err.emit('data', Buffer.from(text, 'utf8')); + } + }; + const after = status => { + steps++; + flush(); + if (status !== 3 && !dead) { // it halted: the server has exited + dead = true; + log(status === 0 ? 'message' : 'problem', 'the server halted with status ' + status + ' after ' + steps + ' steps, ' + bytesIn + ' bytes in, ' + bytesOut + ' out'); + proc.emit('exit', status); + } + }; + + proc.stdin = { + writable: true, + write(buf) { + if (dead) { log('problem', 'write to a halted server dropped (' + buf.length + ' bytes)'); return false; } + bytesIn += buf.length; + const chunkAt = x.wasm_chunk(); + let off = 0; + while (off < buf.length) { + const n = Math.min(CHUNK, buf.length - off); + new Uint8Array(x.memory.buffer).set(buf.subarray(off, off + n), chunkAt); + if (x.wasm_feed(n) !== 0) { log('problem', 'the server input is full; dropped ' + (buf.length - off) + ' bytes'); return false; } + off += n; + } + after(x.wasm_resume()); + return true; + }, + }; + proc.stdout = out; + proc.stderr = err; + proc.kill = () => { + if (dead) return; + dead = true; + log('message', 'server killed after ' + steps + ' steps'); + proc.emit('exit', null); + }; + + const t1 = Date.now(); + after(x.wasm_start()); + log('message', 'server ran to its first wait in ' + (Date.now() - t1) + ' ms'); + return proc; +} + +module.exports = { wasmProcess }; |
