// tests for the language server run by the javascript vm (build/js/tinyvm.js), through // the extension's own host (editors/vscode/client/vmproc.js): the same checks as // tests/drivers/lsp_wasm.js. the vm starts at the image's heap and grows as the server asks. // usage: node tests/drivers/lsp_vm.js path/to/server/dir (tinyvm.js, lsp.json, layout.js) const path = require('path'); const { vmProcess } = require(path.join(__dirname, '..', '..', 'editors', 'vscode', 'client', 'vmproc.js')); const fs = require('fs'); const dir = path.resolve(process.argv[2]); let ok = 0, bad = 0; function check(cond, label, extra) { if (cond) { ok++; console.log('ok lsp vm ' + label); } else { bad++; console.log('FAIL lsp vm ' + label + (extra === undefined ? '' : ': ' + JSON.stringify(extra).slice(0, 300))); } } // runs one session: the messages are sent, and the answers collected by id function session(docs) { const replies = {}, notes = []; const p = vmProcess(dir, { log: () => {} }); const exited = { code: undefined }; p.on('exit', c => { exited.code = c; }); let inbox = Buffer.alloc(0); p.stdout.on('data', d => { inbox = Buffer.concat([inbox, d]); for (;;) { const e = inbox.indexOf('\r\n\r\n'); if (e < 0) return; const n = parseInt(/Content-Length: (\d+)/.exec(inbox.slice(0, e).toString())[1], 10); if (inbox.length < e + 4 + n) return; const m = JSON.parse(inbox.slice(e + 4, e + 4 + n).toString('utf8')); inbox = inbox.slice(e + 4 + n); if (m.id !== undefined) replies[m.id] = m; else notes.push(m); } }); const send = m => { const b = Buffer.from(JSON.stringify(m), 'utf8'); p.stdin.write(Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b])); }; send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { processId: 1, capabilities: {} } }); send({ jsonrpc: '2.0', method: 'initialized', params: {} }); for (const [uri, text] of docs) send({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'zc', version: 1, text } } }); return { p, replies, notes, send, exited }; } const clean = 'int main() {\n\tint x = 1;\n\treturn x;\n}\n'; const bad_src = 'int main() {\n\tint x = 1\n\treturn x;\n}\n'; const tokOf = (s, uri) => { s.send({ jsonrpc: '2.0', id: 2, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri } } }); s.send({ jsonrpc: '2.0', id: 3, method: 'textDocument/hover', params: { textDocument: { uri }, position: { line: 1, character: 9 } } }); s.send({ jsonrpc: '2.0', id: 4, method: 'shutdown', params: null }); s.send({ jsonrpc: '2.0', method: 'exit' }); }; const diagsOf = (s, uri) => s.notes.filter(m => m.method === 'textDocument/publishDiagnostics' && m.params.uri === uri); // a clean program: initialize, no diagnostics, tokens, and it exits on its own { const s = session([['file:///a.zc', clean]]); tokOf(s, 'file:///a.zc'); const init = s.replies[1]; check(init && init.result.capabilities.semanticTokensProvider && init.result.capabilities.hoverProvider, 'initialize advertises tokens and hover', init); check(diagsOf(s, 'file:///a.zc').length === 1 && diagsOf(s, 'file:///a.zc')[0].params.diagnostics.every(x => x.severity !== 1), 'a clean program has no errors (hints are fine)'); const t = s.replies[2]; check(t && t.result && t.result.data.length > 0 && t.result.data.length % 5 === 0, 'semantic tokens come back in groups of five', t && t.result && t.result.data.length); check(s.replies[4] && s.replies[4].result === null, 'shutdown is answered'); check(s.p.stdin.write(Buffer.alloc(0)) === false, 'the server has exited after exit'); } // a broken program: the compiler's own message is the diagnostic { const s = session([['file:///b.zc', bad_src]]); const d = diagsOf(s, 'file:///b.zc'); check(d.length === 1 && d[0].params.diagnostics.length > 0, 'a missing semicolon gives a diagnostic'); check(d.length === 1 && d[0].params.diagnostics.some(x => /expected/.test(x.message)), 'the message is the compiler\'s', d); s.send({ jsonrpc: '2.0', method: 'exit' }); } // hover: a name the document declares, and a compiler name with docs in the image { const src = '// adds one to its argument\nint inc(int x) { return x + 1; }\nint main() {\n\treturn inc(2);\n}\n'; const s = session([['file:///c.zc', src]]); s.send({ jsonrpc: '2.0', id: 5, method: 'textDocument/hover', params: { textDocument: { uri: 'file:///c.zc' }, position: { line: 3, character: 9 } } }); s.send({ jsonrpc: '2.0', method: 'exit' }); const h = s.replies[5]; check(h && h.result && /adds one/.test(h.result.contents.value), 'hover shows the comment above a declaration', h); const s2 = session([['file:///d.zc', 'int main() {\n\tint z = docofs("x");\n\treturn 0;\n}\n']]); s2.send({ jsonrpc: '2.0', id: 6, method: 'textDocument/hover', params: { textDocument: { uri: 'file:///d.zc' }, position: { line: 1, character: 10 } } }); s2.send({ jsonrpc: '2.0', method: 'exit' }); const h2 = s2.replies[6]; check(h2 && h2.result && /packed documentation/.test(h2.result.contents.value), 'hover shows the docs of a compiler name', h2); } // definition navigation returns the embedded runtime source through the same vm host. { const src = 'include rt; fn main() { return rt.s_to_n(b"42"); }\n'; const s = session([['file:///nav.zc', src]]); const at = src.indexOf('s_to_n'), before = src.slice(0, at); s.send({ jsonrpc: '2.0', id: 7, method: 'textDocument/definition', params: { textDocument: { uri: 'file:///nav.zc' }, position: { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 } } }); s.send({ jsonrpc: '2.0', id: 8, method: 'zc/source', params: { path: '/rt.zc' } }); s.send({ jsonrpc: '2.0', method: 'exit' }); const loc = s.replies[7] && s.replies[7].result && s.replies[7].result[0]; check(loc && loc.uri === 'tinyvm:/rt.zc', 's_to_n opens the embedded runtime source', loc); const source = s.replies[8] && s.replies[8].result; const line = source && loc && source.split('\n')[loc.range.start.line]; check(line && line.slice(loc.range.start.character, loc.range.end.character) === 's_to_n' && source === fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'runtime', 'rt.zc'), 'utf8'), 'the virtual runtime source matches its declaration'); } // a document that is bigger than one feed: the input is split and comes out whole { let big = 'int main() {\n'; for (let i = 0; i < 4000; i++) big += '\tint v' + i + ' = ' + i + ';\n'; big += '\treturn 0;\n}\n'; const s = session([['file:///e.zc', big]]); s.send({ jsonrpc: '2.0', method: 'exit' }); check(diagsOf(s, 'file:///e.zc').length === 1, 'a document bigger than one feed is read whole (' + big.length + ' bytes)'); } // the regression: the server halted without an answer on any document that // needed more memory than its first heap (examples/bf.zc, and a document twice // the size of the earlier one). each must be checked and published, and the // server must still be running until it is told to exit { const bfPath = path.join(__dirname, '..', '..', 'examples', 'bf.zc'); const bf = fs.readFileSync(bfPath, 'utf8'); const s = session([['file:///bf.zc', bf]]); s.send({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri: 'file:///bf.zc', version: 2 }, contentChanges: [{ text: bf }] } }); s.send({ jsonrpc: '2.0', method: 'exit' }); check(diagsOf(s, 'file:///bf.zc').length >= 1, 'examples/bf.zc is checked and published (' + bf.length + ' bytes)'); check(s.exited.code === 0, 'the server runs until exit, then leaves with status 0', s.exited); let huge = 'int main() {\n'; for (let i = 0; i < 12000; i++) huge += '\tint h' + i + ' = ' + i + ' + ' + (i % 7) + ';\n'; huge += '\treturn 0;\n}\n'; const s2 = session([['file:///huge.zc', huge]]); s2.send({ jsonrpc: '2.0', method: 'exit' }); check(diagsOf(s2, 'file:///huge.zc').length === 1, 'a document past the first heap is checked and published (' + huge.length + ' bytes)', s2.exited); check(s2.exited.code === 0, 'the big document leaves the server with status 0 after exit', s2.exited); } console.log(ok + ' lsp vm checks passed, ' + bad + ' failed'); process.exit(bad ? 1 : 0);