aboutsummaryrefslogtreecommitdiff
path: root/tests/drivers/lsp_test.js
diff options
context:
space:
mode:
authorGravatar Moon Flower <moonflower@elhenney.local> 2026-10-12 08:46:23 +0330
committerGravatar Moon Flower <moonflower@elhenney.local> 2026-10-12 08:46:23 +0330
commitecc350ca5c2eb50be8309c5f14f8bf0d87db9a02 (patch)
tree70e0af76a1b13affff7c6cc8aac7b3e6ef960478 /tests/drivers/lsp_test.js
a
Diffstat (limited to 'tests/drivers/lsp_test.js')
-rw-r--r--tests/drivers/lsp_test.js246
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);