// the extension's diagnostics cache, end to end: the real client // (editors/vscode/client/extension.js) with a stub for the vscode module, and // the built-in wasm server. the checks: a first session asks the server and // saves the answer; a second session shows the saved answer before the server // starts; a change to an included file is checked again; putting it back is // answered from the cache. // // node tests/drivers/ext_cache_test.js [server-dir] // // the server directory holds what the packaged extension carries in server/: // lsp.wasm, or tinyvm.js, layout.js, lsp.json and lsp.bin (the default is // build/editors/vscode/server, which src/tools/mkvsix.sh fills). the extension is // copied with that server into a scratch directory, since the tree has none. const fs = require('fs'); const os = require('os'); const path = require('path'); const Module = require('module'); const serverDir = path.resolve(process.argv[2] || path.join(__dirname, '..', '..', 'build', 'editors', 'vscode', 'server')); if (!fs.existsSync(serverDir)) { console.log('FAIL no server directory at ' + serverDir); process.exit(1); } const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'zc-extension-')); fs.cpSync(path.join(__dirname, '..', '..', 'editors', 'vscode'), staged, { recursive: true }); fs.cpSync(serverDir, path.join(staged, 'server'), { recursive: true }); process.on('exit', () => fs.rmSync(staged, { recursive: true, force: true })); const client = path.join(staged, 'client'); // the vscode module, as far as the client uses it const world = { lines: [], sets: [], listeners: {}, watchers: [], textDocuments: [], saved: undefined, root: '' }; const stub = { EventEmitter: class { constructor() { this.event = () => ({ dispose() {} }); } fire() {} }, Diagnostic: class { constructor(range, message, severity) { Object.assign(this, { range, message, severity }); } }, Range: class { constructor(a, b, c, d) { Object.assign(this, { start: { line: a, character: b }, end: { line: c, character: d } }); } }, Location: class { constructor(uri, range) { Object.assign(this, { uri, range }); } }, DiagnosticRelatedInformation: class { constructor(location, message) { Object.assign(this, { location, message }); } }, DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, SemanticTokensLegend: class { constructor(t, m) { this.tokenTypes = t; this.tokenModifiers = m; } }, SemanticTokensBuilder: class { constructor() {} push() {} build() { return null; } }, MarkdownString: class { constructor(v = '') { this.value = v; this.escaped = false; } appendText(v) { this.value += v; this.escaped = true; return this; } appendMarkdown(v) { this.value += v; return this; } appendCodeblock(v, language) { this.value += '\n```' + language + '\n' + v + '\n```\n'; return this; } }, CompletionItem: class { constructor(label, kind) { Object.assign(this, { label, kind }); } }, CompletionList: class { constructor(items, isIncomplete) { Object.assign(this, { items, isIncomplete }); } }, CompletionItemKind: { Variable: 5 }, CompletionItemTag: { Deprecated: 1 }, CodeActionKind: { QuickFix: 'quickfix' }, CodeAction: class { constructor(title, kind) { Object.assign(this, { title, kind }); } }, WorkspaceEdit: class { constructor() { this.edits = []; } replace(uri, range, newText) { this.edits.push({ uri, range, newText }); } }, Hover: class { constructor(c) { this.contents = c; } }, Uri: { file: p => ({ toString: () => 'file://' + p, fsPath: p }), parse: s => ({ toString: () => s, fsPath: decodeURIComponent(s.replace(/^file:\/\//, '')) }) }, workspace: { workspaceFolders: undefined, textDocuments: world.textDocuments, getConfiguration: () => ({ get: () => undefined }), createFileSystemWatcher: () => { const w = { handlers: {}, dispose() {} }; world.watchers.push(w); for (const k of ['onDidChange', 'onDidCreate', 'onDidDelete']) w[k] = f => { w.handlers[k] = f; return { dispose() {} }; }; return w; }, registerTextDocumentContentProvider: () => ({ dispose() {} }), ...['onDidChangeConfiguration', 'onDidOpenTextDocument', 'onDidChangeTextDocument', 'onDidSaveTextDocument', 'onDidCloseTextDocument'] .reduce((o, k) => { o[k] = f => { world.listeners[k] = f; return { dispose() {} }; }; return o; }, {}), }, window: { createOutputChannel: () => ({ appendLine: s => world.lines.push(String(s)), show() {}, dispose() {} }), showWarningMessage() {}, showErrorMessage() {}, }, languages: { createDiagnosticCollection: () => ({ set: (uri, list) => world.sets.push({ uri: uri.toString(), list, at: world.lines.length }), delete() {}, dispose() {}, }), registerDocumentSemanticTokensProvider: () => ({ dispose() {} }), registerCompletionItemProvider: (sel, p) => { world.completion = p; return { dispose() {} }; }, registerCodeActionsProvider: (sel, p) => { world.actions = p; return { dispose() {} }; }, registerHoverProvider: (sel, p) => { world.hover = p; return { dispose() {} }; }, registerDocumentFormattingEditProvider: () => ({ dispose() {} }), registerDefinitionProvider: (sel, p) => { world.definition = p; return { dispose() {} }; }, registerReferenceProvider: (sel, p) => { world.references = p; return { dispose() {} }; }, }, commands: { registerCommand: () => ({ dispose() {} }) }, }; const realResolve = Module._resolveFilename; Module._resolveFilename = function (req, ...rest) { return req === 'vscode' ? 'vscode' : realResolve.call(this, req, ...rest); }; require.cache['vscode'] = { id: 'vscode', filename: 'vscode', loaded: true, exports: stub }; let passed = 0, failed = 0; function check(name, ok, detail) { if (ok) { passed++; console.log('ok ' + name); } else { failed++; console.log('FAIL ' + name + (detail ? ': ' + detail : '')); } } const sleep = ms => new Promise(r => setTimeout(r, ms)); async function until(pred, ms = 20000) { const end = Date.now() + ms; while (Date.now() < end) { if (pred()) return true; await sleep(50); } return pred(); } function freshClient() { for (const f of Object.keys(require.cache)) if (f.startsWith(client)) delete require.cache[f]; return require(path.join(client, 'extension.js')); } const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zc-extcache-')); stub.workspace.workspaceFolders = [{ uri: { fsPath: root } }]; const helper = path.join(root, 'helper.zc'); const main = path.join(root, 'main.zc'); const helperOk = 'fn helper() { return 1; }\n'; const helperBad = 'fn helper2() { return 1; }\n'; fs.writeFileSync(helper, helperOk); const mainText = 'include "helper.zc";\nfn main() { return helper(); }\n'; fs.writeFileSync(main, mainText); function doc(p, text) { const d = { uri: { toString: () => 'file://' + p, fsPath: p }, languageId: 'zc', version: 1, text, getText() { return this.text; } }; return d; } const ctx = () => ({ subscriptions: [], globalState: { get: () => world.saved, update: (k, v) => { world.saved = JSON.parse(JSON.stringify(v)); return Promise.resolve(); } }, }); const mainDoc = doc(main, mainText); const mainUri = 'file://' + main; (async () => { // session one: nothing cached, so the server is asked world.textDocuments.push(mainDoc); let ext = freshClient(); let c1 = ctx(); ext.activate(c1); check('session one: the server starts and checks the document', await until(() => world.lines.some(l => /server ready/.test(l))) && await until(() => world.sets.some(s => s.uri === mainUri))); const first = world.sets.filter(s => s.uri === mainUri).pop(); check('session one: the server answered with a list', first && Array.isArray(first.list), JSON.stringify(first && first.list)); await sleep(800); const savedKeys = Object.keys(world.saved || {}); // the hover is the server's markdown, parsed: a zc code block, not escaped text const hv = world.hover && await world.hover.provideHover(mainDoc, { line: 1, character: 4 }); const hc = hv && hv.contents[0]; check('the hover is markdown with a zc code block, parsed and not escaped', hc && !hc.escaped && /^```zc\n/.test(hc.value) && /\n```/.test(hc.value), JSON.stringify(hc)); const suggestions = await world.completion.provideCompletionItems(mainDoc, { line: 1, character: 4 }, { isCancellationRequested: false }); check('completion provider returns signatures and replacement ranges', suggestions instanceof stub.CompletionList && suggestions.items.some(c => c.detail && c.range && c.insertText), JSON.stringify(suggestions && suggestions.items.slice(0, 2))); check('cancelled completion requests are ignored', await world.completion.provideCompletionItems(mainDoc, { line: 1, character: 4 }, { isCancellationRequested: true }) === null); check('session one: the answer is saved under a key', savedKeys.length === 1 && /^[0-9a-f]{64}$/.test(savedKeys[0]), JSON.stringify(savedKeys)); ext.deactivate(); await sleep(700); // session two: the saved answer is shown before the server has started world.lines.length = 0; world.sets.length = 0; ext = freshClient(); ext.activate(ctx()); check('session two: the saved diagnostics are shown at once', world.sets.some(s => s.uri === mainUri) && world.lines.some(l => /from the cache/.test(l))); check('session two: the server has not started yet', !world.lines.some(l => /server ready/.test(l))); await until(() => world.lines.some(l => /server ready/.test(l))); await sleep(300); check('session two: the server was not asked about the document', !world.lines.some(l => /-> textDocument\/didOpen|-> textDocument\/didChange/.test(l)), JSON.stringify(world.lines.slice(0, 6))); check('session two: the cache holds the same key as before', JSON.stringify(Object.keys(world.saved || {})) === JSON.stringify(savedKeys)); // a change to the included file, saved: the document is checked again and the error shows world.lines.length = 0; world.sets.length = 0; fs.writeFileSync(helper, helperBad); for (const h of world.watchers) if (h.handlers.onDidChange) h.handlers.onDidChange({ fsPath: helper }); check('an included file changed: the document is checked again', await until(() => world.lines.some(l => /-> textDocument\/didOpen|-> textDocument\/didChange/.test(l)) && world.sets.some(s => s.uri === mainUri)), JSON.stringify(world.lines.slice(-4))); await sleep(900); check('an included file changed: the new state is saved under its own key', Object.keys(world.saved || {}).length === 2, JSON.stringify(Object.keys(world.saved || {}))); // the file goes back: its tree is the one the cache has, so the old answer is shown from the cache world.lines.length = 0; world.sets.length = 0; fs.writeFileSync(helper, helperOk); for (const h of world.watchers) if (h.handlers.onDidChange) h.handlers.onDidChange({ fsPath: helper }); await sleep(200); check('the old tree is answered from the cache, without asking the server', world.lines.some(l => /from the cache/.test(l)) && !world.lines.some(l => /-> textDocument\/didChange/.test(l)), JSON.stringify(world.lines.slice(-4))); const expiredDoc = doc(path.join(root, 'expired.zc'), 'include rt; fn main() {\n let used = rt.new(int, 3);\n rt.free(used);\n \n}'); const expiredList = await world.completion.provideCompletionItems(expiredDoc, { line: 3, character: 1 }, { isCancellationRequested: false }); const expiredItem = expiredList && expiredList.items.find(c => c.label === 'used'); check('completion maps consumed lifetimes to the editor strikethrough tag', expiredItem && expiredItem.tags?.[0] === stub.CompletionItemTag.Deprecated && /lifetime ended/.test(expiredItem.documentation?.value)); // a borrow error keeps the server's related information: where the owner ended and where it was declared const borrowPath = path.join(root, 'borrow.zc'); const borrowDoc = doc(borrowPath, 'include rt;\nfn main() {\n\tchar* t = rt.new(4); t[0] = 65;\n\tchar* r = t + 1;\n\trt.free(t);\n\treturn r[0];\n}\n'); fs.writeFileSync(borrowPath, borrowDoc.text); world.textDocuments.push(borrowDoc); if (world.listeners.onDidOpenTextDocument) world.listeners.onDidOpenTextDocument(borrowDoc); await until(() => world.sets.some(s => s.uri === 'file://' + borrowPath && s.list.length)); const borrowSet = world.sets.filter(s => s.uri === 'file://' + borrowPath).pop(); const borrowDiag = borrowSet && borrowSet.list.find(d => /r borrows t/.test(d.message)); const related = borrowDiag && borrowDiag.relatedInformation; check('borrow diagnostics pass related information through to the editor', related && related.length === 2 && related[0] instanceof stub.DiagnosticRelatedInformation && related[0].message === 'owner t was freed here' && related[0].location.range.start.line === 4 && related[1].message === 'owner t is declared here', JSON.stringify(borrowSet && borrowSet.list)); const actionDoc = doc(path.join(root, 'imports.zc'), 'fn main() { rt.put_n(7); }'); const actions = await world.actions.provideCodeActions(actionDoc, new stub.Range(0, 15, 0, 15), {}, { isCancellationRequested: false }); check('quick fixes reach the editor as workspace edits', actions.some(a => a.kind === 'quickfix' && a.edit.edits.some(e => e.newText === 'include rt;\n'))); const cancelled = await world.actions.provideCodeActions(actionDoc, new stub.Range(0, 15, 0, 15), {}, { isCancellationRequested: true }); check('cancelled quick-fix requests produce no editor edits', cancelled.length === 0); ext.deactivate(); await sleep(700); fs.rmSync(root, { recursive: true, force: true }); console.log(passed + ' passed, ' + failed + ' failed'); process.exit(failed ? 1 : 0); })().catch(e => { console.log('FAIL harness: ' + (e.stack || e)); process.exit(1); });