diff options
Diffstat (limited to 'editors/vscode')
| -rw-r--r-- | editors/vscode/README.md | 8 | ||||
| -rw-r--r-- | editors/vscode/client/codeactions.js | 27 | ||||
| -rw-r--r-- | editors/vscode/client/diagcache.js | 12 | ||||
| -rw-r--r-- | editors/vscode/client/extension.js | 108 | ||||
| -rw-r--r-- | editors/vscode/client/sourceprovider.js | 17 | ||||
| -rw-r--r-- | editors/vscode/client/vmproc.js | 3 | ||||
| -rw-r--r-- | editors/vscode/client/wasmproc.js | 3 |
7 files changed, 172 insertions, 6 deletions
diff --git a/editors/vscode/README.md b/editors/vscode/README.md index d60194e..5c3d0c7 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -2,7 +2,7 @@ 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. +features include compiler diagnostics, import quick fixes, optimization hints, semantic highlighting, hover documentation (values, types, ownership and lifetimes, module include names, embedded declarations and assembly instruction descriptions), fuzzy completions with signatures and descriptions (consumed variables are crossed out and sorted last), go to definition and find references (also into included files), document formatting and immediate textmate highlighting while the server starts. module completion lists exported members after an imported namespace. import quick fixes preserve namespace headers, comments and crlf line endings, and the editor drops edits for stale document versions. hints are shown as blue information squiggles; `hints on`, `hints small` and `hints off` in the source change which ones appear. formatting reads a `.zcformat` file (`UseTab`, `IndentWidth`, `MaxEmptyLines`, `ContinuationIndent`, `NormalizeSpacing`, `CaseIndent`, `FinalNewline`) from the document's directory upward, and falls back to the editor's tab settings. by default, case labels are indented one level inside their switch; `CaseIndent: 0` aligns them with the switch. ## install @@ -25,6 +25,8 @@ diagnostics are cached per document using a sha256 key that includes the server ## 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. +the extension sends included source files to both bundled servers. relative paths are resolved next to the including file, then in the workspace folder. `include std;` uses the embedded library; it is never automatically imported. hovering an import name shows the module overview. `core.zc` is embedded, and qualified navigation distinguishes `core.syscall` from `rt.syscall`. source libraries can provide module documentation in the overview comment after their namespace and include headers. go to definition for built-ins opens embedded runtime, compiler or library source in a read-only document. -see `tests/drivers/lsp_test.js` for protocol tests and `src/tools/mkvsix.sh` for packaging. +see `tests/drivers/lsp_test.js` for protocol tests, `tests/drivers/codeactions_client_test.js` for workspace-edit version handling, `tests/drivers/ext_cache_test.js` for the real editor provider and bundled server, and `src/tools/mkvsix.sh` for packaging. + +the native language server also accepts `--help` or `-h`; its `--format`, `--annotate` and `--types` source modes remain available. use `bin/zclsp --help` for the stdin protocol and source-mode details. `python3 tests/drivers/cli_help_test.py` checks these command-line options along with the bundled command-line tools. diff --git a/editors/vscode/client/codeactions.js b/editors/vscode/client/codeactions.js new file mode 100644 index 0000000..dd79b3f --- /dev/null +++ b/editors/vscode/client/codeactions.js @@ -0,0 +1,27 @@ +// convert protocol edits into editor actions while rejecting stale versions. +function convertCodeActions(vscode, results, document, expectedVersion = document.version) { + return (Array.isArray(results) ? results : []).flatMap(result => { + if (!result.edit) return []; + const changes = result.edit.documentChanges || Object.entries(result.edit.changes || {}).map(([uri, edits]) => ({ textDocument: { uri }, edits })); + if (changes.some(change => change.textDocument?.uri === document.uri.toString() && change.textDocument.version != null && change.textDocument.version !== expectedVersion)) return []; + const edit = new vscode.WorkspaceEdit(); + let count = 0; + for (const change of changes) { + if (!change.textDocument || !Array.isArray(change.edits)) continue; + const uri = vscode.Uri.parse(change.textDocument.uri); + for (const replacement of change.edits) { + const range = replacement.range; + if (!range || typeof replacement.newText !== 'string') continue; + edit.replace(uri, new vscode.Range(range.start.line, range.start.character, range.end.line, range.end.character), replacement.newText); + count++; + } + } + if (!count) return []; + const action = new vscode.CodeAction(result.title, vscode.CodeActionKind.QuickFix); + action.edit = edit; + action.isPreferred = !!result.isPreferred; + return [action]; + }); +} + +module.exports = { convertCodeActions }; diff --git a/editors/vscode/client/diagcache.js b/editors/vscode/client/diagcache.js index 6e77207..2e1758a 100644 --- a/editors/vscode/client/diagcache.js +++ b/editors/vscode/client/diagcache.js @@ -42,6 +42,7 @@ function includeNames(text) { while ((m = tokens.exec(text)) !== null) { const token = m[0]; if (token.startsWith('//') || token.startsWith('/*')) continue; + // include std; names the library that is part of the compiler: there is no file to read if (include && token.startsWith('"')) { names.push(token.slice(1, -1).replace(/\\([\\"nrt0])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t', 0: '\0' })[c] || c)); } @@ -80,13 +81,20 @@ function sourceTree(text, docPath, workspace) { // 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. +// texts maps each file that exists to its text, which a server with no files +// of its own is sent before it checks the document. 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 texts = new Map(); + for (const [p, hash] of tree.files) { + if (hash === 'missing') continue; + try { texts.set(p, fs.readFileSync(p, 'utf8')); } catch (e) { /* gone since it was hashed */ } + } + if (!tree.complete) return { key: null, deps, texts }; 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 }; + return { key: sha256(parts.join('\n')), deps, texts }; } // the cache itself: key -> diagnostics, least recently used first. saved is diff --git a/editors/vscode/client/extension.js b/editors/vscode/client/extension.js index c51b12e..3846eca 100644 --- a/editors/vscode/client/extension.js +++ b/editors/vscode/client/extension.js @@ -13,10 +13,12 @@ const vscode = require('vscode'); const cp = require('child_process'); const path = require('path'); const fs = require('fs'); +const { convertCodeActions } = require('./codeactions'); const { vmProcess } = require('./vmproc'); const { wasmProcess } = require('./wasmproc'); const { DiagCache, diagKey, fileStamp, sha256 } = require('./diagcache'); const { hoverContents } = require('./hover'); +const { createSourceProvider } = require('./sourceprovider'); const CHANNEL = 'ZC Language Server'; const SLOW_MS = 2000; // a request that takes longer than this is logged as slow @@ -40,6 +42,8 @@ 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 docFiles = new Map(); // uri -> the texts of the files the document includes, sent to the server before it checks the document +const sentFiles = new Map(); // path -> the text the server was last sent for it 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 @@ -190,6 +194,7 @@ function keyOf(doc) { const r = diagKey(serverStamp, doc.getText(), doc.uri.fsPath, workspaceDir()); docKeys.set(uri, r.key); docDeps.set(uri, r.deps); + docFiles.set(uri, r.texts); return r.key; } @@ -238,6 +243,17 @@ function showCached(doc) { return true; } +// the server of this extension has no files of its own: it is sent the text of every file a document +// includes, keyed by its full path, and looks an include up there. only what changed is sent again. +function sendIncludes(uri) { + const texts = docFiles.get(uri); + if (!texts) return; + for (const [p, text] of texts) { + if (sentFiles.get(p) === text) continue; + sentFiles.set(p, text); + send({ jsonrpc: '2.0', method: 'zc/file', params: { path: p.split(path.sep).join('/'), text } }); + } +} // 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; @@ -248,6 +264,7 @@ function sendDoc(doc, key) { q.push({ key, version }); sentKeys.set(uri, q); serverKeys.set(uri, key); + sendIncludes(uri); if (!opened.has(uri)) { opened.add(uri); send({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'zc', version, text: doc.getText() } } }); @@ -346,6 +363,7 @@ function startServer() { opened.clear(); serverKeys.clear(); sentKeys.clear(); + sentFiles.clear(); docVersions.clear(); serverStamp = serverStampFor(bin); const t0 = Date.now(); @@ -398,7 +416,7 @@ function startServer() { } }); watchInit(gen, !!bin); - request('initialize', { processId: process.pid, capabilities: {} }).then(res => { + request('initialize', { processId: process.pid, rootUri: workspaceDir() ? vscode.Uri.file(workspaceDir()).toString() : null, capabilities: {} }).then(res => { if (gen !== generation || stopping || !proc) return; if (initTimer) { clearTimeout(initTimer); initTimer = null; } const caps = res && res.capabilities ? Object.keys(res.capabilities) : []; @@ -527,6 +545,94 @@ function activate(context) { } }; context.subscriptions.push(vscode.languages.registerHoverProvider({ language: 'zc' }, hover)); + + // the sources the server carries (the runtime, compiler, standard library and icon module): shown read only at tinyvm:/NAME.zc + // when go to definition lands on a built-in. the text comes from the server, so it is the code the editor's checks run on + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider('tinyvm', createSourceProvider(request, () => ready))); + + // completions refresh as the prefix changes; deprecated lifetime entries stay visible at the end. + context.subscriptions.push(vscode.languages.registerCompletionItemProvider({ language: 'zc' }, { + async provideCompletionItems(doc, position, token) { + if (!isZc(doc) || !ready || token.isCancellationRequested) return null; + ensureServer(doc); + const version = doc.version; + try { + const res = await request('textDocument/completion', { textDocument: { uri: doc.uri.toString() }, position: { line: position.line, character: position.character } }); + if (!res || token.isCancellationRequested || doc.version !== version) return null; + const items = (res.items || []).map(c => { + const item = new vscode.CompletionItem(c.label, c.kind ? c.kind - 1 : vscode.CompletionItemKind.Variable); + item.detail = c.detail; + item.sortText = c.sortText; + item.filterText = c.filterText; + if (c.tags && c.tags.includes(1)) item.tags = [vscode.CompletionItemTag.Deprecated]; + if (c.documentation) { + item.documentation = new vscode.MarkdownString(c.documentation.value || c.documentation); + item.documentation.isTrusted = false; + } + if (c.textEdit) { + const r = c.textEdit.range; + item.range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); + item.insertText = c.textEdit.newText; + } + return item; + }); + return new vscode.CompletionList(items, !!res.isIncomplete); + } catch (err) { return null; } + } + }, '.', '>')); + + // compiler-backed import fixes use the same document version as their diagnostics. + if (vscode.languages.registerCodeActionsProvider) context.subscriptions.push(vscode.languages.registerCodeActionsProvider({ language: 'zc' }, { + async provideCodeActions(doc, range, actionContext, token) { + if (!isZc(doc) || !ready || token.isCancellationRequested) return []; + ensureServer(doc); + const version = doc.version; + const serverVersion = docVersions.get(doc.uri.toString()); + try { + const results = await request('textDocument/codeAction', { + textDocument: { uri: doc.uri.toString() }, + range: { start: { line: range.start.line, character: range.start.character }, end: { line: range.end.line, character: range.end.character } }, + context: { diagnostics: [] } + }); + if (token.isCancellationRequested || doc.version !== version || docVersions.get(doc.uri.toString()) !== serverVersion) return []; + return convertCodeActions(vscode, results, doc, serverVersion); + } catch (err) { return []; } + } + }, { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] })); + + // go to definition and find references: the compiler's own records, including names from included files + const locations = (res) => (Array.isArray(res) ? res : []).map(l => new vscode.Location(vscode.Uri.parse(l.uri), + new vscode.Range(l.range.start.line, l.range.start.character, l.range.end.line, l.range.end.character))); + context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'zc' }, { + async provideDefinition(doc, position) { + if (!isZc(doc) || !ready) return null; + ensureServer(doc); + try { + return locations(await request('textDocument/definition', { textDocument: { uri: doc.uri.toString() }, position: { line: position.line, character: position.character } })); + } catch (err) { return null; } + } + })); + // formatting: the server's pretty-printer, with the editor's tab settings (a .zcformat file overrides them) + context.subscriptions.push(vscode.languages.registerDocumentFormattingEditProvider({ language: 'zc' }, { + async provideDocumentFormattingEdits(doc, options) { + if (!isZc(doc) || !ready) return []; + ensureServer(doc); + try { + const res = await request('textDocument/formatting', { textDocument: { uri: doc.uri.toString() }, options: { tabSize: options.tabSize, insertSpaces: options.insertSpaces } }); + return (Array.isArray(res) ? res : []).map(e => vscode.TextEdit.replace( + new vscode.Range(e.range.start.line, e.range.start.character, e.range.end.line, e.range.end.character), e.newText)); + } catch (err) { return []; } + } + })); + context.subscriptions.push(vscode.languages.registerReferenceProvider({ language: 'zc' }, { + async provideReferences(doc, position, refContext) { + if (!isZc(doc) || !ready) return null; + ensureServer(doc); + try { + return locations(await request('textDocument/references', { textDocument: { uri: doc.uri.toString() }, position: { line: position.line, character: position.character }, context: { includeDeclaration: !!refContext.includeDeclaration } })); + } catch (err) { return null; } + } + })); } function deactivate() { diff --git a/editors/vscode/client/sourceprovider.js b/editors/vscode/client/sourceprovider.js new file mode 100644 index 0000000..d03d746 --- /dev/null +++ b/editors/vscode/client/sourceprovider.js @@ -0,0 +1,17 @@ +// serves source documents carried by the language server at tinyvm:/NAME.zc. +function createSourceProvider(request, isReady, delay = ms => new Promise(resolve => setTimeout(resolve, ms))) { + return { + async provideTextDocumentContent(uri) { + for (let waited = 0; !isReady() && waited < 10000; waited += 100) await delay(100); + if (!isReady()) return '// the language server is not running\n'; + try { + const result = await request('zc/source', { path: uri.path }); + return typeof result === 'string' ? result : '// no such built-in source\n'; + } catch (error) { + return '// the language server could not give this source\n'; + } + } + }; +} + +module.exports = { createSourceProvider }; diff --git a/editors/vscode/client/vmproc.js b/editors/vscode/client/vmproc.js index d4806e4..8ce8c52 100644 --- a/editors/vscode/client/vmproc.js +++ b/editors/vscode/client/vmproc.js @@ -51,6 +51,9 @@ function vmProcess(dir, opts = {}) { 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); + } else if (status === 3 && vm.polling) { + // the server asked whether input is waiting: let the event loop deliver it, then go on + setImmediate(() => { if (!dead && vm.polling) after(vm.resume()); }); } }; diff --git a/editors/vscode/client/wasmproc.js b/editors/vscode/client/wasmproc.js index 0aa674a..e8250bc 100644 --- a/editors/vscode/client/wasmproc.js +++ b/editors/vscode/client/wasmproc.js @@ -58,6 +58,9 @@ function wasmProcess(file, opts = {}) { 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); + } else if (status === 3 && x.wasm_polling && x.wasm_polling()) { + // the server asked whether input is waiting: let the event loop deliver it, then go on + setImmediate(() => { if (!dead && x.wasm_polling && x.wasm_polling()) after(x.wasm_resume()); }); } }; |
