1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
|
// 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);
console.log(ok + ' lsp checks passed, ' + bad + ' failed');
process.exit(bad ? 1 : 0);
|