diff options
Diffstat (limited to 'editors/vscode')
| -rw-r--r-- | editors/vscode/.vscodeignore | 1 | ||||
| -rw-r--r-- | editors/vscode/README.md | 30 | ||||
| -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 | ||||
| -rw-r--r-- | editors/vscode/language-configuration.json | 30 | ||||
| -rw-r--r-- | editors/vscode/package.json | 127 | ||||
| -rw-r--r-- | editors/vscode/syntaxes/zc.tmLanguage.json | 175 |
10 files changed, 1235 insertions, 0 deletions
diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore new file mode 100644 index 0000000..f784523 --- /dev/null +++ b/editors/vscode/.vscodeignore @@ -0,0 +1 @@ +# everything in this folder ships: the client, the server image, the grammar and the readme diff --git a/editors/vscode/README.md b/editors/vscode/README.md new file mode 100644 index 0000000..d60194e --- /dev/null +++ b/editors/vscode/README.md @@ -0,0 +1,30 @@ +# zc for vs code + +this extension connects vscode to the zc language server. the server runs the compiler in check mode, so diagnostics and semantic highlighting reflect the language the compiler actually parses, including syntax introduced with parse words. + +features include compiler diagnostics, optimization hints, semantic highlighting, hover documentation and immediate textmate highlighting while the server starts. + +## install + +there is no build step for local installation. package the extension with `bash src/tools/mkvsix.sh`, then choose “extensions: install from vsix” in vscode. the package includes a webassembly server; if that file is unavailable, the extension can run the server in the tinyvm javascript vm, which is slower. a native server can be selected with `zc.lsp.serverPath`. + +## settings + +```json +{ + "zc.lsp.serverPath": "", + "zc.trace.server": "messages" +} +``` + +an empty server path uses the bundled server. if the server exits, the extension restarts it and resends open documents. + +## diagnostics cache + +diagnostics are cached per document using a sha256 key that includes the server build, document path and text, and files read through `include`. included files are resolved from the workspace folder and from the including file's directory. saving an included file triggers checks for open documents that use it. cached diagnostics are restored from extension storage when vscode starts; workspaces with more than 300 files are not cached. + +## limitations + +the bundled webassembly server cannot read included files from disk. use a native server with `zc.lsp.serverPath` when diagnostics need filesystem access for includes. hover does not follow declarations reached through `include` yet. + +see `tests/drivers/lsp_test.js` for protocol tests and `src/tools/mkvsix.sh` for packaging. 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 }; diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json new file mode 100644 index 0000000..d7876c9 --- /dev/null +++ b/editors/vscode/language-configuration.json @@ -0,0 +1,30 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"", "notIn": ["string"] }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"] + ], + "wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\#\\%\\^\\&\\*\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s]+)", + "indentationRules": { + "increaseIndentPattern": "^.*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$", + "decreaseIndentPattern": "^\\s*(\\}|\\)|\\])" + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json new file mode 100644 index 0000000..c3bb0d9 --- /dev/null +++ b/editors/vscode/package.json @@ -0,0 +1,127 @@ +{ + "name": "zc-language-support", + "displayName": "TinyVM ZC Language Support", + "description": "Language server for ZC in TinyVM: the compiler's own errors, optimization hints, hover docs and highlighting. runs in the extension, no install needed. see the ZC Language Server output for logs.", + "version": "3.3.0", + "publisher": "tinyvm", + "engines": { + "vscode": "^1.75.0" + }, + "categories": [ + "Programming Languages" + ], + "keywords": [ + "zc", + "tinyvm", + "zygote", + "compiler", + "lifetimes" + ], + "activationEvents": [ + "onLanguage:zc" + ], + "main": "./client/extension.js", + "contributes": { + "languages": [ + { + "id": "zc", + "aliases": [ + "ZC", + "zc", + "Zygote C" + ], + "extensions": [ + ".zc" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "zc", + "scopeName": "source.zc", + "path": "./syntaxes/zc.tmLanguage.json" + } + ], + "semanticTokenScopes": [ + { + "language": "zc", + "scopes": { + "keyword": [ + "keyword.control.zc" + ], + "type": [ + "entity.name.type.zc", + "storage.type.zc" + ], + "function": [ + "entity.name.function.zc" + ], + "variable": [ + "variable.name.zc" + ], + "string": [ + "string.quoted.double.zc" + ], + "number": [ + "constant.numeric.zc" + ], + "operator": [ + "keyword.operator.zc" + ], + "comment": [ + "comment.line.double-slash.zc" + ], + "parameter": [ + "variable.parameter.zc" + ], + "property": [ + "variable.other.property.zc" + ], + "macro": [ + "entity.name.function.macro.zc" + ], + "enumMember": [ + "variable.other.enummember.zc" + ] + } + } + ], + "configuration": { + "type": "object", + "title": "ZC Language Server", + "properties": { + "zc.lsp.serverPath": { + "type": "string", + "default": "", + "description": "Optional. A native zclsp executable to use instead of the built-in server (build it with src/tools/mklsp.sh). Empty runs the server inside the extension." + }, + "zc.trace.server": { + "type": "string", + "enum": [ + "off", + "messages", + "verbose" + ], + "default": "messages", + "enumDescriptions": [ + "log only problems", + "log each request and response with its time (default)", + "also log the bodies of messages" + ], + "description": "How much the zc language server logs to the ZC Language Server output channel." + } + } + }, + "commands": [ + { + "command": "zc.showLog", + "title": "zc: show language server log" + }, + { + "command": "zc.restart", + "title": "zc: restart language server" + } + ] + } +} diff --git a/editors/vscode/syntaxes/zc.tmLanguage.json b/editors/vscode/syntaxes/zc.tmLanguage.json new file mode 100644 index 0000000..49f7b37 --- /dev/null +++ b/editors/vscode/syntaxes/zc.tmLanguage.json @@ -0,0 +1,175 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "ZC", + "scopeName": "source.zc", + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#ownership" }, + { "include": "#keywords" }, + { "include": "#types" }, + { "include": "#builtins" }, + { "include": "#constants" }, + { "include": "#function-declarations" }, + { "include": "#function-calls" }, + { "include": "#operators" } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.line.double-slash.zc", + "match": "//.*$" + }, + { + "name": "comment.block.zc", + "begin": "/\\*", + "end": "\\*/" + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.byte.zc", + "begin": "b\"", + "end": "\"", + "patterns": [{ "name": "constant.character.escape.zc", "match": "\\\\." }] + }, + { + "name": "string.quoted.double.zc", + "begin": "\"", + "end": "\"", + "patterns": [{ "name": "constant.character.escape.zc", "match": "\\\\." }] + }, + { + "name": "string.quoted.single.zc", + "begin": "'", + "end": "'", + "patterns": [{ "name": "constant.character.escape.zc", "match": "\\\\." }] + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.hex.zc", + "match": "\\b0[xX][0-9a-fA-F]+\\b" + }, + { + "name": "constant.numeric.float.zc", + "match": "\\b[0-9]+\\.[0-9]+([eE][+-]?[0-9]+)?\\b|\\b[0-9]+[eE][+-]?[0-9]+\\b" + }, + { + "name": "constant.numeric.decimal.zc", + "match": "\\b[0-9]+\\b" + } + ] + }, + "ownership": { + "patterns": [ + { + "name": "storage.modifier.lifetime.tag.zc", + "match": "\\.(u|m|d)\\b" + }, + { + "name": "keyword.operator.lifetime.recast.zc", + "match": "\\b(u|m|d)\\*(?=[a-zA-Z_])" + } + ] + }, + "keywords": { + "patterns": [ + { + "name": "keyword.control.zc", + "match": "\\b(if|else|while|do|for|switch|case|default|break|continue|return)\\b" + }, + { + "name": "keyword.declaration.zc", + "match": "\\b(fn|let|struct|type|enum|const|enhance|micro|vm)\\b" + }, + { + "name": "keyword.other.zc", + "match": "\\b(as|unchecked|asm|quote|sizeof|typeof|len|cap|push|pop|resize|o3|include|memdebug|fixed|typecheck)\\b" + }, + { + "name": "keyword.operator.memory.zc", + "match": "\\b(new|free)\\b" + } + ] + }, + "types": { + "patterns": [ + { + "name": "storage.type.primitive.zc", + "match": "\\b(int|char|word|bool|void|float)\\b" + } + ] + }, + "builtins": { + "patterns": [ + { + "name": "support.function.builtin.zc", + "match": "\\b(putc|getc|eof|halt|puts|putn|strlen|streq|wcopy|wfill|new_t|new_raw|new_oz|recycle|gc|isobj|osize|typeof_obj|live_objects|install|zdiv|zmod|zshl|mem_set|memreport|checkptr|syscall|readfile|bopen|bread|bwrite|bgetenv|ld8|st8|ld16|st16|ld32|st32|slen|sbyte|snew|sput|scat|sslice|bputs|ston|ntos|atoi|itoa|strcat|starts|env|now|writefile)\\b" + } + ] + }, + "constants": { + "patterns": [ + { + "name": "support.constant.builtin.zc", + "match": "\\b(MW|HEAP_LO|HEAP_HI|ZFP|GORG|FBOT|FTOP|RS0|DSLO|DSHI|SYSGATE|MAGIC|SYS_OPEN|SYS_CLOSE|SYS_READ|SYS_WRITE|SYS_GETENV|SYS_TIME)\\b" + } + ] + }, + "function-declarations": { + "patterns": [ + { + "match": "\\bfn\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(", + "captures": { + "1": { "name": "entity.name.function.zc" } + } + } + ] + }, + "function-calls": { + "patterns": [ + { + "match": "\\b([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(", + "captures": { + "1": { "name": "entity.name.function.call.zc" } + } + } + ] + }, + "operators": { + "patterns": [ + { + "name": "keyword.operator.comparison.zc", + "match": "==|!=|<=|>=|<|>" + }, + { + "name": "keyword.operator.logical.zc", + "match": "&&|\\|\\||!" + }, + { + "name": "keyword.operator.arithmetic.zc", + "match": "\\+|-|\\*|/|%|\\+\\+|--" + }, + { + "name": "keyword.operator.bitwise.zc", + "match": "&|\\||\\^|~|<<|>>" + }, + { + "name": "keyword.operator.assignment.zc", + "match": "=|\\+=|-=|\\*=|/=|%=|&=|\\|=|\\^=|<<=|>>=" + }, + { + "name": "keyword.operator.member.zc", + "match": "->|\\." + } + ] + } + } +} |
