// protocol tests for the language server (node, no packages). // usage: node tests/drivers/lsp_test.js path/to/zclsp // interpreted hosts (no jit) are far slower; ZC_TEST_TIMEOUT_SCALE stretches every deadline const TIMEOUT_SCALE = Number(process.env.ZC_TEST_TIMEOUT_SCALE) || 1; const { spawnSync } = require('child_process'); const fs = require('fs'); const server = process.argv[2]; let ok = 0, bad = 0; function check(cond, label, extra) { if (cond) { ok++; console.log('ok lsp ' + label); } else { bad++; console.log('FAIL lsp ' + label + (extra === undefined ? '' : ': ' + JSON.stringify(extra))); } } function frame(m) { const b = Buffer.from(JSON.stringify(m), 'utf8'); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); } function parse(buf) { const out = []; let i = 0; while (i < buf.length) { const j = buf.indexOf('\r\n\r\n', i); if (j < 0) break; const n = parseInt(/Content-Length: (\d+)/.exec(buf.slice(i, j).toString())[1], 10); out.push(JSON.parse(buf.slice(j + 4, j + 4 + n).toString('utf8'))); i = j + 4 + n; } return out; } function run(msgs) { const r = spawnSync(server, [], { input: Buffer.concat(msgs.map(frame)), timeout: 30000 * TIMEOUT_SCALE, maxBuffer: 1 << 28 }); return { code: r.status, msgs: parse(r.stdout) }; } const init = id => ({ jsonrpc: '2.0', id, method: 'initialize', params: {} }); const open = (uri, text) => ({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'zc', version: 1, text } } }); const change = (uri, text) => ({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version: 2 }, contentChanges: [{ text }] } }); const sem = (id, uri) => ({ jsonrpc: '2.0', id, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri } } }); const diags = (msgs, uri) => msgs.filter(m => m.method === 'textDocument/publishDiagnostics' && (!uri || m.params.uri === uri)); const A = 'file:///a.zc', B2 = 'file:///b.zc'; // initialize: capabilities and the 12 type legend let r = run([init(1), { jsonrpc: '2.0', method: 'exit' }]); let res = r.msgs.find(m => m.id === 1); check(res && res.result.capabilities.semanticTokensProvider.legend.tokenTypes.length === 12, 'initialize advertises the legend'); check(res && res.result.capabilities.textDocumentSync.change === 1, 'initialize asks for full-text sync'); // a request before initialize is refused, and the id is echoed r = run([{ jsonrpc: '2.0', id: 7, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri: A } } }]); check(r.msgs.length === 1 && r.msgs[0].error && r.msgs[0].error.code === -32002 && r.msgs[0].id === 7, 'a request before initialize gets -32002'); // unknown methods: MethodNotFound with numeric and string ids, silence for notifications r = run([init(1), { jsonrpc: '2.0', id: 'abc', method: 'workspace/nothing' }, { jsonrpc: '2.0', method: 'workspace/nothing' }]); const nf = r.msgs.find(m => m.id === 'abc'); check(nf && nf.error && nf.error.code === -32601, 'unknown request gets MethodNotFound, string id echoed', nf); check(r.msgs.length === 2, 'an unknown notification is ignored'); // a clean program has no diagnostics const good = 'fn main() {\n\treturn 1 + 2;\n}\n'; r = run([init(1), open(A, good)]); let d = diags(r.msgs, A); check(d.length === 1 && d[0].params.diagnostics.length === 0, 'a clean program publishes an empty list', d); // an error lands on the right line and column const bad1 = 'fn main() {\n\tlet x = 5\n\treturn x;\n}\n'; r = run([init(1), open(A, bad1)]); d = diags(r.msgs, A); const e1 = d.length && d[0].params.diagnostics[0]; check(e1 && e1.severity === 1 && e1.range.start.line === 1 && /expected ';', found 'return'/.test(e1.message), 'a missing semicolon is reported on its line', e1); // utf-16 columns: an emoji (two code units) and an accented letter before the error const uni = '// café 😀\nfn main() { let text = "é😀"; return text[0] + undefined_name; }\n'; r = run([init(1), open(A, uni)]); d = diags(r.msgs, A); const e2 = d.length && d[0].params.diagnostics[0]; const col = uni.split('\n')[1].indexOf('undefined_name'); check(e2 && e2.range.start.line === 1 && e2.range.start.character === col, 'columns count utf-16 units', e2 && e2.range); // fixing the text through didChange clears the error r = run([init(1), open(A, bad1), change(A, good)]); d = diags(r.msgs, A); check(d.length === 2 && d[0].params.diagnostics.length > 0 && d[1].params.diagnostics.length === 0, 'didChange re-checks the document'); // two documents do not leak into each other, and checks repeat the same way r = run([init(1), open(A, bad1), open(B2, good), change(A, bad1)]); const da = diags(r.msgs, A), db = diags(r.msgs, B2); check(da.length === 2 && JSON.stringify(da[0].params.diagnostics) === JSON.stringify(da[1].params.diagnostics) && db[0].params.diagnostics.length === 0, 'documents are checked in isolation'); // syntax modifications made by the program itself apply to the check, like the compiler const pipeDef = 'include run; immediate fn pipe_op() { run.next(); run.add_binop("|>", 1, "c"); return 0; }\npipe_op\n'; const pipeUse = 'fn double(x) { return x * 2; }\nfn main() { return 4 |> double; }\n'; r = run([init(1), open(A, pipeDef + pipeUse), open(B2, pipeUse)]); check(diags(r.msgs, A)[0].params.diagnostics.filter(x => x.severity === 1).length === 0, 'a parse word that adds an operator is understood'); check(diags(r.msgs, B2)[0].params.diagnostics.filter(x => x.severity === 1).length > 0, 'the same program without the parse word is an error'); // semantic tokens: groups of five, keywords and functions classified from the compiler's own tables r = run([init(1), open(A, good), sem(2, A)]); res = r.msgs.find(m => m.id === 2); const data = res && res.result && res.result.data; check(data && data.length > 0 && data.length % 5 === 0, 'semantic tokens come in groups of five'); check(data && data[3] === 0 && data[0] === 0 && data[1] === 0, 'fn is a keyword at 0:0', data && data.slice(0, 5)); check(data && data[8] === 2, 'a function name is a function', data && data.slice(5, 10)); // decode the semantic tokens of a document into text:type pairs function tokens(text) { const rr = run([init(1), open(A, text), sem(2, A)]); const dd = rr.msgs.find(m => m.id === 2).result.data; const kinds = ['keyword', 'type', 'function', 'variable', 'string', 'number', 'operator', 'comment', 'parameter', 'property', 'macro', 'enumMember']; const lines = text.split('\n'); let l = 0, c = 0; const out = []; for (let i = 0; i < dd.length; i += 5) { l += dd[i]; c = dd[i] ? dd[i + 1] : c + dd[i + 1]; out.push(lines[l].substr(c, dd[i + 2]) + ':' + kinds[dd[i + 3]]); } return out; } const meth = 'struct point {\n\tlet x, y;\n\tfn sum() { return this.x + this.y; }\n}\nfn main() {\n\tpoint p;\n\treturn p.sum();\n}\n'; const tk = tokens(meth); check(tk.filter(t => t === 'sum:function').length === 2, 'a method is a function where it is declared and where it is called', tk); check(tk.includes('this:parameter') && tk.includes('x:property'), 'this is a parameter and a field is a property', tk); check(tk.filter(t => t === 'point:type').length >= 2, 'a user type is a type, also before a postfix star', tk); // a large document goes through the streaming reader let big = ''; for (let i = 0; i < 2500; i++) big += 'fn f' + i + '(a, b) { return a * b + ' + i + '; }\n'; big += 'fn main() { return f0(1, 2); }\n'; r = run([init(1), open(A, big)]); d = diags(r.msgs, A); // a large check streams what it has found, so there may be several lists; the last is the whole answer check(d.length >= 1 && d[d.length - 1].params.diagnostics.filter(x => x.severity === 1).length === 0, 'a document of ' + big.length + ' bytes is checked', d.length); // local tables grow past their initial reservation, and the next document still checks let many = 'fn main() {\n'; for (let i = 0; i < 600; i++) many += '\tlet v' + i + ' = ' + i + ';\n'; many += '\treturn 0;\n}\n'; r = run([init(1), open(A, many), open(B2, good)]); d = diags(r.msgs, A); check(d.length === 1 && !d[0].params.diagnostics.some(x => x.severity === 1) && diags(r.msgs, B2).length === 1, 'locals grow past 512 and the server carries on', d.length); // hover: the text at a position, zero-based line and character, or null function hover(text, line, ch) { const rr = run([init(1), open(A, text), { jsonrpc: '2.0', id: 3, method: 'textDocument/hover', params: { textDocument: { uri: A }, position: { line, character: ch } } }]); const x = rr.msgs.find(m => m.id === 3); return x && x.result ? x.result.contents.value : null; } const at = (text, line, word, nth) => { const s = text.split('\n')[line]; let i = -1; for (let k = 0; k <= (nth || 0); k++) i = s.indexOf(word, i + 1); return i; }; const hsrc = '// adds two numbers\nint add(int a, int b) { return a + b; }\nint twice(int a) { return a * 2; }\nint main() {\n\tint n = add(1, 2);\n\tlet i = 3;\n\treturn twice(n) + i;\n}\n'; let hv = hover(hsrc, 4, at(hsrc, 4, 'add')); check(hv && hv.includes('int add(int a, int b)') && hv.includes('adds two numbers'), 'hover on a call shows the signature and the doc', hv); check(hv && /^```zc\n/.test(hv) && /\n```\n\nadds two numbers/.test(hv), 'hover separates the code fence from Markdown documentation', hv); const unicodeHover = 'fn main() { let text = "é😀"; int value = 1; return value; }\n'; hv = hover(unicodeHover, 0, at(unicodeHover, 0, 'value')); check(hv && hv.includes('(int) let value'), 'hover converts UTF-16 columns after BMP and astral characters', hv); hv = hover(hsrc, 0, hsrc.split('\n')[0].length + 10); check(hv === null, 'an out-of-range hover column cannot select a name on the following line', hv); hv = hover(hsrc, 6, at(hsrc, 6, 'twice')); check(hv && hv.includes('int twice(int a)') && !hv.includes('adds two numbers'), 'a function with no doc still shows its signature', hv); hv = hover(hsrc, 1, at(hsrc, 1, 'a', 2)); check(hv && hv.includes('(int) let a // parameter;'), 'hover on a parameter shows its declaration', hv); hv = hover(hsrc, 5, at(hsrc, 5, 'i')); check(hv && hv.includes('let i') && hv.includes('local'), 'hover on a local shows its declaration', hv); hv = hover(hsrc, 6, at(hsrc, 6, 'i', 1)); check(hv && hv.includes('let i') && hv.includes('local'), 'hover on a use of a local shows its declaration', hv); const tsrc = 'fn main() {\n\tlet i = 3;\n\tint* p;\n\treturn i;\n}\n'; // an untyped parameter and an untyped function show the types annotate gives them const isrc = 'fn twice(x) { return x * 2; }\nfn main() {\n\tlet n = twice(4);\n\treturn n;\n}\n'; let hi = hover(isrc, 0, 9); check(hi && hi.includes('(int) let x // parameter;'), 'an untyped parameter shows its inferred type', hi); hi = hover(isrc, 0, 3); check(hi && hi.includes('int twice(int x)'), 'an untyped function shows its inferred parameter and return types', hi); hv = hover(tsrc, 1, 5); check(hv && hv.includes('(int) let i'), 'an untyped number local shows the type annotate gives it', hv); hv = hover(tsrc, 2, 6); check(hv && hv.includes('(int*) let p; // local'), 'a pointer local shows its pointer type on its declaration line', hv); hv = hover('fn main() {\n\tlet i = 3;\n\treturn i;\n}\n', 2, 8); check(hv && hv.includes('let i'), 'a return of a local is a use, not a declaration', hv); hv = hover(hsrc, 6, at(hsrc, 6, 'return')); check(hv && hv.includes('leaves the function with a value'), 'a keyword shows what it does', hv); const builtinSource = 'include rt; fn main() {\n\trt.put_c(32);\n\treturn 0;\n}\n'; hv = hover(builtinSource, 1, at(builtinSource, 1, 'put_c')); check(hv && hv.includes('put_c('), 'a built-in function shows its signature', hv); const kwsrc = 'include rt; fn main() {\n\tint* p = rt.new(int);\n\trt.free(p);\n\treturn 0;\n}\n'; hv = hover(kwsrc, 1, at(kwsrc, 1, 'new')); check(hv && hv.includes('new(T)') && hv.includes('fresh heap object') && hv.includes('owns it'), 'hover on new shows its form and ownership behavior', hv); hv = hover(kwsrc, 2, at(kwsrc, 2, 'free')); check(hv && hv.includes('releases a heap object') && hv.includes('consumes an owned object automatically'), 'hover on free shows inferred consumption', hv); const kdoc = '// the number of lines\nfn main() {\n\treturn 0;\n}\n'; hv = hover(kdoc, 2, 1); check(hv === null || !hv.includes('number of lines'), 'a doc comment is not attached to a keyword in a body', hv); // a method of a struct is indented in the body, and its hover is still its line and doc const msrc = 'struct point {\n\tlet x;\n\t// the sum of the parts\n\tfn sum() { return this.x; }\n\tstatic fn zero() { return 0; }\n}\nfn main() {\n\tpoint p;\n\treturn p.sum() + point.zero();\n}\n'; hv = hover(msrc, 8, at(msrc, 8, 'sum')); check(hv && hv.includes('fn sum()') && hv.includes('the sum of the parts'), 'a method called through a pointer shows its signature and doc', hv); hv = hover(msrc, 8, at(msrc, 8, 'zero')); check(hv && hv.includes('static fn zero()'), 'a static function of a struct shows its signature', hv); hv = hover(msrc, 4, at(msrc, 4, 'static')); check(hv && hv.includes('static fn name') && hv.includes('hidden this'), 'static keyword hover explains the declaration form', hv); check(tokens(msrc).includes('static:keyword'), 'static is highlighted as a keyword', tokens(msrc)); // a typed method shows its return type and parameters const tmsrc = 'struct point {\n\tlet x;\n\t// the sum of the parts\n\tint sum(int k) { return this.x + k; }\n}\nfn main() {\n\tpoint p;\n\treturn p.sum(1);\n}\n'; hv = hover(tmsrc, 7, at(tmsrc, 7, 'sum')); check(hv && hv.includes('int sum(int k)') && hv.includes('the sum of the parts'), 'a typed method shows its return type and parameters', hv); // externs are declarations, so they draw no "never reassigned" hint const ext = 'extern double sqrt(double);\nextern double pow(double x, double y);\nfn main() {\n\treturn 0;\n}\n'; r = run([init(1), open(A, ext)]); d = diags(r.msgs, A); check(d.length === 1 && !d[0].params.diagnostics.some(x => /never reassigned/.test(x.message)), 'an extern draws no hint about its stub', d.length && d[0].params.diagnostics); // shutdown and exit end the process cleanly r = run([init(1), { jsonrpc: '2.0', id: 9, method: 'shutdown' }, { jsonrpc: '2.0', method: 'exit' }]); check(r.code === 0 && r.msgs.some(m => m.id === 9 && m.result === null), 'shutdown then exit ends with status 0', r.code); // the corpus: every valid program checks clean, every bad one is rejected with its message function cli(file) { const r = spawnSync(server, [], { input: fs.readFileSync(file), timeout: 30000 * TIMEOUT_SCALE }); return r.stdout.toString(); } let clean = 0, dirty = []; for (const dir of ['examples', 'tests/programs']) { for (const f of fs.readdirSync(dir).filter(n => n.endsWith('.zc'))) { if (f === 'manyerrors.zc' || f === 'undefined_call.zc' || f === 'zc0typed.zc') continue; // manyerrors and undefined_call are bad on purpose; zc0typed.zc is stage 0 syntax const out = cli(dir + '/' + f); if (/: error: /.test(out)) dirty.push(dir + '/' + f + ' ' + out.split('\n')[0]); else clean++; } } check(dirty.length === 0, 'every example and test program checks clean (' + clean + ' files)', dirty); let missed = [], n = 0; for (const dir of ['errors', 'typeerrors', 'langerrors']) { for (const f of fs.readdirSync('tests/' + dir).filter(x => x.endsWith('.zc'))) { const wantLine = fs.readFileSync('tests/' + dir + '/' + f, 'utf8').split(/\r?\n/).find(line => line.startsWith('// expect: ')); const want = wantLine ? wantLine.slice('// expect: '.length) : ''; const out = cli('tests/' + dir + '/' + f); n++; if (!out.includes(want)) missed.push(dir + '/' + f + ' wanted [' + want + '] got [' + out.split('\n')[0] + ']'); } } check(missed.length === 0, 'every bad program is rejected with the compiler\'s message (' + n + ' files)', missed); const manyOut = cli('tests/programs/manyerrors.zc').split('\n').filter(l => /: error: /.test(l)); check(manyOut.length === 4, 'several errors in one run are all reported', manyOut); // names resolve the way the compiler resolves them: a local or a parameter named // like a built-in is that variable, and the built-in is only what the scope leaves const scp = 'int cap = 2;\nfn grow(int cap) { return cap + 1; }\nfn main() {\n\tint cap = 3;\n\treturn len(grow(cap));\n}\n'; const sct = tokens(scp); check(sct.includes('cap:parameter') && sct.includes('cap:variable') && !sct.includes('cap:keyword') && !sct.includes('cap:function'), 'a name like a built-in is a variable or parameter where the scope says', sct); check(sct.includes('len:keyword'), 'a built-in that is a keyword in the compiler is still a keyword', sct); let hcv = hover(scp, 4, at(scp, 4, 'cap')); check(hcv && hcv.includes('(int) let cap') && hcv.includes('local'), 'hover on a use of a local named like a built-in shows the local', hcv); hcv = hover(scp, 1, at(scp, 1, 'cap', 1)); check(hcv && hcv.includes('parameter'), 'hover on a use of a parameter named like a built-in shows the parameter', hcv); hcv = hover(scp, 4, at(scp, 4, 'len')); check(hcv && hcv.includes('the number of items'), 'hover on the built-in still shows its text where no local shadows it', hcv); // typedefs: the name shows its declaration, at the declaration, at a use in a declaration, and in a block const tdsrc = 'struct node { int v; node* next; };\n// another name for node*\ntypedef node* link;\ntypedef int(int, int) binop;\nlink head(link n) { return n; }\nint use(string s) {\n\ttypedef int cell;\n\tcell c = 1;\n\tbinop f = 0;\n\treturn c;\n}\n'; hv = hover(tdsrc, 2, at(tdsrc, 2, 'link')); check(hv && hv.includes('typedef node* link;') && hv.includes('another name for node*'), 'hover on the name in a typedef shows the typedef and its doc', hv); hv = hover(tdsrc, 4, at(tdsrc, 4, 'link')); check(hv && hv.includes('typedef node* link;') && hv.includes('a pointer to `node`'), 'hover on a typedef name used as a type shows the typedef and the type', hv); hv = hover(tdsrc, 4, at(tdsrc, 4, 'link', 1)); check(hv && hv.includes('typedef node* link;'), 'hover on a typedef name used as a parameter type shows the typedef', hv); hv = hover(tdsrc, 3, at(tdsrc, 3, 'binop')); check(hv && hv.includes('typedef int(int, int) binop;'), 'hover on a function type typedef shows the whole typedef', hv); hv = hover(tdsrc, 7, at(tdsrc, 7, 'cell')); check(hv && hv.includes('typedef int cell;'), 'hover on a typedef made in a block shows the typedef', hv); hv = hover(tdsrc, 5, at(tdsrc, 5, 'string')); check(hv && hv.includes('char*') && hv.includes('built-in alias'), 'hover on string shows char* and what string is', hv); hv = hover(tdsrc, 2, 3); check(hv && hv.includes('typedef T name;') && hv.includes('type name = T;'), 'hover on the typedef keyword explains it', hv); check(tokens(tdsrc).includes('typedef:keyword'), 'typedef is highlighted as a keyword', tokens(tdsrc)); console.log(ok + ' lsp checks passed, ' + bad + ' failed'); process.exit(bad ? 1 : 0);