diff options
| author | 2026-10-12 08:46:23 +0330 | |
|---|---|---|
| committer | 2026-10-12 08:46:23 +0330 | |
| commit | ecc350ca5c2eb50be8309c5f14f8bf0d87db9a02 (patch) | |
| tree | 70e0af76a1b13affff7c6cc8aac7b3e6ef960478 /tests/drivers/lsp_test.js | |
a
Diffstat (limited to 'tests/drivers/lsp_test.js')
| -rw-r--r-- | tests/drivers/lsp_test.js | 246 |
1 files changed, 246 insertions, 0 deletions
diff --git a/tests/drivers/lsp_test.js b/tests/drivers/lsp_test.js new file mode 100644 index 0000000..a783e1e --- /dev/null +++ b/tests/drivers/lsp_test.js @@ -0,0 +1,246 @@ +// protocol tests for the language server (node, no packages). +// usage: node tests/drivers/lsp_test.js path/to/zclsp +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, 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\tput_n(1 + 2);\n\treturn 0;\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() { put_s("é😀"); return 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 = 'immediate fn pipe_op() { next(); 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 = new(point);\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 >= 3, '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); +check(d.length === 1 && d[0].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() { put_s("é😀"); put_c(10); return 0; }\n'; +hv = hover(unicodeHover, 0, at(unicodeHover, 0, 'put_c')); +check(hv && hv.includes('put_c('), '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('parameter') && hv.includes('int a'), '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\tlet p = new(int);\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('x // parameter; type int'), '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('type int'), 'an untyped number local shows the type annotate gives it', hv); +hv = hover(tsrc, 2, 5); +check(hv && hv.includes('type int*'), '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); +hv = hover('fn main() {\n\tput_c(32);\n\treturn 0;\n}\n', 1, 1); +check(hv && hv.includes('put_c('), 'a built-in function shows its signature', hv); +const kwsrc = 'fn main() {\n\tint* p = new(int);\n\tfree(p);\n\treturn 0;\n}\n'; +hv = hover(kwsrc, 1, at(kwsrc, 1, 'new')); +check(hv && hv.includes('new(T)') && hv.includes('fresh object'), 'hover on new shows its form and what it does', hv); +hv = hover(kwsrc, 2, at(kwsrc, 2, 'free')); +check(hv && hv.includes('frees a heap object'), 'hover on free shows its doc', 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 = new(point);\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); + +// 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 = new(point);\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 }); + 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 want = fs.readFileSync('tests/' + dir + '/' + f, 'utf8').split(/\r?\n/)[0].replace('// expect: ', ''); + 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 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); + +console.log(ok + ' lsp checks passed, ' + bad + ' failed'); +process.exit(bad ? 1 : 0); |
