aboutsummaryrefslogtreecommitdiff
path: root/editors/vscode/client
diff options
context:
space:
mode:
Diffstat (limited to 'editors/vscode/client')
-rw-r--r--editors/vscode/client/diagcache.js137
-rw-r--r--editors/vscode/client/extension.js537
-rw-r--r--editors/vscode/client/hover.js21
-rw-r--r--editors/vscode/client/vmproc.js81
-rw-r--r--editors/vscode/client/wasmproc.js96
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 };