aboutsummaryrefslogtreecommitdiff
path: root/editors/vscode/client
diff options
context:
space:
mode:
Diffstat (limited to 'editors/vscode/client')
-rw-r--r--editors/vscode/client/codeactions.js27
-rw-r--r--editors/vscode/client/diagcache.js12
-rw-r--r--editors/vscode/client/extension.js108
-rw-r--r--editors/vscode/client/sourceprovider.js17
-rw-r--r--editors/vscode/client/vmproc.js3
-rw-r--r--editors/vscode/client/wasmproc.js3
6 files changed, 167 insertions, 3 deletions
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()); });
}
};