diff options
Diffstat (limited to 'tests/drivers')
35 files changed, 1912 insertions, 0 deletions
diff --git a/tests/drivers/code_address_growth_test.py b/tests/drivers/code_address_growth_test.py new file mode 100644 index 0000000..a454406 --- /dev/null +++ b/tests/drivers/code_address_growth_test.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# native address metadata grows without losing warm translations or rewrite tracking. +import subprocess +import sys + +source = '''o3 int warmed(int value) { return value + 1; } +fn main() { + put_n(warmed(6)); put_c(32); + let code = new(6); + code[0] = code + 3; code[3] = 8; code[4] = 15; code[5] = 18; + put_n(syscall(9, 9, code + 6, 0) >= code + 6); put_c(32); + put_n(code()); put_c(32); + fill_w(code + 4, 19, 1); put_n(code()); put_c(32); + let replacement = new(1); replacement[0] = 23; + copy_w(code + 4, replacement, 1); put_n(code()); put_c(32); + gc(); put_n(warmed(8)); put_c(32); + put_n(syscall(9, 9, syscall(9, 0, 0, 0) + 1, 0) == -1); put_c(10); + return 0; +} +''' +result = subprocess.run(sys.argv[1:], input=source, text=True, capture_output=True, timeout=20) +assert result.returncode == 0 and result.stdout == '7 1 7 9 11 9 1\n', (result.returncode, result.stdout, result.stderr) +print('ok new code addresses, warm calls, bulk rewrites, and denied metadata growth') diff --git a/tests/drivers/code_emission_growth_test.py b/tests/drivers/code_emission_growth_test.py new file mode 100644 index 0000000..f13c141 --- /dev/null +++ b/tests/drivers/code_emission_growth_test.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# emitted code and global data keep their addresses when the program area fills. +import argparse +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument('--length', type=int, default=600000) +parser.add_argument('command', nargs=argparse.REMAINDER) +options = parser.parse_args() +source = 'int earlier() { return 7; }\n' +source += f'let storage[{options.length}];\n' +source += 'let text = "' + 's' * 3500 + '";\n' +source += 'int later(int n) { return earlier() + n; }\n' +source += '''fn main() { + storage[len(storage) - 1] = 9; + gc(); put_n(later(3)); put_c(32); put_n(storage[len(storage) - 1]); put_c(32); + put_n(len_w(text)); put_c(32); put_n(code_blocks != 0); put_c(32); + let quoted = quote { return 5; }; + put_n(eval(quoted)); put_c(32); + let f = fn(float x) { return x + 1.5; }; put_n((f(2.0) as word) == 0x400c000000000000); put_c(10); + return 0; +} +''' +result = subprocess.run(options.command, input=source, text=True, capture_output=True, timeout=40) +assert result.returncode == 0 and result.stdout == '10 9 3500 1 5 1\n', (result.returncode, result.stdout, result.stderr) +print('ok linked code segments preserve functions, globals, strings, quotes, and float payloads') + +# force the next float opcode and its payload to need a new segment. +source = 'immediate fn force_edge() { next(); hlimit = here + 3; return 0; }\n' +source += 'fn main() { force_edge let value = 1.5; gc(); put_n((value as word) == 0x3ff8000000000000); put_c(10); return 0; }\n' +result = subprocess.run(options.command, input=source, text=True, capture_output=True, timeout=20) +assert result.returncode == 0 and result.stdout == '1\n', (result.returncode, result.stdout, result.stderr) +print('ok a float opcode and its payload remain adjacent at a segment boundary') +source = 'immediate fn force_edge() { next(); hlimit = here + 3; return 0; }\n' +source += 'force_edge\nenhance cube(x) vm { 0 $ 0 $ * * };\n' +source += 'enhance sum(a, b) micro "a = add a b";\n' +source += 'fn main() { gc(); put_n(cube(7)); put_c(32); put_n(sum(3, 4)); put_c(10); return 0; }\n' +result = subprocess.run(options.command, input=source, text=True, capture_output=True, timeout=20) +assert result.returncode == 0 and result.stdout == '343 7\n', (result.returncode, result.stdout, result.stderr) +print('ok vm and micro recipes remain contiguous after a code reservation') diff --git a/tests/drivers/compiler_growth_test.py b/tests/drivers/compiler_growth_test.py new file mode 100644 index 0000000..982611c --- /dev/null +++ b/tests/drivers/compiler_growth_test.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# focused growth checks for compiler columns, stable names, types, and scratch stacks. +import argparse +import subprocess +import sys + +parser = argparse.ArgumentParser() +parser.add_argument('server') +parser.add_argument('--names', type=int, default=18000) +options = parser.parse_args() +server = options.server +assert options.names > 0 + + +def run(source, expected): + result = subprocess.run([server], input=source, text=True, capture_output=True, timeout=30) + assert result.returncode == 0, result.stderr + assert result.stdout == expected, result.stdout[:1000] + + +locals_source = 'fn main() {\n' +locals_source += ''.join(f'int local_{i} = {i};\n' for i in range(900)) +locals_source += 'put_n(local_899); put_c(10); return 0; }\n' +run(locals_source, '899\n') + +types_source = ''.join(f'type array_{i} = [{i + 1}]int;\n' for i in range(1500)) +types_source += 'fn main() { put_n(ntl > 1500); put_c(10); gc(); put_n(tsz(tarr(tint, 1500))); put_c(10); return 0; }\n' +run(types_source, '1\n1500\n') + +names_source = ''.join(f'const int repeated_long_identifier_for_pool_and_hash_growth_{i} = {i};\n' for i in range(options.names)) +names_source += f'''fn main() {{ + word saved = intern("a name that must survive growth"); + reserve_names(hashcap * 4); reserve_symbols(symcap * 2); reserve_locals(loccap * 2); + reserve_fixes(fixcap * 2); reserve_R_SRC(4096); reserve_R_CTX(4096); + reserve_R_LOOP(4096); reserve_R_SNAP(32768); reserve_R_QBUF(32768); reserve_R_EXIT(8192); + gc(); + put_n(saved == intern("a name that must survive growth")); put_c(10); + put_n(repeated_long_identifier_for_pool_and_hash_growth_{options.names - 1}); put_c(10); + put_n(poolblocks != 0 && symcap > NS && hashcap > IHN); put_c(10); + return 0; +}} +''' +run(names_source, f'1\n{options.names - 1}\n1\n') + +long_name = 'identifier_' + 'x' * 1500 +run(f'const int {long_name} = 7; fn main() {{ put_n({long_name}); put_c(10); return 0; }}\n', '7\n') + +mark_source = '''let roots[60000]; +unchecked fn main() { + int i = 0; + while (i < 60000) { roots[i] = new(1); i++; } + gc(); put_n(markcap > MARKN); put_c(32); put_n(live_objects() == 60000); put_c(32); + fill_w(roots, 0, 60000); scrub(); gc(); + // conservative roots can retain incidental addresses in code or untyped words. + put_n(live_objects() < 1000); put_c(10); return 0; +} +''' +run(mark_source, '1 1 1\n') + +generics_source = ''.join(f'int generic_{i}<T>() {{ return sizeof(T); }}\n' for i in range(90)) +generics_source += 'int size_of<T>() { return sizeof(T); }\n' +generics_source += 'fn main() { int total = 0;\n' +generics_source += ''.join(f'total += generic_{i}<int>();\n' for i in range(90)) +generics_source += ''.join(f'total += size_of<[{i + 1}]int>();\n' for i in range(150)) +generics_source += 'gc(); put_n(total); put_c(10); return 0; }\n' +run(generics_source, '11415\n') + +captures_source = 'fn main() {\n' + ''.join(f'int captured_{i} = {i};\n' for i in range(70)) +captures_source += 'let f = fn() { return ' + ' + '.join(f'captured_{i}' for i in range(70)) + '; };\n' +captures_source += 'gc(); put_n(f()); put_c(10); return 0; }\n' +run(captures_source, '2415\n') + +nested_source = 'fn main() { int captured = 7;\n' +nested_source += ''.join(f'let nested_{i} = fn() {{\n' for i in range(12)) +nested_source += 'return captured;\n' +nested_source += ''.join(f'}}; return nested_{i}();\n' for i in reversed(range(1, 12))) +nested_source += '}; gc(); put_n(nested_0()); put_c(10); return 0; }\n' +run(nested_source, '7\n') +assembly_source = 'fn main() { int value = asm { ' + 'future _ ' * 300 +assembly_source += ''.join(f':label_{i} ' for i in range(200)) + ':future 7 }; gc(); put_n(value); put_c(10); return 0; }\n' +run(assembly_source, '7\n') +print('ok compiler tables, long identifiers, and mark work grow without losing references') diff --git a/tests/drivers/core_lsp_test.js b/tests/drivers/core_lsp_test.js new file mode 100644 index 0000000..41d0095 --- /dev/null +++ b/tests/drivers/core_lsp_test.js @@ -0,0 +1,43 @@ +// check maintained zc sources through the editor document protocol, one file at a time. +const assert = require('assert'); +const fs = require('fs'); +const cp = require('child_process'); +const path = require('path'); +const target = process.argv[2]; +const files = ['src/runtime/rt.zc', 'src/compiler/compiler.zc', 'src/runtime/run.zc', 'src/lib/std.zc', 'src/lsp/server.zc', 'src/tools/build.zc']; +const frame = message => { + const body = Buffer.from(JSON.stringify(message)); + return Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\r\n\r\n'), body]); +}; +for (const file of files) { + const uri = 'file://' + path.resolve(file); + const text = fs.readFileSync(file, 'utf8'); + const messages = [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', method: 'initialized', params: {} }, + { jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, languageId: 'zc', text } } }, + { jsonrpc: '2.0', id: 2, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri } } }, + { jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version: 2 }, contentChanges: [{ text: text + '\n' }] } }, + { jsonrpc: '2.0', method: 'textDocument/didClose', params: { textDocument: { uri } } }, + { jsonrpc: '2.0', id: 3, method: 'shutdown', params: null }, + { jsonrpc: '2.0', method: 'exit' }, + ]; + const result = cp.spawnSync(target, [], { input: Buffer.concat(messages.map(frame)), timeout: 30000, maxBuffer: 32 << 20 }); + assert.strictEqual(result.status, 0, file + ': ' + result.stderr); + let data = result.stdout; + const replies = []; + while (data.length) { + const end = data.indexOf('\r\n\r\n'); + assert.ok(end >= 0, 'complete protocol header'); + const length = Number(/Content-Length:\s*(\d+)/i.exec(data.subarray(0, end).toString())[1]); + replies.push(JSON.parse(data.subarray(end + 4, end + 4 + length))); + data = data.subarray(end + 4 + length); + } + const diagnostics = replies.filter(m => m.method === 'textDocument/publishDiagnostics'); + assert.strictEqual(diagnostics.length, 3, file + ': open, change, and close publish diagnostics'); + const errors = diagnostics.flatMap(m => m.params.diagnostics).filter(d => d.severity === 1); + assert.deepStrictEqual(errors, [], file + ': ' + errors.map(d => (d.range.start.line + 1) + ': ' + d.message).join('\n')); + assert.ok(replies.find(m => m.id === 2).result.data.length, file + ': semantic tokens are available'); + assert.deepStrictEqual(diagnostics[2].params.diagnostics, [], 'close clears diagnostics'); + console.log('ok editor open, highlight, change, and close: ' + file); +} diff --git a/tests/drivers/diagcache_test.js b/tests/drivers/diagcache_test.js new file mode 100644 index 0000000..43966cf --- /dev/null +++ b/tests/drivers/diagcache_test.js @@ -0,0 +1,140 @@ +// the rules of the diagnostics cache (editors/vscode/client/diagcache.js), with +// plain node and no vscode: a key covers the server build, the text, and every +// file the text reads through include, transitively. +// +// node tests/drivers/diagcache_test.js + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('assert'); +const dc = require(path.join(__dirname, '..', '..', 'editors', 'vscode', 'client', 'diagcache.js')); + +let passed = 0, failed = 0; +function check(name, fn) { + try { fn(); passed++; console.log('ok ' + name); } + catch (e) { failed++; console.log('FAIL ' + name + ': ' + e.message); } +} + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zc-diagcache-')); +const write = (rel, text) => { const p = path.join(root, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, text); return p; }; +const A = write('a.zc', 'include "b.zc";\nfn main() { return helper(); }\n'); +const B = write('b.zc', 'include "c.zc";\n'); +const C = write('c.zc', 'fn helper() { return 1; }\n'); +const U = write('unrelated.zc', 'fn other() { return 2; }\n'); +const aText = fs.readFileSync(A, 'utf8'); + +const keyA = (stamp = 'S1', text = aText, p = A) => dc.diagKey(stamp, text, p, root).key; + +check('includeNames reads every include, however it is spaced', () => { + assert.deepStrictEqual(dc.includeNames('include "a.zc";\ninclude"b/c.zc";\nfn f() {}'), ['a.zc', 'b/c.zc']); + assert.deepStrictEqual(dc.includeNames('fn f() { return 1; }'), []); +}); + +check('includeNames ignores comments and literals while allowing comments between tokens', () => { + assert.deepStrictEqual(dc.includeNames('// include "fake.zc"\n/* include "also-fake.zc" */\nlet s = "include \\"string.zc\\"";\ninclude /* dependency */ "real.zc";'), ['real.zc']); +}); + +check('the same tree gives the same key', () => { + assert.strictEqual(keyA(), keyA()); + assert.match(keyA(), /^[0-9a-f]{64}$/); +}); + +check('a change to the document text changes the key', () => { + assert.notStrictEqual(keyA('S1', aText + ' '), keyA()); +}); + +check('a change to the server build changes the key', () => { + assert.notStrictEqual(keyA('S2'), keyA('S1')); +}); + +check('a change to a file the document includes, two steps down, changes the key', () => { + const before = keyA(); + fs.writeFileSync(C, 'fn helper() { return 3; }\n'); + const after = keyA(); + assert.notStrictEqual(before, after); + fs.writeFileSync(C, 'fn helper() { return 1; }\n'); + assert.strictEqual(keyA(), before, 'putting it back gives the old key again'); +}); + +check('a file nothing includes does not change the key', () => { + const before = keyA(); + fs.writeFileSync(U, 'fn other() { return 9; }\n'); + assert.strictEqual(keyA(), before); +}); + +check('creating a missing include changes the key', () => { + const M = write('m.zc', 'include "later.zc";\nfn f() { return 0; }\n'); + const mText = fs.readFileSync(M, 'utf8'); + const before = dc.diagKey('S1', mText, M, root); + assert.ok(before.deps.some(p => p.endsWith('later.zc')), 'the missing name is still a dependency'); + write('later.zc', 'fn g() { return 1; }\n'); + assert.notStrictEqual(dc.diagKey('S1', mText, M, root).key, before.key); +}); + +check('an include is found from the workspace folder as well as the including file', () => { + const X = write('sub/x.zc', 'include "b.zc";\nfn x() { return 0; }\n'); + const tree = dc.sourceTree(fs.readFileSync(X, 'utf8'), X, root); + assert.ok(tree.files.has(path.join(root, 'b.zc')), 'b.zc is read from the workspace root'); + assert.ok(tree.files.has(path.join(root, 'sub', 'b.zc')), 'and the directory of x.zc is tried too'); + assert.ok(tree.complete); +}); + +check('a tree too large to cover has no key, so nothing is cached for it', () => { + let many = ''; + for (let i = 0; i < dc.MAX_FILES + 5; i++) many += 'include "nope' + i + '.zc";\n'; + const big = write('big.zc', many); + const r = dc.diagKey('S1', many, big, root); + assert.strictEqual(r.key, null); + const cache = new dc.DiagCache(null); + assert.strictEqual(cache.put(r.key, []), false); + assert.strictEqual(cache.size(), 0); +}); + +check('the cache answers only for the key it was given', () => { + const cache = new dc.DiagCache(null); + const d = [{ message: 'x' }]; + assert.ok(cache.put('k1', d)); + assert.deepStrictEqual(cache.get('k1'), d); + assert.strictEqual(cache.get('k2'), null); + assert.strictEqual(cache.get(null), null); + assert.strictEqual(cache.put(null, []), false, 'a tree without a key is not kept'); + assert.strictEqual(cache.size(), 1); +}); + +check('two states of one document are both kept, so going back is a hit', () => { + const cache = new dc.DiagCache(null); + cache.put('state-a', []); + cache.put('state-b', [{ message: 'b' }]); + assert.deepStrictEqual(cache.get('state-a'), []); + assert.deepStrictEqual(cache.get('state-b'), [{ message: 'b' }]); +}); + +check('the least recently used entries go first past the limit', () => { + const cache = new dc.DiagCache(null, 2); + cache.put('1', []); + cache.put('2', []); + cache.get('1'); + cache.put('3', []); + assert.strictEqual(cache.size(), 2); + assert.strictEqual(cache.get('2'), null, 'two was the least recently used'); + assert.deepStrictEqual(cache.get('1'), []); + assert.deepStrictEqual(cache.get('3'), []); +}); + +check('the cache carries over a save and load as plain JSON', () => { + const cache = new dc.DiagCache(null); + cache.put('k1', [{ message: 'z', range: { start: { line: 1, character: 2 } } }]); + const back = new dc.DiagCache(JSON.parse(JSON.stringify(cache))); + assert.deepStrictEqual(back.get('k1'), [{ message: 'z', range: { start: { line: 1, character: 2 } } }]); +}); + +check('a saved entry that is not well formed is dropped, not trusted', () => { + const back = new dc.DiagCache({ bad: { key: 'bad' }, good: [] }); + assert.strictEqual(back.get('bad'), null); + assert.deepStrictEqual(back.get('good'), []); +}); + +fs.rmSync(root, { recursive: true, force: true }); +console.log(passed + ' passed, ' + failed + ' failed'); +process.exit(failed ? 1 : 0); diff --git a/tests/drivers/diagnostic_revision_test.js b/tests/drivers/diagnostic_revision_test.js new file mode 100644 index 0000000..725fb87 --- /dev/null +++ b/tests/drivers/diagnostic_revision_test.js @@ -0,0 +1,47 @@ +// exercise delayed diagnostics without relying on an editor's timing. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const root = path.resolve(__dirname, '../..'); +const displayed = [], cached = []; +const vscode = { + EventEmitter: class { fire() {} }, + SemanticTokensLegend: class {}, + Diagnostic: class { constructor(range, message) { this.message = message; } }, + Range: class {}, + DiagnosticSeverity: { Hint: 4, Information: 3, Warning: 2, Error: 1 }, + Uri: { parse: value => value }, +}; +const filename = path.join(root, 'editors/vscode/client/extension.js'); +const context = vm.createContext({ + module: { exports: {} }, __dirname: path.dirname(filename), Buffer, setTimeout, clearTimeout, + require(name) { + if (name === 'vscode') return vscode; + if (name === './vmproc' || name === './wasmproc') return {}; + if (name.startsWith('./')) return require(path.join(path.dirname(filename), name)); + return require(name); + }, + displayed, cached, +}); +vm.runInContext(fs.readFileSync(filename, 'utf8') + ` +diagnostics = { set(uri, list) { displayed.push(list.map(d => d.message)); } }; +cache = { put(key, raw) { cached.push({ key, raw }); return false; } }; +const uri = 'file:///reopened.zc'; +const diag = message => [{ message, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]; +opened.add(uri); docKeys.set(uri, 'current'); docVersions.set(uri, 3); +sentKeys.set(uri, [{ key: 'current', version: 3 }]); +onPublish(uri, diag('old close'), 1); +onPublish(uri, diag('old open'), 2); +onPublish(uri, diag('current open'), 3); +onPublish(uri, diag('duplicate'), 3); +opened.delete(uri); +onPublish(uri, diag('closed'), 3); +opened.add(uri); docKeys.set(uri, null); docVersions.set(uri, 5); +sentKeys.set(uri, [{ key: null, version: 4 }, { key: null, version: 5 }]); +onPublish(uri, diag('old uncacheable text'), 4); +onPublish(uri, diag('current uncacheable text'), 5); +`, context, { filename }); +assert.deepStrictEqual(displayed.map(list => Array.from(list)), [['current open'], ['current uncacheable text']]); +assert.deepStrictEqual(cached.map(entry => entry.key), ['current']); +console.log('ok closed, reopened, duplicate, and uncacheable diagnostic revisions'); diff --git a/tests/drivers/enhancement_backends_test.py b/tests/drivers/enhancement_backends_test.py new file mode 100644 index 0000000..b7d553b --- /dev/null +++ b/tests/drivers/enhancement_backends_test.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# recipes, vm lowering, fallback, and ignored optimizer regions agree across hosts. +import pathlib +import subprocess + +root = pathlib.Path(__file__).resolve().parents[2] +source = (root / 'tests/programs/enhance.zc').read_text() +# keep this focused check short while still exercising signed and unsigned arithmetic. +source = source.replace('i < 5000', 'i < 200') +source += '\n' +reference = None +for command in [['bin/zy'], ['bin/zym'], ['node', 'src/js/tinyvm.js']]: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=25) + assert result.returncode == 0 and result.stdout and 'zc:' not in result.stdout, (command, result.returncode, result.stdout, result.stderr) + if reference is None: + reference = result.stdout + else: + assert result.stdout == reference, (command, reference, result.stdout) + print('ok vm recipes, multiple outputs, arithmetic, and fallback: ' + command[0]) +source = '''o3 int increment(int value) { return value + 1; } +fn main() { put_n(increment(6)); put_c(32); put_n(17 / 5); put_c(32); put_n(17 % 5); put_c(10); return 0; } +''' +for command in [['bin/zy'], ['bin/zym'], ['node', 'src/js/tinyvm.js']]: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=15) + assert result.returncode == 0 and result.stdout == '7 3 2\n', (command, result.stdout, result.stderr) + print('ok optimizer descriptors preserve portable division slots: ' + command[0]) + +# slot zero can be replaced, and an unsupported replacement preserves it. +# real install descriptors are exercised without calling a compiler-specific parser. +source = '''let descriptor[7], names[2]; +fn main() { + names[0] = "a"; names[1] = "b"; + descriptor[0] = 0; descriptor[1] = 0; descriptor[2] = 2; descriptor[3] = 1; + descriptor[4] = "a = add a b"; descriptor[6] = names; + int installed = install(descriptor) == 0, first = 17 / 5; + descriptor[4] = "a = unknown a b"; + int rejected = install(descriptor) < 0, second = 17 / 5; + descriptor[4] = "a = sdiv a b"; install(descriptor); + put_n(installed); put_c(32); put_n(first); put_c(32); put_n(rejected); put_c(32); put_n(second); put_c(10); return 0; +} +''' +for command in [['bin/zy'], ['bin/zym'], ['node', 'src/js/tinyvm.js']]: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=15) + assert result.returncode == 0 and result.stdout == '1 22 1 22\n', (command, result.stdout, result.stderr) + print('ok replacing a built-in slot and rejecting a bad recipe: ' + command[0]) diff --git a/tests/drivers/ext_cache_test.js b/tests/drivers/ext_cache_test.js new file mode 100644 index 0000000..11bf50b --- /dev/null +++ b/tests/drivers/ext_cache_test.js @@ -0,0 +1,154 @@ +// the extension's diagnostics cache, end to end: the real client +// (editors/vscode/client/extension.js) with a stub for the vscode module, and +// the built-in wasm server. the checks: a first session asks the server and +// saves the answer; a second session shows the saved answer before the server +// starts; a change to an included file is checked again; putting it back is +// answered from the cache. +// +// node tests/drivers/ext_cache_test.js + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Module = require('module'); + +const client = path.join(__dirname, '..', '..', 'editors', 'vscode', 'client'); + +// the vscode module, as far as the client uses it +const world = { lines: [], sets: [], listeners: {}, watchers: [], textDocuments: [], saved: undefined, root: '' }; +const stub = { + EventEmitter: class { constructor() { this.event = () => ({ dispose() {} }); } fire() {} }, + Diagnostic: class { constructor(range, message, severity) { Object.assign(this, { range, message, severity }); } }, + Range: class { constructor(a, b, c, d) { Object.assign(this, { start: { line: a, character: b }, end: { line: c, character: d } }); } }, + DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, + SemanticTokensLegend: class { constructor(t, m) { this.tokenTypes = t; this.tokenModifiers = m; } }, + SemanticTokensBuilder: class { constructor() {} push() {} build() { return null; } }, + MarkdownString: class { + constructor(v = '') { this.value = v; this.escaped = false; } + appendText(v) { this.value += v; this.escaped = true; return this; } + appendMarkdown(v) { this.value += v; return this; } + appendCodeblock(v, language) { this.value += '\n```' + language + '\n' + v + '\n```\n'; return this; } + }, + Hover: class { constructor(c) { this.contents = c; } }, + Uri: { parse: s => ({ toString: () => s, fsPath: decodeURIComponent(s.replace(/^file:\/\//, '')) }) }, + workspace: { + workspaceFolders: undefined, + textDocuments: world.textDocuments, + getConfiguration: () => ({ get: () => undefined }), + createFileSystemWatcher: () => { + const w = { handlers: {}, dispose() {} }; + world.watchers.push(w); + for (const k of ['onDidChange', 'onDidCreate', 'onDidDelete']) w[k] = f => { w.handlers[k] = f; return { dispose() {} }; }; + return w; + }, + ...['onDidChangeConfiguration', 'onDidOpenTextDocument', 'onDidChangeTextDocument', 'onDidSaveTextDocument', 'onDidCloseTextDocument'] + .reduce((o, k) => { o[k] = f => { world.listeners[k] = f; return { dispose() {} }; }; return o; }, {}), + }, + window: { + createOutputChannel: () => ({ appendLine: s => world.lines.push(String(s)), show() {}, dispose() {} }), + showWarningMessage() {}, showErrorMessage() {}, + }, + languages: { + createDiagnosticCollection: () => ({ + set: (uri, list) => world.sets.push({ uri: uri.toString(), list, at: world.lines.length }), + delete() {}, dispose() {}, + }), + registerDocumentSemanticTokensProvider: () => ({ dispose() {} }), + registerHoverProvider: (sel, p) => { world.hover = p; return { dispose() {} }; }, + }, + commands: { registerCommand: () => ({ dispose() {} }) }, +}; +const realResolve = Module._resolveFilename; +Module._resolveFilename = function (req, ...rest) { return req === 'vscode' ? 'vscode' : realResolve.call(this, req, ...rest); }; +require.cache['vscode'] = { id: 'vscode', filename: 'vscode', loaded: true, exports: stub }; + +let passed = 0, failed = 0; +function check(name, ok, detail) { + if (ok) { passed++; console.log('ok ' + name); } + else { failed++; console.log('FAIL ' + name + (detail ? ': ' + detail : '')); } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); +async function until(pred, ms = 20000) { + const end = Date.now() + ms; + while (Date.now() < end) { if (pred()) return true; await sleep(50); } + return pred(); +} + +function freshClient() { + for (const f of Object.keys(require.cache)) if (f.startsWith(client)) delete require.cache[f]; + return require(path.join(client, 'extension.js')); +} + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zc-extcache-')); +stub.workspace.workspaceFolders = [{ uri: { fsPath: root } }]; +const helper = path.join(root, 'helper.zc'); +const main = path.join(root, 'main.zc'); +const helperOk = 'fn helper() { return 1; }\n'; +const helperBad = 'fn helper2() { return 1; }\n'; +fs.writeFileSync(helper, helperOk); +const mainText = 'include "helper.zc";\nfn main() { return helper(); }\n'; +fs.writeFileSync(main, mainText); + +function doc(p, text) { + const d = { uri: { toString: () => 'file://' + p, fsPath: p }, languageId: 'zc', version: 1, text, getText() { return this.text; } }; + return d; +} + +const ctx = () => ({ + subscriptions: [], + globalState: { get: () => world.saved, update: (k, v) => { world.saved = JSON.parse(JSON.stringify(v)); return Promise.resolve(); } }, +}); +const mainDoc = doc(main, mainText); +const mainUri = 'file://' + main; + +(async () => { + // session one: nothing cached, so the server is asked + world.textDocuments.push(mainDoc); + let ext = freshClient(); + let c1 = ctx(); + ext.activate(c1); + check('session one: the server starts and checks the document', await until(() => world.lines.some(l => /server ready/.test(l))) && await until(() => world.sets.some(s => s.uri === mainUri))); + const first = world.sets.filter(s => s.uri === mainUri).pop(); + check('session one: the server answered with a list', first && Array.isArray(first.list), JSON.stringify(first && first.list)); + await sleep(800); + const savedKeys = Object.keys(world.saved || {}); + // the hover is the server's markdown, parsed: a zc code block, not escaped text + const hv = world.hover && await world.hover.provideHover(mainDoc, { line: 1, character: 4 }); + const hc = hv && hv.contents[0]; + check('the hover is markdown with a zc code block, parsed and not escaped', hc && !hc.escaped && /^```zc\n/.test(hc.value) && /\n```/.test(hc.value), JSON.stringify(hc)); + check('session one: the answer is saved under a key', savedKeys.length === 1 && /^[0-9a-f]{64}$/.test(savedKeys[0]), JSON.stringify(savedKeys)); + ext.deactivate(); + await sleep(700); + + // session two: the saved answer is shown before the server has started + world.lines.length = 0; world.sets.length = 0; + ext = freshClient(); + ext.activate(ctx()); + check('session two: the saved diagnostics are shown at once', world.sets.some(s => s.uri === mainUri) && world.lines.some(l => /from the cache/.test(l))); + check('session two: the server has not started yet', !world.lines.some(l => /server ready/.test(l))); + await until(() => world.lines.some(l => /server ready/.test(l))); + await sleep(300); + check('session two: the server was not asked about the document', !world.lines.some(l => /-> textDocument\/didOpen|-> textDocument\/didChange/.test(l)), JSON.stringify(world.lines.slice(0, 6))); + check('session two: the cache holds the same key as before', JSON.stringify(Object.keys(world.saved || {})) === JSON.stringify(savedKeys)); + + // a change to the included file, saved: the document is checked again and the error shows + world.lines.length = 0; world.sets.length = 0; + fs.writeFileSync(helper, helperBad); + for (const h of world.watchers) if (h.handlers.onDidChange) h.handlers.onDidChange({ fsPath: helper }); + check('an included file changed: the document is checked again', await until(() => world.lines.some(l => /-> textDocument\/didOpen|-> textDocument\/didChange/.test(l)) && world.sets.some(s => s.uri === mainUri)), JSON.stringify(world.lines.slice(-4))); + await sleep(900); + check('an included file changed: the new state is saved under its own key', Object.keys(world.saved || {}).length === 2, JSON.stringify(Object.keys(world.saved || {}))); + + // the file goes back: its tree is the one the cache has, so the old answer is shown from the cache + world.lines.length = 0; world.sets.length = 0; + fs.writeFileSync(helper, helperOk); + for (const h of world.watchers) if (h.handlers.onDidChange) h.handlers.onDidChange({ fsPath: helper }); + await sleep(200); + check('the old tree is answered from the cache, without asking the server', world.lines.some(l => /from the cache/.test(l)) && !world.lines.some(l => /-> textDocument\/didChange/.test(l)), JSON.stringify(world.lines.slice(-4))); + + ext.deactivate(); + await sleep(700); + fs.rmSync(root, { recursive: true, force: true }); + console.log(passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})().catch(e => { console.log('FAIL harness: ' + (e.stack || e)); process.exit(1); }); diff --git a/tests/drivers/float_backends_test.py b/tests/drivers/float_backends_test.py new file mode 100644 index 0000000..2cb9473 --- /dev/null +++ b/tests/drivers/float_backends_test.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# compare float instructions, packed binary32 conversion, and decimal output. +import pathlib +import subprocess + +root = pathlib.Path(__file__).resolve().parents[2] +commands = [['bin/zy'], ['bin/zym'], ['node', 'src/js/tinyvm.js']] +sources = [(root / 'tests/programs/float.zc').read_text(), '''fn render(bits, precision, mode) { asm { bits @ precision @ mode @ #1236 } return 0; } +fn convert(bits) { return asm { bits @ #1218 }; } +fn main() { + [4]f32 values; + values[0] = 1.5; values[1] = -2.25; values[2] = 1e-45; values[3] = 1e40; + put_n(values[0] == 1.5); put_c(32); put_n(values[1] == -2.25); put_c(32); + put_n(values[2] > 0.0); put_c(32); put_n(values[3] == 1.0 / 0.0); put_c(10); + render(0.125, 2, 1); put_c(32); render(-0.125, 2, 1); put_c(32); + render(1e30, 6, 1); put_c(32); render(1.7976931348623157e308, 6, 1); put_c(10); + render(0.1, 0, 0); put_c(32); render(1e-5, 0, 0); put_c(32); render(1e16, 0, 0); put_c(10); + render(9.999, 3, 2); put_c(32); render(5e-324, 3, 2); put_c(32); render(0, 3, 2); put_c(10); + let negative_zero = 1 << 63; render(negative_zero, 6, 1); put_c(32); render(negative_zero, 0, 0); put_c(10); + put_n(convert(0.0 / 0.0)); put_c(32); put_n(convert(1.0 / 0.0)); put_c(32); put_n(convert(-1.0 / 0.0)); put_c(10); + return 0; +} +'''] +expected_arithmetic = '4.000000 4.500000 3.000000 2.500000 2.500000 0.001500 0.100000 -0.125000 inf -inf nan 1 1 1 0 0 3 -3 3.000000 -2.000000 2\n' +for i, source in enumerate(sources): + reference = None + for command in commands: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=30) + assert result.returncode == 0 and result.stdout and 'zc:' not in result.stdout, (command, result.returncode, result.stdout, result.stderr) + if reference is None: + if i == 0: + assert result.stdout == expected_arithmetic, result.stdout + else: + assert result.stdout.startswith('1 1 1 1\n0.13 -0.13 '), result.stdout + assert result.stdout.endswith('0 9223372036854775807 -9223372036854775808\n'), result.stdout + reference = result.stdout + else: + assert result.stdout == reference, (command, i, reference, result.stdout) + print('ok float arithmetic and output fixture ' + str(i + 1) + ': ' + command[0]) + +# an exponent after a long fractional spelling must reach the parser intact. +literal = '1.' + '0' * 300 + 'e1' +source = f'fn main() {{ put_n(({literal} as word) == 0x4024000000000000); put_c(10); return 0; }}\n' +for command in commands: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=15) + assert result.returncode == 0 and result.stdout == '1\n', (command, result.stdout, result.stderr) + print('ok float parsing reads the exponent beyond 127 characters: ' + command[0]) diff --git a/tests/drivers/frame_growth_test.py b/tests/drivers/frame_growth_test.py new file mode 100644 index 0000000..74156e3 --- /dev/null +++ b/tests/drivers/frame_growth_test.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# growing frames keep pointers into older segments alive through collection. +import subprocess +import sys + +command = sys.argv[1:] +source = '''unchecked int descend(int depth, int* root) { + [2000]int padding; + padding[0] = depth; + if (depth == 0) { gc(); return *root; } + return descend(depth - 1, root) + padding[0]; +} +unchecked int oversized(int* root) { + [300000]int values; + values[0] = *root; values[299999] = 9; + gc(); return values[0] + values[299999]; +} +unchecked fn main() { + int value = 7; + put_n(descend(300, &value)); put_c(32); + put_n(oversized(&value)); put_c(32); + gc(); put_n(frame_node == 0); put_c(10); + return 0; +} +''' +result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=40) +assert result.returncode == 0 and result.stdout == '45157 16 1\n', (command, result.returncode, result.stdout, result.stderr) +print('ok deep and oversized frames preserve borrowed locals through collection: ' + command[0]) +optimized = source.replace('unchecked int descend', 'unchecked o3 int descend').replace('unchecked int oversized', 'unchecked o3 int oversized') +result = subprocess.run(command, input=optimized, text=True, capture_output=True, timeout=40) +assert result.returncode == 0 and result.stdout == '45157 16 1\n', (command, result.returncode, result.stdout, result.stderr) +print('ok optimized recursive calls wait for the matching return depth: ' + command[0]) diff --git a/tests/drivers/genome_blob_test.py b/tests/drivers/genome_blob_test.py new file mode 100644 index 0000000..81533a7 --- /dev/null +++ b/tests/drivers/genome_blob_test.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# documentation precedes the symbol table; the boot header must skip those bytes. +import pathlib +import re + +source = pathlib.Path('src/core/genome.h').read_text() +org = int(re.search(r'#define GORG (\d+)', source)[1]) +length = int(re.search(r'#define GLEN (\d+)', source)[1]) +words = [int(word.strip()) for word in re.search(r'genome\[\]\s*=\s*\{([\s\S]*?)\};', source)[1].split(',') if word.strip()] +assert len(words) == length and words[6] == org + length +cursor = words[5] - org +assert 7 <= cursor < length +records = {} +while words[cursor]: + kind, value, signature = words[cursor:cursor + 3] + assert kind in (1, 2, 3, 4, 6, 8), (cursor, kind) + cursor += 3 + name = '' + while words[cursor]: + assert 0 < words[cursor] < 128, (cursor, words[cursor]) + name += chr(words[cursor]) + cursor += 1 + cursor += 1 + records[name] = (kind, value, signature) +assert cursor == length - 1 +assert len(records) > 1000 and records['start'][0] == 1 +assert records['reserve_code'][0] == 1 and records['build_image_grow'][0] == 1 +assert any(name.startswith('doc ') for name in records), 'the fixture contains documentation bytes' +for name, (kind, value, _) in records.items(): + if kind == 8: + assert org <= value < org + length and words[value - org] >= 0, name +print('ok documented genome starts its symbol table at the header address') diff --git a/tests/drivers/hover_features_test.js b/tests/drivers/hover_features_test.js new file mode 100644 index 0000000..895e923 --- /dev/null +++ b/tests/drivers/hover_features_test.js @@ -0,0 +1,85 @@ +// focused type, inference, and block-documentation hover regressions. +// usage: node tests/drivers/hover_features_test.js path/to/zclsp +const assert = require('assert'); +const cp = require('child_process'); +const server = process.argv[2]; +const uri = 'file:///hover-features.zc'; +const frame = m => { + const body = Buffer.from(JSON.stringify(m)); + return Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\r\n\r\n'), body]); +}; +function hover(text, needle, nth = 0) { + let offset = -1; + for (let i = 0; i <= nth; i++) offset = text.indexOf(needle, offset + 1); + assert.ok(offset >= 0, 'fixture contains ' + needle); + const before = text.slice(0, offset); + const line = before.split('\n').length - 1; + const character = before.length - before.lastIndexOf('\n') - 1; + const messages = [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, languageId: 'zc', text } } }, + { jsonrpc: '2.0', id: 2, method: 'textDocument/hover', params: { textDocument: { uri }, position: { line, character } } }, + { jsonrpc: '2.0', method: 'exit' }, + ]; + const result = cp.spawnSync(server, process.argv.slice(3), { input: Buffer.concat(messages.map(frame)), timeout: 10000, maxBuffer: 4 << 20 }); + assert.strictEqual(result.status, 0, String(result.stderr)); + let data = result.stdout; + while (data.length) { + const end = data.indexOf('\r\n\r\n'); + const count = Number(/Content-Length:\s*(\d+)/i.exec(data.subarray(0, end).toString())[1]); + const msg = JSON.parse(data.subarray(end + 4, end + 4 + count).toString()); + data = data.subarray(end + 4 + count); + if (msg.id === 2) return msg.result && msg.result.contents.value; + } + return null; +} +for (const type of ['word', 'int', 'char', 'bool', 'float', 'f64', 'f32', 'byte', 'u8', 'i8', 'u16', 'i16', 'u32', 'i32']) { + const value = hover(type + ' global;\nfn main() { return 0; }\n', type); + assert.ok(value && value.startsWith('```zc\n') && value.includes('\n\n'), type + ' has formatted built-in documentation'); +} +assert.match(hover('void noop() {}\nfn main() { return 0; }\n', 'void'), /no meaningful result/); +const array = 'fn main() {\n [4]u8 values;\n return values[0];\n}\n'; +assert.match(hover(array, '[4]'), /fixed length of 4/); +assert.match(hover(array, 'values', 1), /type \[4\]u8/); +const struct = 'struct pair { int left; float right; };\nfn main() { pair value; return value.left; }\n'; +assert.match(hover(struct, 'pair', 1), /int left;[\s\S]*float right;/); +assert.match(hover(struct, 'value', 1), /type pair/); +const fields = 'struct pair { int /* left field */ left, /* right field */ right; };\nfn main() { pair p; return p.left + p.right; }\n'; +assert.match(hover(fields, 'left +'), /field; type int[\s\S]*left field/); +assert.match(hover(fields, 'right;'), /field; type int[\s\S]*right field/); +const empty = '/**/ int helper() { return 1; }\nfn main() { return helper(); }\n'; +assert.match(hover(empty, 'helper();'), /int helper\(\)/); +const variableDocs = 'fn main() {\n int /* first variable */ first = 1, /* second variable */ second = 2;\n return first + second;\n}\n'; +const first = hover(variableDocs, 'first +'); +const second = hover(variableDocs, 'second;'); +assert.ok(first.includes('first variable') && !first.includes('second variable'), first); +assert.ok(second.includes('second variable') && !second.includes('first variable'), second); +const paramDocs = 'int add(int /* left argument */ a, int /* right argument */ b) { return a + b; }\nfn main() { return add(1, 2); }\n'; +const a = hover(paramDocs, 'a +'); +const b = hover(paramDocs, 'b;'); +assert.ok(a.includes('left argument') && !a.includes('right argument'), a); +assert.ok(b.includes('right argument') && !b.includes('left argument'), b); +const functionDocs = '/**\n * adds **one** to its argument.\n */\nint add(int x) { return x + 1; }\nfn main() { return add(2); }\n'; +assert.match(hover(functionDocs, 'add(2)'), /adds \*\*one\*\*/); +const inferred = 'fn twice(x) { return x * 2; }\nfn main() { let value = twice(3); return value; }\n'; +assert.match(hover(inferred, 'twice(3)'), /int twice\(int x\)/); +assert.match(hover(inferred, 'x *'), /type int/); +assert.match(hover(inferred, 'value;'), /type int/); +const pointer = 'int read(int* p) { return p[0]; }\nfn main() { int value = 3; return read(&value); }\n'; +assert.match(hover(pointer, 'p[0]'), /type int\*[\s\S]*pointer to `int`/); +const alias = 'type row = [3]int;\nfn main() { row values; return values[0]; }\n'; +assert.match(hover(alias, 'row values'), /\[3\]int[\s\S]*fixed length of 3/); +const enumeration = 'enum color { red, green = 5, blue };\nfn main() { color shade = green; return shade; }\n'; +assert.match(hover(enumeration, 'shade;'), /type color/); +const dynamic = 'fn main() { []int items; push(items, 3); return len(items); }\n'; +assert.match(hover(dynamic, 'items);'), /type \[\]int[\s\S]*growable array handle/); +const callable = 'fn main() { let apply = fn(int x) { return x + 1; }; return apply(2); }\n'; +assert.match(hover(callable, 'apply(2)'), /type .*\([\s\S]*callable value/); +assert.match(hover(callable, 'apply(2)'), /type int\(int\)/); +const floatClosure = 'fn main() { let f = fn(float x) { return x + 1.5; }; let result = f(2.0); return result; }\n'; +assert.match(hover(floatClosure, 'result;'), /type float/); +const inferredPointer = 'struct node { int value; };\nfn make_node() { return new(node); }\nfn main() { let p = make_node(); return p.value; }\n'; +assert.match(hover(inferredPointer, 'p.value'), /type node\*/); +const aggregateAlias = 'fn main() { [3]int a; a[1] = 7; let alias = a; return alias[1]; }\n'; +assert.match(hover(aggregateAlias, 'alias[1]'), /type int\*/); +console.log('ok built-in and composite types, inferred signatures, and separate block docs'); diff --git a/tests/drivers/hover_test.js b/tests/drivers/hover_test.js new file mode 100644 index 0000000..10af643 --- /dev/null +++ b/tests/drivers/hover_test.js @@ -0,0 +1,23 @@ +// protocol conversion checks. run with node tests/drivers/hover_test.js. +const assert = require('assert'); +const { hoverContents } = require('../../editors/vscode/client/hover'); +class MarkdownString { + constructor() { this.value = ''; this.calls = []; } + appendMarkdown(value) { this.calls.push('markdown'); this.value += value; return this; } + appendText(value) { this.calls.push('text'); this.value += value; return this; } + appendCodeblock(value, language) { this.calls.push('code'); this.value += '\n```' + language + '\n' + value + '\n```\n'; return this; } +} +const api = { MarkdownString }; +const markdown = '```zc\nint add(int a, int b)\n```\n\nAdds **two** numbers.'; +const md = hoverContents(api, { kind: 'markdown', value: markdown })[0]; +assert.strictEqual(md.value, markdown); +assert.deepStrictEqual(md.calls, ['markdown']); +assert.strictEqual(md.isTrusted, false); +assert.strictEqual(md.supportHtml, false); +assert.deepStrictEqual(hoverContents(api, { kind: 'plaintext', value: '*literal*' })[0].calls, ['text']); +const legacy = hoverContents(api, { language: 'zc', value: 'int x;' })[0]; +assert.match(legacy.value, /```zc\nint x;\n```/); +assert.deepStrictEqual(legacy.calls, ['code']); +assert.strictEqual(hoverContents(api, ['**Doc**', { language: 'zc', value: 'fn main()' }]).length, 2); +for (const contents of [null, undefined, '', [], {}, { value: 123 }]) assert.deepStrictEqual(hoverContents(api, contents), []); +console.log('ok hover conversion preserves Markdown, plaintext, code, and arrays'); diff --git a/tests/drivers/image_growth_test.py b/tests/drivers/image_growth_test.py new file mode 100644 index 0000000..064eae7 --- /dev/null +++ b/tests/drivers/image_growth_test.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# image output can grow past its first buffer and the building host's genome slot. +import pathlib +import subprocess +import sys + +root = pathlib.Path(__file__).resolve().parents[2] +source = '''fn main() { + int retained = 7; + int n = build_image_grow(GORG, 8); + gc(); + put_n(n > FBOT - GORG); put_c(32); + put_n(image_size == n && image_words[6] == GORG + n); put_c(32); + put_n(image_words[5] > GORG && image_words[5] < image_words[6]); put_c(32); + put_n(retained); put_c(10); + return 0; +} +`''' +source += (root / 'src/runtime/rt.zc').read_text() +source += (root / 'src/runtime/run.zc').read_text() +source += 'let padding[350000]; fn main() { return padding[349999]; }\n' +result = subprocess.run(sys.argv[1:], input=source, capture_output=True, text=True, timeout=40) +assert result.returncode == 0, (result.stdout, result.stderr) +assert result.stdout == '1 1 1 7\n', result.stdout[:1000] +print('ok growing image output keeps logical addresses and older frames intact') diff --git a/tests/drivers/image_run.js b/tests/drivers/image_run.js new file mode 100644 index 0000000..6d0b949 --- /dev/null +++ b/tests/drivers/image_run.js @@ -0,0 +1,14 @@ +// run a generated image directly, avoiding compiler time in vm execution tests. +const fs = require('fs'); +const { TinyVM } = require('../../src/js/tinyvm.js'); +const { mapValues } = require('../../src/js/mapvalues.js'); +const header = fs.readFileSync(process.argv[2], 'utf8'); +const org = Number(header.match(/#define\s+GORG\s+(\d+)/)[1]); +const words = header.match(/genome\[\]\s*=\s*\{([\s\S]*?)\};/)[1].split(',').map(s => s.trim()).filter(Boolean); +const layout = mapValues(process.argv[3], ['GORG', 'DS0', 'DSLIM', 'RS0', 'RSLIM', 'SYSGATE', 'ZFP', 'HEAP_LO', 'HEAP_HI']); +const machine = new TinyVM({ layout, onMessage: message => process.stderr.write(message) }); +machine.loadGenome(words, org); +if (process.argv[4] === '--stdin') machine.setInput(fs.readFileSync(0)); +const status = machine.run(); +process.stdout.write(machine.getOutput()); +process.exit(status); diff --git a/tests/drivers/inference_growth_test.py b/tests/drivers/inference_growth_test.py new file mode 100644 index 0000000..d747f70 --- /dev/null +++ b/tests/drivers/inference_growth_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# infer past the original function-record and edit-count limits. +import re +import subprocess +import sys + +source = ''.join(f'fn function_{i}(x) {{ return x + 1; }}\n' for i in range(600)) +source += 'fn main() {\n' +source += ''.join(f'let value_{i} = {i};\n' for i in range(5000)) +source += ''.join(f'function_{i}(1);\n' for i in range(600)) +source += 'return value_4999; }\n' +result = subprocess.run([sys.argv[1], '--annotate'], input=source, text=True, capture_output=True, timeout=45) +assert result.returncode == 0, result.stderr +assert re.search(r'int function_599\(int x\)', result.stdout), result.stdout[-1000:] +assert 'int value_4999 = 4999;' in result.stdout, result.stdout[-1000:] +assert 'let value_' not in result.stdout, 'later inference edits were dropped' +print('ok inference handles 600 functions and 5000 local declarations') + +# later parameters and nested functions retain the enclosing inference context. +params = ', '.join(f'arg_{i}' for i in range(20)) +source = f'fn wide({params}) {{ let nested = fn(int y) {{ return y + 1; }}; return arg_19 + nested(1); }}\n' +source += 'fn main() { return wide(' + ', '.join('1' for _ in range(20)) + '); }\n' +result = subprocess.run([sys.argv[1], '--annotate'], input=source, text=True, capture_output=True, timeout=15) +assert result.returncode == 0, result.stderr +assert 'int arg_19' in result.stdout, result.stdout +assert 'int wide(' in result.stdout, result.stdout +print('ok later arguments and nested closures preserve inferred signatures') + +source = 'struct point { int x, y; };\nfn main() { let a[3]; a[1] = 7; let alias = a; point p; p.x = 9; let ref = p; return alias[1] + ref.x; }\n' +result = subprocess.run([sys.argv[1], '--annotate'], input=source, text=True, capture_output=True, timeout=15) +assert result.returncode == 0 and 'word* alias' in result.stdout and 'point* ref' in result.stdout, result.stdout +again = subprocess.run([sys.argv[1], '--annotate'], input=result.stdout, text=True, capture_output=True, timeout=15) +assert again.returncode == 0 and again.stdout == result.stdout, again.stdout +print('ok array and struct aliases get compatible pointer annotations') diff --git a/tests/drivers/layout_config_test.py b/tests/drivers/layout_config_test.py new file mode 100644 index 0000000..469e7dd --- /dev/null +++ b/tests/drivers/layout_config_test.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# generated layouts expose initial reservations and reject arithmetic overflow. +import pathlib +import subprocess +import tempfile + +root = pathlib.Path(__file__).resolve().parents[2] +tool = root / 'bin/genmap' +result = subprocess.run([tool, '--help'], capture_output=True, text=True) +assert result.returncode == 0 and '--generated-text' in result.stderr +invalid = [ + ['--symbols', '2147483647'], ['--code', '2147483647'], + ['--frame', '2147483647'], ['--return', '2147483647'], + ['--asm-labels', '2147483647'], ['--enum-members', '2147483647'], + ['--regions', '2147483647'], ['--heap', '7'], ['--hash', '3'], +] +for options in invalid: + result = subprocess.run([tool, '--preset', 'default', *options], capture_output=True, text=True) + assert result.returncode != 0 and result.stderr, options +with tempfile.TemporaryDirectory(dir=root / 'build') as directory: + options = ['--asm-labels', '4', '--asm-refs', '8', '--exit', '32', '--enum-members', '8', '--tagged-enums', '2', '--methods', '2', '--generated-text', '64', '--facts', '2', '--paths', '2', '--regions', '2'] + subprocess.run([tool, '--preset', 'default', *options, '-o', directory], check=True, capture_output=True) + source = (pathlib.Path(directory) / 'compiler.zc').read_text() + for name, value in [('ASM_LABELS', '4'), ('ASM_REFS', '8'), ('EXIT_WORDS', '20'), ('ENUM_MEMBERS', '8'), ('TAGGED_ENUMS', '2'), ('STRUCT_METHODS', '2'), ('GENERATED_WORDS', '40'), ('RANGE_FACTS', '2'), ('JOIN_PATHS', '2'), ('O3_REGIONS', '2')]: + assert f'const {name} = 0x{value};' in source, name +print('ok parser layout controls and invalid configuration checks') diff --git a/tests/drivers/learn.py b/tests/drivers/learn.py new file mode 100644 index 0000000..42f729f --- /dev/null +++ b/tests/drivers/learn.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# checks docs/LEARN.md against the examples it shows. every examples/learn/*.zc runs +# from the repo root with its stdout compared to the matching .out file, and +# every ```zc block in docs/LEARN.md must be exactly one of those files, so the +# guide can't drift from the code it teaches. usage: learn.py path/to/zy +import glob, os, platform, re, subprocess, sys + +root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.chdir(root) +zy = os.path.abspath(sys.argv[1]) +x86_only = {"22-raw-x86"} +bad = 0 + +def ok(name): + print(f"ok {name}") + +def fail(name, why): + global bad + bad += 1 + print(f"FAIL {name}: {why}") + +files = sorted(glob.glob("examples/learn/*.zc")) +for path in files: + name = os.path.splitext(os.path.basename(path))[0] + if name in x86_only and platform.machine() not in ("x86_64", "AMD64"): + print(f"skip {name} (x86-64 only)") + continue + with open(path, "rb") as f: + src = f.read() + try: + r = subprocess.run([zy], stdin=open(path, "rb"), capture_output=True, timeout=30) + except subprocess.TimeoutExpired: + fail(f"learn {name}", "timed out") + continue + got = r.stdout.decode("utf-8", "replace") + want_path = os.path.splitext(path)[0] + ".out" + if not os.path.exists(want_path): + fail(f"learn {name}", f"no {want_path}") + elif got == open(want_path, encoding="utf-8").read(): + ok(f"learn {name} prints what it says") + else: + fail(f"learn {name}", f"got [{got}]") + +# every zc block in docs/LEARN.md is one of the example files +with open("docs/LEARN.md", encoding="utf-8") as f: + doc = f.read() +blocks = re.findall(r"^```zc\n(.*?)^```", doc, re.S | re.M) +srcs = {} +for path in files: + with open(path, encoding="utf-8") as f: + srcs[os.path.basename(path)] = f.read() +used = set() +for b in blocks: + hit = [n for n, s in srcs.items() if s == b] + if hit: + used.add(hit[0]) + else: + fail("learn block", "a ```zc block in docs/LEARN.md is not an examples/learn file: " + b.splitlines()[0]) +for n in srcs: + if n not in used: + fail(f"learn {n}", "not shown in docs/LEARN.md") +if not bad: + ok(f"docs/LEARN.md: {len(blocks)} blocks, all match examples/learn") +sys.exit(1 if bad else 0) diff --git a/tests/drivers/local_index_test.py b/tests/drivers/local_index_test.py new file mode 100644 index 0000000..95bfd78 --- /dev/null +++ b/tests/drivers/local_index_test.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# local name heads survive shadowing, scope reuse, closures, and context switches. +import subprocess +import sys +source = ''' +fn choose(int x) { + let total = x; + { let x = 7; total += x; } + { let x = 9; total += x; } + let f = fn(int x) { return x + 1; }; + let g = fn() { return total; }; + return total + x + f(3) + g(); +} +o3 int fast(int x) { int result = x; { int x = 8; result += x; } return result + x; } +fn main() { put_n(choose(2)); put_c(32); put_n(fast(3)); put_c(10); return 0; } +''' +result = subprocess.run(sys.argv[1:], input=source, text=True, capture_output=True, timeout=15) +assert result.returncode == 0 and result.stdout == '42 14\n', (result.returncode, result.stdout, result.stderr) +print('ok local shadow chains, reused scopes, closure parameters, captures, and o3 contexts') +source = 'struct point { int x, y; };\nfn main() { let a[3]; a[1] = 7; let alias = a; point p; p.x = 9; let ref = p; put_n(alias[1]); put_c(32); put_n(ref.x); put_c(10); return 0; }\n' +result = subprocess.run(sys.argv[1:], input=source, text=True, capture_output=True, timeout=15) +assert result.returncode == 0 and result.stdout == '7 9\n', (result.returncode, result.stdout, result.stderr) +print('ok inferred aliases load array and struct addresses rather than the alias slot') diff --git a/tests/drivers/lsp_growth_test.js b/tests/drivers/lsp_growth_test.js new file mode 100644 index 0000000..b06230a --- /dev/null +++ b/tests/drivers/lsp_growth_test.js @@ -0,0 +1,53 @@ +// focused regressions for growing document, uri, hover, and name-set storage. +// usage: node tests/drivers/lsp_growth_test.js path/to/zclsp-or-lsp.wasm +const assert = require('assert'); +const cp = require('child_process'); +const { wasmProcess } = require('../../editors/vscode/client/wasmproc'); +const target = process.argv[2]; +const frame = m => { + const body = Buffer.from(JSON.stringify(m)); + return Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\r\n\r\n'), body]); +}; +function parse(input) { + const messages = []; + for (let offset = 0; offset < input.length;) { + const end = input.indexOf('\r\n\r\n', offset); + assert.ok(end >= 0, 'complete response header'); + const length = Number(/Content-Length:\s*(\d+)/i.exec(input.subarray(offset, end).toString())[1]); + const start = end + 4; + assert.ok(start + length <= input.length, 'complete response body'); + messages.push(JSON.parse(input.subarray(start, start + length).toString())); + offset = start + length; + } + return messages; +} +const uri = 'file:///' + 'long-directory/'.repeat(30) + 'main.zc'; +const docs = '// **formatted** documentation with `inline code`.\n' + ('// a long paragraph of documentation.\n').repeat(150) + '// final documentation line\n'; +const source = docs + 'int helper() { return 1; }\nfn main() { return helper(); }\n'; +const messages = [{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }]; +for (let i = 0; i < 32; i++) messages.push({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri: i === 31 ? uri : 'file:///doc' + i + '.zc', languageId: 'zc', version: 1, text: source } } }); +messages.push({ jsonrpc: '2.0', id: 2, method: 'textDocument/hover', params: { textDocument: { uri }, position: { line: source.split('\n').length - 2, character: 19 } } }); +messages.push({ jsonrpc: '2.0', method: 'exit' }); +let output; +if (target.endsWith('.wasm')) { + const chunks = []; + const server = wasmProcess(target); + server.stdout.on('data', data => chunks.push(data)); + for (const message of messages) assert.ok(server.stdin.write(frame(message))); + output = Buffer.concat(chunks); +} else { + const result = cp.spawnSync(target, [], { input: Buffer.concat(messages.map(frame)), timeout: 30000, maxBuffer: 8 << 20 }); + assert.strictEqual(result.status, 0, String(result.stderr)); + output = result.stdout; +} +const responses = parse(output); +const published = responses.filter(m => m.method === 'textDocument/publishDiagnostics'); +assert.strictEqual(published.length, 32, 'every open document is published'); +assert.strictEqual(published[31].params.uri, uri, 'a long uri is echoed without truncation'); +assert.ok(published.every(m => !m.params.diagnostics.some(d => d.severity === 1)), 'all documents check cleanly'); +const hover = responses.find(m => m.id === 2).result.contents; +assert.strictEqual(hover.kind, 'markdown'); +assert.match(hover.value, /^```zc\nint helper\(\)\n```\n\n/); +assert.ok(hover.value.includes('**formatted**')); +assert.ok(hover.value.includes('final documentation line'), 'hover documentation grows past its initial buffer'); +console.log('ok lsp grows beyond 16 documents, 160 uri bytes, and 2300 hover characters'); diff --git a/tests/drivers/lsp_live.js b/tests/drivers/lsp_live.js new file mode 100644 index 0000000..b5fe805 --- /dev/null +++ b/tests/drivers/lsp_live.js @@ -0,0 +1,23 @@ +// the native server must answer while the client keeps the pipe open and waits, as an editor +// does: one message in, the answer out, before anything else is sent. an output buffer that +// is only flushed at exit, or an end-of-input peek that waits for the next message, fails this. +// usage: node tests/drivers/lsp_live.js path/to/zclsp +const cp = require('child_process'); +const p = cp.spawn(process.argv[2], []); +let inbox = Buffer.alloc(0), step = 0, done = false; +const frame = m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); }; +const finish = (ok, what) => { if (done) return; done = true; p.kill(); console.log((ok ? 'ok ' : 'FAIL ') + what); process.exit(ok ? 0 : 1); }; +const timer = setTimeout(() => finish(false, 'lsp live: no answer in 5 s while the pipe stayed open (step ' + step + ')'), 5000); +p.stdout.on('data', d => { + inbox = Buffer.concat([inbox, d]); + 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()); + inbox = inbox.slice(e + 4 + n); + if (step === 0 && m.id === 1) { + step = 1; // the answer came with the pipe still open: ask for something else + p.stdin.write(frame({ jsonrpc: '2.0', id: 2, method: 'shutdown', params: null })); + } else if (step === 1 && m.id === 2) { clearTimeout(timer); finish(true, 'lsp live: initialize and shutdown are answered while the pipe stays open'); } +}); +p.stdin.write(frame({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { processId: 1, capabilities: {} } })); diff --git a/tests/drivers/lsp_sandbox.js b/tests/drivers/lsp_sandbox.js new file mode 100644 index 0000000..14bc1e3 --- /dev/null +++ b/tests/drivers/lsp_sandbox.js @@ -0,0 +1,22 @@ +// opening a file in the editor checks it, and a check runs the file's compile-time code +// (immediate functions). the server must not let that code write files, so a file that +// tries to leaves nothing behind and the server still answers. +// usage: node tests/drivers/lsp_sandbox.js path/to/zclsp +const cp = require('child_process'), fs = require('fs'), os = require('os'), path = require('path'); +const target = path.join(os.tmpdir(), 'zc-sandbox-' + process.pid); +try { fs.unlinkSync(target); } catch (e) {} +const src = 'immediate fn evil() { let fd = syscall(1, "' + target + '", 1, 0); syscall(4, fd, 65, 1); syscall(2, fd, 0, 0); return 0; }\nevil\nfn main() { return 0; }\n'; +const frame = m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); }; +const input = Buffer.concat([ + frame({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { processId: 1, capabilities: {} } }), + frame({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri: 'file:///evil.zc', languageId: 'zc', version: 1, text: src } } }), + frame({ jsonrpc: '2.0', id: 3, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri: 'file:///evil.zc' } } }), + frame({ jsonrpc: '2.0', method: 'exit' }), +]); +const r = cp.spawnSync(process.argv[2], [], { input, timeout: 20000 }); +const wrote = fs.existsSync(target); +if (wrote) fs.unlinkSync(target); +const answered = r.stdout && r.stdout.includes('"id":3'); +const ok = !wrote && answered; +console.log((ok ? 'ok ' : 'FAIL ') + 'lsp sandbox: compile-time code cannot write files' + (ok ? '' : ' (wrote: ' + wrote + ', answered: ' + !!answered + ')')); +process.exit(ok ? 0 : 1); diff --git a/tests/drivers/lsp_stream.js b/tests/drivers/lsp_stream.js new file mode 100644 index 0000000..0dd0553 --- /dev/null +++ b/tests/drivers/lsp_stream.js @@ -0,0 +1,11 @@ +// expose either extension server host as a stdin/stdout process for focused tests. +const fs = require('fs'); +const path = require('path'); +const target = path.resolve(process.argv[2]); +const { wasmProcess } = require('../../editors/vscode/client/wasmproc'); +const { vmProcess } = require('../../editors/vscode/client/vmproc'); +const server = target.endsWith('.wasm') ? wasmProcess(target) : vmProcess(target); +server.stdout.on('data', bytes => process.stdout.write(bytes)); +server.stderr.on('data', bytes => process.stderr.write(bytes)); +server.on('exit', status => { if (status !== 0) process.exitCode = 1; }); +server.stdin.write(fs.readFileSync(0)); 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); diff --git a/tests/drivers/lsp_vm.js b/tests/drivers/lsp_vm.js new file mode 100644 index 0000000..24c1a5b --- /dev/null +++ b/tests/drivers/lsp_vm.js @@ -0,0 +1,112 @@ +// tests for the language server run by the javascript vm (src/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); +} +// 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); diff --git a/tests/drivers/lsp_wasm.js b/tests/drivers/lsp_wasm.js new file mode 100644 index 0000000..4ff2d16 --- /dev/null +++ b/tests/drivers/lsp_wasm.js @@ -0,0 +1,112 @@ +// tests for the language server as WebAssembly, through the extension's own +// host (editors/vscode/client/wasmproc.js). the same protocol checks as the +// native server, plus the hover docs of a compiler name. +// usage: node tests/drivers/lsp_wasm.js path/to/lsp.wasm +const path = require('path'); +const { wasmProcess } = require(path.join(__dirname, '..', '..', 'editors', 'vscode', 'client', 'wasmproc.js')); +const fs = require('fs'); +const wasm = process.argv[2]; +let ok = 0, bad = 0; +function check(cond, label, extra) { + if (cond) { ok++; console.log('ok lsp wasm ' + label); } + else { bad++; console.log('FAIL lsp wasm ' + 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 = wasmProcess(wasm, { 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); +} +// 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 wasm checks passed, ' + bad + ' failed'); +process.exit(bad ? 1 : 0); diff --git a/tests/drivers/memory_limits_test.py b/tests/drivers/memory_limits_test.py new file mode 100644 index 0000000..a973480 --- /dev/null +++ b/tests/drivers/memory_limits_test.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# focused host memory configuration and growth checks. +import os +import subprocess + +source = '''fn main() { + int initial = syscall(9, 0, 0, 0); + put_n(syscall(9, 5, initial, 0) == initial); put_c(32); + put_n(syscall(9, 4, initial + 4096, 0) == -1); put_c(32); + put_n(syscall(9, 5, initial + 8192, 0) == initial + 8192); put_c(32); + put_n(syscall(9, 4, initial + 4096, 0) == initial + 4096); put_c(32); + put_n(syscall(9, 5, initial - 1, 0) == -1); put_c(10); + return 0; +} +''' +commands = [['bin/zy'], ['bin/zym'], ['node', 'src/js/tinyvm.js']] +environment = dict(os.environ, TINYVM_MAX_MEMORY_WORDS='16777216') +for command in commands: + result = subprocess.run(command, input=source, text=True, capture_output=True, env=environment, timeout=15) + assert result.returncode == 0 and result.stdout == '1 1 1 1 1\n', (command, result.stdout, result.stderr) + invalid = subprocess.run(command, input=source, text=True, capture_output=True, env=dict(environment, TINYVM_MEMORY_WORDS='1'), timeout=15) + assert invalid.returncode != 0 and invalid.stderr, (command, invalid.stdout) + print('ok runtime memory limits, denied growth, successful growth, and invalid startup: ' + command[0]) diff --git a/tests/drivers/parser_growth_test.py b/tests/drivers/parser_growth_test.py new file mode 100644 index 0000000..4cbfbb2 --- /dev/null +++ b/tests/drivers/parser_growth_test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# parser storage grows across enum members, tagged enums, methods, and generated text. +import subprocess +import sys + +command = sys.argv[1:] + + +def run(source, expected): + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=30) + assert result.returncode == 0, (result.stdout, result.stderr) + assert result.stdout == expected, result.stdout[:1000] + + +source = 'enum values { ' + ', '.join(f'member_{i}' for i in range(300)) + ' };\n' +source += 'fn main() { gc(); put_n(member_299); put_c(10); return 0; }\n' +run(source, '299\n') +source = ''.join(f'enum tagged_{i} {{ item_{i} {{ int value; }}, empty_{i} }};\n' for i in range(25)) +source += 'fn main() { tagged_24*.m item = tagged_24.item_24(7); gc(); put_n(item.item_24_value); put_c(10); return 0; }\n' +run(source, '7\n') +source = 'struct methods { int value;\n' +source += ''.join(f'int get_{i}() {{ return this.value + {i}; }}\n' for i in range(100)) +source += '}\nfn main() { methods item; item.value = 7; gc(); put_n(item.get_99()); put_c(10); return 0; }\n' +run(source, '106\n') +source = 'enum expanded {\n' +source += ',\n'.join(f'member_with_long_generated_name_{i} {{ ' + ''.join(f'int field_with_long_generated_name_{j}; ' for j in range(8)) + '}' for i in range(40)) +source += '\n};\nfn main() { gc(); put_n(ebcap > 8192); put_c(10); return 0; }\n' +run(source, '1\n') +source = 'fn main() { int result = 0; switch (127) {\n' +source += ''.join(f'case {i}: result = {i};\n' for i in range(128)) +source += '} gc(); put_n(result); put_c(32); put_n(jpscap > 64); put_c(10); return 0; }\n' +run(source, '127 1\n') +source = 'fn main() { int result = 0;\n' +source += ''.join('o3 { result += 1; }\n' for _ in range(40)) +source += 'gc(); put_n(result); put_c(32); put_n(o3pendcap > 10); put_c(10); return 0; }\n' +run(source, '40 1\n') +source = 'fn main() { int i = 0; while (i < 120) { putfact(i, i, 1); i++; } gc(); put_n(factcap > 32); put_c(32); put_n(getlo(119)); put_c(10); return 0; }\n' +run(source, '1 119\n') +long_type = 'structure_' + 't' * 180 +long_method = 'method_' + 'm' * 180 +source = f'struct {long_type} {{ int {long_method}() {{ return 7; }} }};\n' +source += f'fn main() {{ {long_type} value; put_n(value.{long_method}()); put_c(10); return 0; }}\n' +run(source, '7\n') +name = 'generic_' + 'g' * 180 +source = f'int {name}_one<T>() {{ return sizeof(T); }}\nint {name}_two<T>() {{ return sizeof(T) + 1; }}\n' +source += f'fn main() {{ put_n({name}_one<int>() + {name}_two<int>()); put_c(10); return 0; }}\n' +run(source, '3\n') +print('ok parser tables, analyzer paths, queued regions, and generated names grow') diff --git a/tests/drivers/protocol_growth_test.js b/tests/drivers/protocol_growth_test.js new file mode 100644 index 0000000..64015d8 --- /dev/null +++ b/tests/drivers/protocol_growth_test.js @@ -0,0 +1,34 @@ +// preserve long request identifiers and ignore deeply nested unknown json fields. +const assert = require('assert'); +const cp = require('child_process'); +const target = process.argv[2]; +const id = 'request_' + 'x'.repeat(500); +let nested = { ['unknown_' + 'y'.repeat(100)]: 1 }; +for (let i = 0; i < 40; i++) nested = { child: nested }; +const frame = message => { + const body = Buffer.from(JSON.stringify(message)); + return Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\r\nX-Extra: ' + 'z'.repeat(500) + '\r\n\r\n'), body]); +}; +const messages = [{ jsonrpc: '2.0', id, method: 'initialize', extra: nested, params: {} }]; +for (let i = 0; i < 24; i++) messages.push({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { + uri: 'file:///enum_' + i + '.zc', version: 1, languageId: 'zc', + text: 'enum choice { item { int value; }, empty };\nfn main() { choice*.m p = choice.item(7); return p.item_value; }\n', +} } }); +messages.push({ jsonrpc: '2.0', id: 'finish', method: 'shutdown', params: null }, { jsonrpc: '2.0', method: 'exit' }); +const result = cp.spawnSync(target, [], { input: Buffer.concat(messages.map(frame)), timeout: 20000, maxBuffer: 8 << 20 }); +assert.strictEqual(result.status, 0, String(result.stderr)); +const responses = []; +let data = result.stdout; +while (data.length) { + const end = data.indexOf('\r\n\r\n'); + assert.ok(end >= 0); + const length = Number(/Content-Length:\s*(\d+)/i.exec(data.subarray(0, end).toString())[1]); + responses.push(JSON.parse(data.subarray(end + 4, end + 4 + length).toString())); + data = data.subarray(end + 4 + length); +} +assert.ok(responses.some(message => message.id === id && message.result.capabilities), 'the full request id is echoed'); +assert.ok(responses.some(message => message.id === 'finish'), 'the next request is parsed'); +const diagnostics = responses.filter(message => message.method === 'textDocument/publishDiagnostics'); +assert.strictEqual(diagnostics.length, 24); +assert.ok(diagnostics.every(message => !message.params.diagnostics.some(diagnostic => diagnostic.severity === 1)), JSON.stringify(diagnostics)); +console.log('ok long protocol fields, deep json, and tagged enums across document checks'); diff --git a/tests/drivers/seed_growth_test.py b/tests/drivers/seed_growth_test.py new file mode 100644 index 0000000..426c970 --- /dev/null +++ b/tests/drivers/seed_growth_test.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# stage 0 accepts long source streams and grows a tiny initial image reservation. +import pathlib +import subprocess +import tempfile + +root = pathlib.Path(__file__).resolve().parents[2] +with tempfile.TemporaryDirectory(prefix='seed-growth-', dir=root / 'build') as directory: + folder = pathlib.Path(directory) + subprocess.run(['bin/genmap', '--preset', 'run', '--grow', '--genome', '600000', '--heap', '1048576', '-o', directory], check=True, capture_output=True) + compiler = folder / 'zc0' + subprocess.run(['cc', '-O1', '-w', '-fwrapv', '-DMAXW=16', 'src/tools/zc0.c', '-I' + directory, '-Isrc/core', '-o', str(compiler)], check=True) + source = '/*' + 'x' * (5 << 20) + '*/\n' + source += (folder / 'rt.zc').read_text() + (root / 'src/runtime/run.zc').read_text() + source += 'let data[550000]; fn main() { data[549999] = 7; put_n(data[549999]); put_c(32); put_n(4611686018427387904); put_c(10); return 0; }\n' + with (folder / 'genome.h').open('w') as output: + subprocess.run([str(compiler)], input=source, text=True, stdout=output, check=True, timeout=30) + binary = folder / 'vm' + import shutil + for path in ['src/micro/micro.c', 'src/micro/micro_port.h', 'src/micro/port_linux.c', 'src/core/fops.h', 'src/core/bops.h', 'src/core/stack.h']: + shutil.copy(root / path, folder) + subprocess.run(['cc', '-O2', '-w', '-fwrapv', str(folder / 'micro.c'), str(folder / 'port_linux.c'), '-I' + directory, '-o', str(binary)], check=True) + result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=20) + assert result.returncode == 0 and result.stdout == '7 4611686018427387904\n', (result.returncode, result.stdout, result.stderr) +print('ok stage 0 reads beyond four megabytes and grows beyond its initial image capacity') diff --git a/tests/drivers/stack_growth_test.py b/tests/drivers/stack_growth_test.py new file mode 100644 index 0000000..d4abe81 --- /dev/null +++ b/tests/drivers/stack_growth_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# operand values and return addresses survive explicit and automatic growth. +import subprocess +import sys +import tempfile +from pathlib import Path + +command = sys.argv[1:] + + +def run(source, expected, compile_image=False): + if compile_image and command[0] == 'node': + # compile the execution stress cases once with stage 0, so a javascript + # compiler's speed does not determine the execution-test timeout. + with tempfile.TemporaryDirectory(prefix='stack-image-', dir='build') as directory: + target = Path(directory) + subprocess.run(['bin/genmap', '--preset', 'run', '--grow', '--heap', '1048576', '--genome', '131072', '--frame', '4096', '--return', '256', '--data', '3072', '-o', directory], check=True, capture_output=True) + compiler = target / 'zc0' + subprocess.run(['cc', '-O1', '-w', '-fwrapv', 'src/tools/zc0.c', '-I' + directory, '-Isrc/core', '-o', str(compiler)], check=True, capture_output=True) + full_source = (target / 'rt.zc').read_text() + Path('src/runtime/run.zc').read_text() + source + image = subprocess.run([str(compiler)], input=full_source, text=True, capture_output=True, check=True) + header = target / 'genome.h' + header.write_text(image.stdout) + result = subprocess.run(['node', 'tests/drivers/image_run.js', str(header), str(target / 'memmap.h')], text=True, capture_output=True, timeout=45) + else: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=45) + assert result.returncode == 0 and result.stdout == expected, (command, result.returncode, result.stdout, result.stderr) + + +run('''fn main() { + int old_data = data_top - data_low, old_return = return_top - return_low; + put_n(reserve_data(old_data + 1) > old_data); put_c(32); + put_n(reserve_returns(old_return + 1) > old_return); put_c(32); + gc(); put_n(data_node != 0 && return_node != 0); put_c(10); return 0; +} +''', '1 1 1\n') + +run('''fn main() { + put_n(reserve_data(-1) == -1); put_c(32); put_n(reserve_returns(-1) == -1); put_c(32); + put_n(syscall(9, 6, 0, 0) == -1); put_c(32); + int current = syscall(9, 0, 0, 0); syscall(9, 5, current, 0); + put_n(reserve_data(0x10000000) == -1); put_c(32); gc(); put_n(7); put_c(10); return 0; +} +''', '1 1 1 1 7\n') + +count = 40000 +source = 'fn main() { int total = asm { ' + ' '.join(str(i + 1) for i in range(count)) +source += ' + ' * (count - 1) + ' }; gc(); put_n(total); put_c(32); ' +source += 'put_n(data_node != 0); put_c(10); return 0; }\n' +run(source, '800020000 1\n', compile_image=True) + +run('''unchecked int descend(int n) { + if (!n) { gc(); return 7; } + return descend(n - 1) + 1; +} +fn main() { put_n(descend(70000)); put_c(32); put_n(return_node != 0); put_c(10); return 0; } +''', '70007 1\n', compile_image=True) +print('ok explicit reservation, 40000 operands, and 70000 return addresses: ' + command[0]) diff --git a/tests/drivers/storage_analysis_test.js b/tests/drivers/storage_analysis_test.js new file mode 100644 index 0000000..da967f5 --- /dev/null +++ b/tests/drivers/storage_analysis_test.js @@ -0,0 +1,71 @@ +// definite storage findings use the compiler's parser and the editor protocol. +const assert = require('assert'); +const cp = require('child_process'); +const server = process.argv[2]; +function check(text, needle) { + const uri = 'file:///storage-analysis.zc'; + const messages = [{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, text } } }]; + if (needle) { + const at = text.lastIndexOf(needle), before = text.slice(0, at); + assert.ok(at >= 0); + messages.push({ jsonrpc: '2.0', id: 2, method: 'textDocument/hover', params: { textDocument: { uri }, position: { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 } } }); + } + messages.push({ jsonrpc: '2.0', method: 'exit' }); + const input = Buffer.concat(messages.map(m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); })); + const r = cp.spawnSync(server, process.argv.slice(3), { input, timeout: 20000, maxBuffer: 8 << 20 }); + assert.strictEqual(r.status, 0, String(r.stderr)); + const replies = []; let data = r.stdout; + while (data.length) { + const end = data.indexOf('\r\n\r\n'); assert.ok(end >= 0); + const n = Number(/Content-Length:\s*(\d+)/i.exec(data.subarray(0, end).toString())[1]); + replies.push(JSON.parse(data.subarray(end + 4, end + 4 + n))); + data = data.subarray(end + 4 + n); + } + const diagnostics = replies.find(m => m.method === 'textDocument/publishDiagnostics').params.diagnostics; + return { diagnostics, warnings: diagnostics.filter(d => d.severity === 2), hover: replies.find(m => m.id === 2)?.result?.contents?.value }; +} +const prefix = 'unchecked fn main() {\n'; +for (const [type, length] of [['int', 3], ['u8', 3], ['fixed []int', 3], ['[]u8', 3]]) { + const r = check(prefix + ` let p = new(${type}, ${length});\n let alias = p;\n return alias[${length}];\n}\n`, 'alias['); + assert.strictEqual(r.warnings.length, 1, JSON.stringify(r.diagnostics)); + assert.match(r.warnings[0].message, /out-of-bounds.*index 3.*length 3/); + assert.match(r.hover, /size: 3 elements.*lifetime: heap storage; live here/); + assert.strictEqual(r.warnings[0].range.start.line, 3); +} +const negative = check(prefix + ' let p = new(3); return p[-1];\n}\n'); +assert.match(negative.warnings[0]?.message || '', /index -1.*length 3/); +const freed = check(prefix + ' let p = new(int, 3);\n let q = p;\n free(p);\n return q[0];\n}\n', 'q['); +assert.strictEqual(freed.warnings.length, 1, JSON.stringify(freed.diagnostics)); +assert.match(freed.warnings[0].message, /use after free.*line 4/); +assert.match(freed.hover, /size: 3 elements.*lifetime: ended on line 4/); +const dereference = check(prefix + ' let p = new(int); free(p); return *p;\n}\n'); +assert.match(dereference.warnings[0]?.message || '', /use after free/); +const field = check('struct item { int value; };\n' + prefix + ' let p = new(item); free(p); return p.value;\n}\n'); +assert.match(field.warnings[0]?.message || '', /use after free/); +const scope = check(prefix + ' int* p;\n { let a[3]; p = a; }\n return p[0];\n}\n', 'p['); +assert.match(scope.warnings[0]?.message || '', /use after free.*line 3/); +assert.match(scope.hover, /lifetime: ended on line 3/); +const safe = [ + prefix + ' let p = new(int, 3); let q = p as u8*; return q[23]; }', + prefix + ' let p = new(int, 3); let q = (u8*)p; return q[23]; }', + prefix + ' int n = 2; let p = &n; let q = n; return q[2]; }', + prefix + ' let p = new(int, 3); let ignored = 0 && free(p); return p[0]; }', + prefix + ' let p = new(int, 3); let ignored = 1 || free(p); return p[0]; }', + prefix + ' let p = new(int, 50); return p[(u8)300]; }', + prefix + ' let p = new(int, 50); return p[300 as u8]; }', + prefix + ' let p = new(int, 3); let q = p; q[2] = 7; free(p); return 0; }', + prefix + ' let p = new(3); free(p); p = new(3); return p[0]; }', + 'fn unknown(int* p) {}\n' + prefix + ' let p = new(int, 3); unknown(p); return p[9]; }', + 'fn maybe(int n) { return n; }\n' + prefix + ' let p = new(int, 3); return p[maybe(9)]; }', + prefix + ' let p = new(int, 3); if (1) free(p); return p[0]; }', + prefix + ' let p = new(int, 3); while (0) free(p); return p[0]; }', + prefix + ' let p = new(int, 3); let f = fn() { free(p); return 0; }; return p[0]; }', + prefix + ' let p = new([]int, 0); push(p, 9); return p[0]; }', +]; +for (const source of safe) assert.deepStrictEqual(check(source).warnings, [], source); +const unknown = check('int read(int* p) { return p[0]; }\nfn main() { int n = 1; return read(&n); }\n', 'p['); +assert.match(unknown.hover, /size: unknown.*lifetime: unknown/); +const stack = check('fn main() { let a[3]; return a[2]; }\n', 'a['); +assert.match(stack.hover, /size: 3 elements.*lifetime: stack storage/); +console.log('ok definite bounds, free and scope lifetimes, alias hovers, and conservative unknown cases'); diff --git a/tests/drivers/vscode_hover_test.js b/tests/drivers/vscode_hover_test.js new file mode 100644 index 0000000..378dda2 --- /dev/null +++ b/tests/drivers/vscode_hover_test.js @@ -0,0 +1,48 @@ +// run inside a vscode extension test host to check the real hover api objects. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vscode = require('vscode'); +exports.run = async () => { + const root = path.resolve(__dirname, '..', '..'); + const fixture = path.join(root, 'build', 'review', 'hover-fixture.zc'); + fs.mkdirSync(path.dirname(fixture), { recursive: true }); + fs.writeFileSync(fixture, '// adds **two** numbers with `integer` arithmetic.\nint add(int a, int b) { return a + b; }\nfn main() { return add(1, 2); }\n'); + const extension = vscode.extensions.getExtension('tinyvm.zc-language-support'); + assert.ok(extension, 'development extension is loaded'); + const doc = await vscode.workspace.openTextDocument(fixture); + await vscode.window.showTextDocument(doc); + await extension.activate(); + let hover; + for (let attempt = 0; attempt < 100; attempt++) { + const replies = await vscode.commands.executeCommand('vscode.executeHoverProvider', doc.uri, new vscode.Position(2, 19)); + hover = replies && replies.find(reply => reply.contents.some(md => md.value.includes('adds **two** numbers'))); + if (hover) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.ok(hover, 'real vscode provider returns declaration documentation'); + const md = hover.contents[0]; + assert.ok(md instanceof vscode.MarkdownString, 'hover contains a real MarkdownString'); + assert.match(md.value, /^```zc\nint add\(int a, int b\)\n```\n\n/); + assert.ok(md.value.includes('**two**') && md.value.includes('`integer`')); + assert.strictEqual(md.isTrusted, false); + const storageFile = path.join(root, 'build', 'review', 'hover-storage.zc'); + fs.writeFileSync(storageFile, 'unchecked fn main() {\n let p = new(int, 3);\n let /* borrowed array */ q = p;\n free(p);\n return q[0];\n}\n'); + const storageDoc = await vscode.workspace.openTextDocument(storageFile); + await vscode.window.showTextDocument(storageDoc); + let storageHover; + for (let attempt = 0; attempt < 100; attempt++) { + const replies = await vscode.commands.executeCommand('vscode.executeHoverProvider', storageDoc.uri, new vscode.Position(4, 8)); + storageHover = replies && replies.find(reply => reply.contents.some(md => md.value.includes('size: 3 elements'))); + if (storageHover) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.ok(storageHover, 'real editor returns storage metadata'); + const storageMd = storageHover.contents[0]; + assert.ok(storageMd instanceof vscode.MarkdownString); + assert.match(storageMd.value, /borrowed array/); + assert.match(storageMd.value, /lifetime: ended on line 4/); + const result = 'ok real vscode markdown, code fences, block docs, inferred pointer types, and lifetime metadata\n'; + fs.writeFileSync(path.join(root, 'build', 'review', 'vscode-hover.log'), result); + console.log(result.trim()); +}; diff --git a/tests/drivers/wasm_test.js b/tests/drivers/wasm_test.js new file mode 100644 index 0000000..ddab3be --- /dev/null +++ b/tests/drivers/wasm_test.js @@ -0,0 +1,28 @@ +// runs programs on the webassembly build (src/web/zc.wasm) and compares with the expected output. +// usage: node tests/drivers/wasm_test.js [path/to/zc.wasm] +const fs = require('fs'); +const ZC = require('../../src/web/zc.js'); +(async () => { + const zc = await ZC.load(fs.readFileSync(process.argv[2] || __dirname + '/../../src/web/zc.wasm')); + const cases = [ + ['examples/hello.zc', 'hello, world!'], + ['examples/fact.zc', '1101110101111100000000\n3628800\n'], + ['examples/selfpatch.zc', 'ABC'], + ['examples/lists.zc', '[0 1 4 9 16 25]\nlive: 7\nafter the block: 0\n'], + ['examples/wiring.zc', '16 1234 25 4/9'], + ['examples/struct_closure_opt.zc', '299\n'], + ['examples/opt_test.zc', '1303\n'], + ['tests/programs/basics.zc', '285 610 42 512\nhey 65 -3 -2 1024 -8\n12456\n757\n9 100-101\n5\nok\n'], + ]; + let ok = 0, bad = 0; + for (const [file, want] of cases) { + const r = await zc.run(fs.readFileSync(__dirname + '/../../' + file, 'utf8')); + if (r.output.replace(/\n+$/, '') === want.replace(/\n+$/, '')) { ok++; console.log('ok wasm ' + file); } + else { bad++; console.log('FAIL wasm ' + file + ': got ' + JSON.stringify(r.output) + ' ' + r.messages); } + } + // an error is reported the way the interpreter reports it + const r = await zc.run('fn main() { return undefined_name; }'); + if (/undefined_name/.test(r.output)) { ok++; console.log('ok wasm compiler errors'); } else { bad++; console.log('FAIL wasm compiler errors: ' + JSON.stringify(r.output)); } + console.log(ok + ' wasm checks passed, ' + bad + ' failed'); + process.exit(bad ? 1 : 0); +})(); diff --git a/tests/drivers/word_literals_test.py b/tests/drivers/word_literals_test.py new file mode 100644 index 0000000..a2d230d --- /dev/null +++ b/tests/drivers/word_literals_test.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# every 64-bit word is representable, even outside a single literal's 63-bit range. +import subprocess + +values = [0, 1, (1 << 62) - 1, 1 << 62, (1 << 63) - 1, -(1 << 62), -(1 << 62) - 1, -(1 << 63), -1] +source = 'fn main() {\n' + ''.join(f'put_n({value}); put_c(10);\n' for value in values) + 'return 0; }\n' +expected = ''.join(str(value) + '\n' for value in values) +for command in [['bin/zy'], ['bin/zym'], ['node', 'src/js/tinyvm.js']]: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=20) + assert result.returncode == 0 and result.stdout == expected, (command, result.stdout, result.stderr) + print('ok full-width positive and negative word literals: ' + command[0]) +source = '''immediate fn add_word_operator() { next(); add_binop("**", 11, "4611686018427387904 | +"); return 0; } +add_word_operator +fn main() { put_n(0 ** 0); put_c(10); return 0; } +''' +for command in [['bin/zy'], ['bin/zym'], ['node', 'src/js/tinyvm.js']]: + result = subprocess.run(command, input=source, text=True, capture_output=True, timeout=20) + assert result.returncode == 0 and result.stdout == '4611686018427387904\n', (command, result.stdout, result.stderr) + print('ok full-width word in a stored operator sequence: ' + command[0]) |
