diff options
Diffstat (limited to 'tests/drivers')
43 files changed, 2060 insertions, 101 deletions
diff --git a/tests/drivers/actor_image_test.py b/tests/drivers/actor_image_test.py new file mode 100644 index 0000000..9df8828 --- /dev/null +++ b/tests/drivers/actor_image_test.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# source syntax callbacks execute in the host while actor code is emitted separately. +from pathlib import Path +import argparse +import subprocess +import tempfile + +parser = argparse.ArgumentParser() +parser.add_argument('--host', default='bin/zy') +parser.add_argument('--case', default='') +args = parser.parse_args() +prefix = b''.join(Path(f).read_bytes() + b'\n' for f in ('src/runtime/rt.zc', 'src/core/core.zc', 'src/runtime/run.zc')) +cases = [('declaration', Path('tests/actor/accept/declaration.zc').read_text(), b'5 7 0 0\n'), ('generic', Path('tests/actor/accept/generic_declaration.zc').read_text(), b'5 7 4 0\n')] +for name, source, expected in cases: + if args.case and args.case not in name: + continue + with tempfile.TemporaryDirectory(prefix='tinyvm-actor-image-') as work: + directory = Path(work) + compiled = subprocess.run([args.host], input=Path('src/tools/build.zc').read_bytes() + b'`' + prefix + source.encode(), capture_output=True, timeout=30) + assert compiled.returncode == 0 and len(compiled.stdout) >= 56 and not compiled.stdout.startswith(b'zc:'), (name, compiled.stdout[:2000], compiled.stderr) + header = subprocess.run(['bin/mkgenome'], input=compiled.stdout, capture_output=True, check=True) + (directory / 'genome.h').write_bytes(header.stdout) + for f in ('src/core/memmap.h', 'src/core/fops.h', 'src/core/bops.h', 'src/core/stack.h', 'src/micro/micro.c', 'src/micro/micro_port.h', 'src/micro/port_linux.c'): + (directory / Path(f).name).write_bytes(Path(f).read_bytes()) + subprocess.run(['cc', '-O2', '-w', '-fwrapv', '-pthread', '-DZY_THREADS', str(directory / 'micro.c'), str(directory / 'port_linux.c'), '-I' + work, '-o', str(directory / 'image')], check=True, timeout=30) + result = subprocess.run([str(directory / 'image')], capture_output=True, timeout=12) + assert result.returncode == 0 and result.stdout == expected, (name, result.returncode, result.stdout, result.stderr) + print('ok actor compiler-free image: ' + name) diff --git a/tests/drivers/actor_test.py b/tests/drivers/actor_test.py new file mode 100644 index 0000000..43a110f --- /dev/null +++ b/tests/drivers/actor_test.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# exercise syntax injection, isolated workers, typed graph copying and regressions. +from pathlib import Path +import argparse +import subprocess +import time + +parser = argparse.ArgumentParser() +parser.add_argument('vm', nargs='?', default='bin/zy') +parser.add_argument('--case', default='') +parser.add_argument('--library', default='') +args = parser.parse_args() +root = Path(__file__).resolve().parents[2] +expected = {'parallel': '0 0\n11 21\n', 'state': '0 5 7\n4\n', 'graphs': '25 a 5\n', 'results': 'ok\n', 'plain': '3\n', 'module_helpers': '10\n', 'safepoint': '8\n', 'values': '1\n', 'functions': '12\n', 'pointer_scalar': '1\n', 'declaration': '5 7 0 0\n', 'lifecycle': '-1 3\n', 'generic_extensions': '3\n', 'failed_worker': '-1 -1 3\n', 'generic_declaration': '5 7 4 0\n', 'generic_typed_method': '7 2\n'} +count = 0 +for case in sorted((root / 'tests/actor').glob('*/*.zc')): + if args.case and args.case not in str(case): + continue + start = time.monotonic() + source = case.read_text() + if args.library: + source = source.replace('include rt, actor;', f'include rt; include \"{args.library}\"; include actor;') + proc = subprocess.run([str(root / args.vm)], input=source, text=True, capture_output=True, cwd=root, timeout=15) + elapsed = time.monotonic() - start + if case.parent.name == 'reject': + required = {'no_include': "actor: isn't a type", 'input_type': 'type error:', 'output_type': 'type error:', 'explicit_this': 'this is implicit', 'nested_missing_import': 'module access requires `include rt;`', 'generic_input': 'type error:', 'generic_method_return': 'type error:'}[case.stem] + assert required in proc.stdout, f'{case}: expected {required!r}, got {proc.stdout!r}' + else: + actual = proc.stdout + if case.stem == 'parallel': + lines = actual.splitlines(keepends=True) + assert len(lines) == 3, f'invalid parallel output: {actual!r}' + runtime_ms = int(lines[-1]) + assert 280 <= runtime_ms < 580, f'workers did not overlap: {runtime_ms}ms' + actual = ''.join(lines[:-1]) + assert proc.returncode == 0 and actual == expected[case.stem], f'{case}: stdout={proc.stdout!r}, stderr={proc.stderr!r}' + print(f'ok {case.relative_to(root)} ({elapsed:.3f}s)') + count += 1 +assert count, 'no actor fixtures selected' +print(f'{count} actor fixtures passed') diff --git a/tests/drivers/analysis_cache_test.js b/tests/drivers/analysis_cache_test.js new file mode 100644 index 0000000..3ffde33 --- /dev/null +++ b/tests/drivers/analysis_cache_test.js @@ -0,0 +1,45 @@ +// Repeated replies use cached results; edits, include updates and reopen invalidate them. +const assert = require('assert'), cp = require('child_process'), path = require('path'); +const uri = 'file:///cache/main.zc', text = 'include "dep.zc";\nfn main() { int n = 7; return n + value(); }'; +const msgs = []; let id = 1; +const request = (method, params = {}) => { const rid = id++; msgs.push({ id: rid, method, params }); return rid; }; +const note = (method, params) => msgs.push({ method, params }); +const stats = () => request('zc/cacheStats'); +const hover = () => request('textDocument/hover', { textDocument: { uri }, position: { line: 1, character: 30 } }); +request('initialize'); note('zc/file', { path: '/cache/dep.zc', text: 'int value() { return 1; }' }); +note('textDocument/didOpen', { textDocument: { uri, version: 1, text } }); +const a = hover(), before = stats(), b = hover(), after = stats(); +const methods = ['textDocument/semanticTokens/full', 'textDocument/definition', 'textDocument/references', 'textDocument/completion']; +const pairs = methods.map(method => [request(method, { textDocument: { uri }, position: { line: 1, character: 30 } }), request(method, { textDocument: { uri }, position: { line: 1, character: 30 } })]); +const all = stats(); +note('textDocument/didChange', { textDocument: { uri, version: 2 }, contentChanges: [{ text: text.replace('= 7', '= 9') }] }); +const changed = hover(), editStats = stats(); +note('zc/file', { path: '/cache/dep.zc', text: 'int value() { return 2; }' }); +hover(); const includeStats = stats(); +note('textDocument/didClose', { textDocument: { uri } }); +note('textDocument/didOpen', { textDocument: { uri, version: 1, text } }); +const reopened = hover(), reopenStats = stats(); note('exit', {}); +const frame = m => { const b = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...m })); return Buffer.concat([Buffer.from(`Content-Length: ${b.length}\r\n\r\n`), b]); }; +let out; +if (process.argv[2] === '--wasm' || process.argv[2] === '--vm') { + const wasm = process.argv[2] === '--wasm', host = require(path.resolve('editors/vscode/client', wasm ? 'wasmproc.js' : 'vmproc.js')); + const p = (wasm ? host.wasmProcess : host.vmProcess)(path.resolve(process.argv[3]), { log: () => {} }); + const chunks = []; p.stdout.on('data', d => chunks.push(d)); msgs.forEach(m => p.stdin.write(frame(m))); out = Buffer.concat(chunks); +} else { + const r = cp.spawnSync(process.argv[2], process.argv.slice(3), { input: Buffer.concat(msgs.map(frame)), timeout: 60000, maxBuffer: 16 << 20 }); + assert.strictEqual(r.status, 0, String(r.stderr)); out = r.stdout; +} +const replies = new Map(); +while (out.length) { const e = out.indexOf('\r\n\r\n'), n = Number(/Content-Length:\s*(\d+)/i.exec(out.subarray(0, e).toString())[1]); const m = JSON.parse(out.subarray(e + 4, e + 4 + n)); replies.set(m.id, m.result); out = out.subarray(e + 4 + n); } +assert.deepStrictEqual(replies.get(a), replies.get(b)); +assert.ok(replies.get(a)?.contents.value.includes('7'), 'first hover has value'); +assert.strictEqual(replies.get(after).hits, replies.get(before).hits + 1); +assert.strictEqual(replies.get(after).checks, replies.get(before).checks); +pairs.forEach(([x, y]) => assert.deepStrictEqual(replies.get(x), replies.get(y))); +assert.strictEqual(replies.get(all).hits, replies.get(after).hits + methods.length); +assert.ok(replies.get(changed)?.contents.value.includes('9'), 'edit invalidates value'); +assert.strictEqual(replies.get(editStats).misses, replies.get(all).misses + 1); +assert.strictEqual(replies.get(includeStats).misses, replies.get(editStats).misses + 1); +assert.ok(replies.get(reopened)?.contents.value.includes('7'), 'reopen invalidates value'); +assert.strictEqual(replies.get(reopenStats).misses, replies.get(includeStats).misses + 1); +console.log('ok analysis cache reuses all request types and invalidates on edits, includes and reopen'); diff --git a/tests/drivers/analyzer_property_test.py b/tests/drivers/analyzer_property_test.py new file mode 100644 index 0000000..d816f47 --- /dev/null +++ b/tests/drivers/analyzer_property_test.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""deterministic safe/invalid neighbors for ownership and projection analysis.""" +import argparse +import random +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument('host') +parser.add_argument('--cases', type=int, default=32) +args = parser.parse_args() +rng = random.Random(0xB0770) +for i in range(args.cases): + n = rng.randrange(1, 12); at = rng.randrange(n) + prefix = f'include rt; int* saved; fn main() {{ let p = rt.new(int, {n}); p[{at}] = {i}; ' + variants = [ + (f'return p[{at}]; }}', True), + (f'saved = p; return saved[{at}]; }}', True), + (f'saved = p; return p[{at}]; }}', False), + (f'rt.free(p); return p[{at}]; }}', False), + (f'int* q = p; rt.free(p); return q[{at}]; }}', False), + ] + for tail, accepted in variants: + src = prefix + tail + r = subprocess.run([args.host], input=src.encode(), capture_output=True, timeout=5) + text = (r.stdout + r.stderr).decode(errors='replace') + error = 'zc:' in text or r.returncode != 0 + assert r.returncode >= 0 and (not error) == accepted, (i, accepted, src, text) +print(f'ok {args.cases * 5} generated ownership accept/reject neighbors (seed 0xB0770)') diff --git a/tests/drivers/array_methods_test.py b/tests/drivers/array_methods_test.py new file mode 100644 index 0000000..82fef67 --- /dev/null +++ b/tests/drivers/array_methods_test.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""array receiver calls: execution, type checks and mutation contracts.""" +import argparse +import re +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument('host', nargs='?', default='bin/zy') +parser.add_argument('--case', default='') +args = parser.parse_args() + +cases = [ + ('growable', True, 'include rt; fn main() { int[?] a = {2,3}; a.push(7); rt.put_n(a.len()); rt.put_c(32); rt.put_n(a.cap() >= a.len()); rt.put_c(32); rt.put_n(a.pop()); a.resize(4); rt.put_c(32); rt.put_n(a[3]); rt.put_c(32); rt.put_n(a.len()); }', '3 1 7 0 4'), + ('fixed length', True, 'include rt; fn main() { int[3] a = {2,3,4}; rt.put_n(a.len()); rt.put_c(32); rt.put_n(a.cap()); fixed int[?] b = rt.new(fixed int[?], 5); rt.put_c(32); rt.put_n(b.len()); }', '3 3 5'), + ('packed', True, 'include rt; fn main() { u8[?] a = {1}; a.push(300); rt.put_n(a.pop()); rt.put_c(32); i16[?] b = {-5}; rt.put_n(b.pop()); rt.put_c(32); f32[?] c = {2.5}; c.push(1.5); rt.put_n(c.pop() == 1.5); }', '44 -5 1'), + ('optimized', True, 'include rt; o3 int change(int[?] a) { a.push(9); int n = a.pop(); a.resize(3); return n + a.len(); } fn main() { int[?] a = {1}; rt.put_n(change(a)); rt.put_c(32); rt.put_n(a.len()); }', '12 3'), + ('field receiver', True, 'include rt; struct box { int[?] items; }; fn main() { box b; b.items = rt.new(int[?]); b.items.push(8); rt.put_n(b.items.pop()); }', '8'), + ('nested arguments', True, 'include rt; fn main() { int[?] a = {1}, b = {2,3}; a.push(b.pop() + b.len()); a.resize(b.len() + 2); rt.put_n(a[1]); rt.put_c(32); rt.put_n(a.len()); }', '4 3'), + ('fixed push', False, 'fn main() { int[2] a; a.push(1); }', None), + ('fixed pop', False, 'fn main() { int[2] a; return a.pop(); }', None), + ('fixed resize', False, 'fn main() { int[2] a; a.resize(3); }', None), + ('fixed handle push', False, 'include rt; fn main() { fixed int[?] a = rt.new(fixed int[?], 2); a.push(1); }', None), + ('element mismatch', False, 'include rt; fn main() { int[?] a = {}; a.push("wrong"); }', None), + ('extra argument', False, 'include rt; fn main() { int[?] a = {}; a.len(1); }', None), + ('invalidated borrow', False, 'include rt; fn main() { int[?] a = {1}; int* p = &a[0]; a.resize(10); return *p; }', None), + ('free fixed push', False, 'fn main() { int[2] a; push(a,1); }', None), + ('free fixed pop', False, 'fn main() { int[2] a; return pop(a); }', None), + ('registered generic', True, 'include rt, run; T first<T>(fixed T[?] a) { return a[0]; } immediate fn register() { run.next(); run.add_method("first", "first"); } register fn main() { int[?] a = {9}; rt.put_n(a.first()); int[2] b = {7,8}; rt.put_c(32); rt.put_n(b.first()); }', '9 7'), + ('registered callback', True, 'include rt, run; U apply<T,U>(fixed T[?] a, U(T) f) { return f(a[0]); } immediate fn register() { run.next(); run.add_method("apply", "apply"); } register fn main() { int[?] a = {4}; rt.put_n(a.apply(fn (e) { return e + 2; })); rt.put_c(32); float result = a.apply(fn:float(int e) { return e * 0.5; }); rt.put_n(result == 2.0); }', '6 1'), + ('registered callback mismatch', False, 'include rt, run; U apply<T,U>(fixed T[?] a, U(T) f) { return f(a[0]); } immediate fn register() { run.next(); run.add_method("apply", "apply"); } register fn main() { int[?] a = {4}; return a.apply(fn:int(char* e) { return e[0]; }); }', None), + ('registration requires receiver', False, 'include run; int empty() { return 0; } immediate fn register() { run.next(); run.add_method("empty", "empty"); } register fn main() { return 0; }', None), + ('declarative registration', True, 'include rt, run; T first<T>(fixed T[?] a) { return a[0]; } run.method("first", first); fn main() { int[3] a = {6,7,8}; rt.put_n(a.first()); }', '6'), + ('map inferred', True, 'include rt, std; fn main() { int[?] a = {1,2,3}; int[?] b = a.map(fn(e) { return e + 2; }); rt.put_n(b[0]); rt.put_c(32); rt.put_n(b[2]); rt.put_c(32); float[?] c = a.map(fn(e) { return e * 0.5; }); rt.put_n(c[2] == 1.5); }', '3 5 1'), + ('filter fixed', True, 'include rt, std; fn main() { int[5] a = {1,2,3,4,5}; let b = a.filter(fn(e) { return e % 2; }); rt.put_n(b.len()); rt.put_c(32); rt.put_n(b[2]); }', '3 5'), + ('search methods', True, 'include rt, std; fn main() { int[?] a = {2,4,6}; rt.put_n(a.find(4)); rt.put_c(32); rt.put_n(a.find(5)); rt.put_c(32); rt.put_n(a.contains(6)); rt.put_c(32); rt.put_n(a.find_if(fn(e) { return e > 4; })); }', '1 -1 1 2'), + ('reduce and predicates', True, 'include rt, std; fn main() { int[3] a = {2,4,6}; rt.put_n(a.reduce(1, fn(acc,e) { return acc + e; })); rt.put_c(32); rt.put_n(a.all(fn(e) { return e % 2 == 0; })); rt.put_c(32); rt.put_n(a.any(fn(e) { return e > 5; })); }', '13 1 1'), + ('empty algorithms', True, 'include rt, std; fn main() { int[?] a = {}; int calls = 0; let b = a.map(fn(e) { calls++; return e; }); let c = a.filter(fn(e) { calls++; return e; }); rt.put_n(b.len() + c.len() + calls); rt.put_c(32); rt.put_n(a.all(fn(e) { return e; })); rt.put_c(32); rt.put_n(a.any(fn(e) { return e; })); rt.put_c(32); rt.put_n(a.reduce(9, fn(acc,e) { return acc + e; })); }', '0 1 0 9'), + ('shared filter references', True, 'include rt, std; fn main() { char*[?] a = rt.new(char*[?]); char* s = rt.new(char, 2); s[0] = 65; a.push(s); let b = a.filter(fn(e) { return e[0] == 65; }); rt.free(a); rt.gc(); rt.put_c(b[0][0]); }', 'A'), + ('map needs import', False, 'include rt; fn main() { int[?] a = {1}; let b = a.map(fn(e) { return e; }); }', None), + ('pop transfers ownership', True, 'include rt; fn main() { int before = rt.live_objects(); { int*[?] a = rt.new(int*[?]); int* p = rt.new(int); *p = 7; a.push(p); let saved = a.pop(); rt.free(a); rt.put_n(rt.isobj(saved)); rt.put_c(32); rt.put_n(*saved); } rt.put_c(32); rt.put_n(rt.live_objects() - before); }', '1 7 0'), + ('returned pop survives array', True, 'include rt; int*.u take() { int*[?] a = rt.new(int*[?]); int* p = rt.new(int); *p = 8; a.push(p); return a.pop(); } fn main() { let p = take(); rt.put_n(rt.isobj(p)); rt.put_c(32); rt.put_n(*p); }', '1 8'), + ('single field struct push', False, 'include rt; struct item { int value; }; fn main() { item[?] a = rt.new(item[?]); item x; x.value = 7; a.push(x); }', None), + ('single field struct pop', False, 'include rt; struct item { int value; }; fn main() { item[?] a = rt.new(item[?], 1); return a.pop().value; }', None), + ('resize drops children', True, 'include rt; fn main() { int before = rt.live_objects(); int*[?] a = rt.new(int*[?]); a.push(rt.new(int)); a.resize(0); rt.put_n(rt.live_objects() - before); }', '2'), + ('callback borrows references', False, 'include rt, std; fn main() { int*[?] a = rt.new(int*[?]); a.push(rt.new(int)); let b = a.filter(fn(e) { rt.free(e); return 1; }); }', None), + ('packed map and filter', True, 'include rt, std; fn main() { i16[?] a = {-5,7}; i16[?] b = a.filter(fn(e) { return e < 0; }); rt.put_n(b[0]); rt.put_c(32); f32[2] c = {1.5,2.5}; f32[?] d = c.map(fn(e) { return e; }); rt.put_n(d[1] == 2.5); }', '-5 1'), + ('numeric coercion', True, 'include rt; fn main() { float[?] a = rt.new(float[?]); a.push(2); rt.put_n(a[0] == 2.0); rt.put_c(32); int[?] b = {}; b.push(2.5); b.resize(3.0); rt.put_n(b[0]); rt.put_c(32); rt.put_n(b.len()); }', '1 2 3'), + ('invalid resize length', False, 'include rt; fn main() { int[?] a = {}; a.resize("bad"); }', None), + ('free pop ownership', True, 'include rt; fn main() { int*[?] a = rt.new(int*[?]); int* p = rt.new(int); *p = 9; push(a,p); let saved = pop(a); rt.free(a); rt.put_n(rt.isobj(saved)); rt.put_c(32); rt.put_n(*saved); }', '1 9'), + ('packed string values', True, 'include rt; struct box { bstr text; }; fn main() { bstr[?] a = {b"hi"}; let s = a.pop(); rt.put_n(s[1]); rt.put_c(32); box b; b.text = b"ok"; rt.put_n(b.text[1]); }', '105 107'), + ('field array borrow', False, 'include rt; struct box { int[?] items; }; fn main() { box* b = rt.new(box); b.items = rt.new(int[?], 1); let borrowed = b.items; rt.free(b); return borrowed[0]; }', None), +] + +count = 0 +for name, accept, source, expected in cases: + if args.case and args.case not in name: + continue + source = 'typecheck on;\n' + source + check = subprocess.run([args.host], input=source, text=True, capture_output=True, timeout=10) + diagnostics = check.stdout + check.stderr + failed = check.returncode != 0 or bool(re.search(r'(zc:|zc0:|error:)', diagnostics)) + assert failed != accept, f'{name}: expected {"accept" if accept else "reject"}\n{diagnostics}' + if accept: + assert check.returncode == 0 and check.stdout == expected, f'{name}: {check.stdout!r}, {check.stderr!r}; expected {expected!r}' + print('ok array methods: ' + name) + count += 1 +assert count, 'no cases selected' diff --git a/tests/drivers/cli_help_test.py b/tests/drivers/cli_help_test.py new file mode 100644 index 0000000..1b32fa4 --- /dev/null +++ b/tests/drivers/cli_help_test.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""bounded command-line help and option rejection contracts.""" +import argparse +import os +import subprocess +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--group', choices=['binaries', 'scripts', 'js', 'all'], default='all') +parser.add_argument('--bin-dir', default='bin') +args = parser.parse_args() +commands = [] +if args.group in ('binaries', 'all'): + commands += [[str(Path(args.bin_dir) / name)] for name in ['zy', 'zym', 'zclsp', 'zc0', 'mkgenome', 'genmap']] +if args.group in ('scripts', 'all'): + commands += [['bash', name] for name in ['tinyvm.sh', 'src/tools/zcc', 'src/tools/automap.sh', 'src/tools/embed.sh', 'src/tools/mklsp.sh', 'src/tools/mklspwasm.sh', 'src/tools/mkvsix.sh', 'src/web/build.sh', 'src/baremetal/run.sh']] +if args.group in ('js', 'all'): + commands += [['node', name] for name in ['src/js/tinyvm.js', 'src/js/extract_genome.js', 'src/js/extract_lsp.js']] + +def invoke(command, option): + # keep stdin open: help and rejected options must finish without reading it. + env = dict(os.environ, TINYVM_MEMORY_WORDS='invalid', TINYVM_MAX_MEMORY_WORDS='invalid') + child = subprocess.Popen([*command, option], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + try: + child.wait(timeout=3) + return child.returncode, child.stdout.read(), child.stderr.read() + finally: + if child.poll() is None: + child.kill() + child.wait() + child.stdin.close() + child.stdout.close() + child.stderr.close() + +failures = 0 +for command in commands: + try: + for option in ['--help', '-h']: + status, out, err = invoke(command, option) + assert status == 0 and b'usage:' in out.lower() and not err, (option, status, out[:200], err[:200]) + status, out, err = invoke(command, '--unknown-option') + assert status > 0 and err and not out, (status, out[:200], err[:200]) + print('ok help and option rejection: ' + ' '.join(command)) + except (AssertionError, subprocess.TimeoutExpired) as error: + failures += 1 + print('FAIL ' + ' '.join(command) + ': ' + str(error)) +if args.group in ('binaries', 'all'): + source = b'int identity(int n) { return n; }\n' + for option in ['--format', '--annotate', '--types']: + result = subprocess.run([str(Path(args.bin_dir) / 'zclsp'), option], input=source, capture_output=True, timeout=10) + assert result.returncode == 0 and not result.stderr, (option, result.stdout, result.stderr) + if option != '--types': + assert b'identity' in result.stdout, (option, result.stdout) + print('ok lsp source options remain available alongside help') +raise SystemExit(bool(failures)) diff --git a/tests/drivers/codeactions_client_test.js b/tests/drivers/codeactions_client_test.js new file mode 100644 index 0000000..f1936e7 --- /dev/null +++ b/tests/drivers/codeactions_client_test.js @@ -0,0 +1,24 @@ +const assert = require('assert'); +const { convertCodeActions } = require('../../editors/vscode/client/codeactions'); +class Range { constructor(...coordinates) { this.coordinates = coordinates; } } +class WorkspaceEdit { + constructor() { this.edits = []; } + replace(uri, range, text) { this.edits.push({ uri, range, text }); } +} +class CodeAction { constructor(title, kind) { this.title = title; this.kind = kind; } } +const vscode = { Range, WorkspaceEdit, CodeAction, CodeActionKind: { QuickFix: 'quickfix' }, Uri: { parse: s => s } }; +const uri = 'file:///test.zc', doc = { uri: { toString: () => uri }, version: 3 }; +const edit = { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, newText: 'include rt;\n' }; +const source = { title: 'include rt;', kind: 'quickfix', isPreferred: true, edit: { documentChanges: [{ textDocument: { uri, version: 3 }, edits: [edit] }] } }; +const actions = convertCodeActions(vscode, [source], doc); +assert.strictEqual(actions.length, 1); +assert.strictEqual(actions[0].kind, 'quickfix'); +assert.strictEqual(actions[0].isPreferred, true); +assert.deepStrictEqual(actions[0].edit.edits[0], { uri, range: new Range(0, 0, 0, 0), text: 'include rt;\n' }); +assert.deepStrictEqual(convertCodeActions(vscode, [source], { ...doc, version: 4 }), [], 'stale document versions cannot become editor edits'); +assert.strictEqual(convertCodeActions(vscode, [source], { ...doc, version: 1 }, 3).length, 1, 'protocol version epochs are distinct from editor versions'); +assert.strictEqual(convertCodeActions(vscode, [{ ...source, edit: { changes: { [uri]: [edit] } } }], doc).length, 1); +assert.deepStrictEqual(convertCodeActions(vscode, null, doc), []); +assert.deepStrictEqual(convertCodeActions(vscode, [{ title: 'no edit' }, { ...source, edit: { changes: {} } }], doc), []); +assert.deepStrictEqual(source.edit.documentChanges[0].edits, [edit], 'conversion keeps protocol objects intact'); +console.log('ok quick-fix client conversion, stale-version rejection and empty responses'); diff --git a/tests/drivers/compiler_contract_test.py b/tests/drivers/compiler_contract_test.py new file mode 100644 index 0000000..63e476c --- /dev/null +++ b/tests/drivers/compiler_contract_test.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""accept/reject contracts, selectable in small groups before implementation.""" +import argparse +import json +import re +import subprocess +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('host') +parser.add_argument('--group') +parser.add_argument('--case') +args = parser.parse_args() +cases = json.loads(Path('tests/spec/compiler_contracts.json').read_text()) +cases = [c for c in cases if (not args.group or c['group'] == args.group) and (not args.case or args.case in c['name'])] +assert cases, 'no selected contracts' +failures = [] +for c in cases: + source = ('typecheck on;\n' + c['source']).encode() + r = subprocess.run([args.host], input=source, capture_output=True, timeout=10) + text = (r.stdout + r.stderr).decode(errors='replace') + error = bool(re.search(r'(zc:|zc0:|error:|undefined|not defined)', text)) or r.returncode != 0 + valid = (not error) == c['accept'] and r.returncode >= 0 + if valid and 'output' in c: + valid = r.returncode == 0 and r.stdout.decode() == c['output'] + print(('ok ' if valid else 'FAIL ') + c['group'] + ': ' + c['name']) + if not valid: + failures.append(c['name']) + print(text.strip()[:1000]) +raise SystemExit(1 if failures else 0) diff --git a/tests/drivers/compiler_growth_test.py b/tests/drivers/compiler_growth_test.py index 982611c..e56bf43 100644 --- a/tests/drivers/compiler_growth_test.py +++ b/tests/drivers/compiler_growth_test.py @@ -18,65 +18,68 @@ def run(source, expected): assert result.stdout == expected, result.stdout[:1000] -locals_source = 'fn main() {\n' +locals_source = 'include rt; 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' +locals_source += 'rt.put_n(local_899); rt.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' +types_source = ''.join(f'type array_{i} = int[{i + 1}];\n' for i in range(1500)) +types_source = 'include rt, run;\n' + types_source +types_source += 'fn main() { rt.put_n(run.ntl > 1500); rt.put_c(10); rt.gc(); rt.put_n(run.tsz(run.tarr(run.tint, 1500))); rt.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 = 'include rt, run;\n' + ''.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); + word saved = run.intern("a name that must survive growth"); + run.reserve_names(run.hashcap * 4); run.reserve_symbols(run.symcap * 2); run.reserve_locals(run.loccap * 2); + run.reserve_fixes(run.fixcap * 2); run.reserve_R_SRC(4096); run.reserve_R_CTX(4096); + run.reserve_R_LOOP(4096); run.reserve_R_SNAP(32768); run.reserve_R_QBUF(32768); run.reserve_R_EXIT(8192); + rt.gc(); + rt.put_n(saved == run.intern("a name that must survive growth")); rt.put_c(10); + rt.put_n(repeated_long_identifier_for_pool_and_hash_growth_{options.names - 1}); rt.put_c(10); + rt.put_n(run.poolblocks != 0 && run.symcap > run.NS && run.hashcap > run.IHN); rt.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') +run(f'include rt; const int {long_name} = 7; fn main() {{ rt.put_n({long_name}); rt.put_c(10); return 0; }}\n', '7\n') -mark_source = '''let roots[60000]; +mark_source = '''include rt; 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; + int i = 0, before = rt.live_objects(); + while (i < 60000) { roots[i] = rt.new(1); i++; } + rt.gc(); rt.put_n(rt.markcap > rt.MARKN); rt.put_c(32); rt.put_n(rt.live_objects() - before == 60000); rt.put_c(32); + // explicitly release survivors: conservative instruction roots may resemble heap addresses. + i = 0; while (i < 60000) { rt.free(roots[i]); i++; } + rt.fill_w(roots, 0, 60000); rt.scrub(); rt.gc(); + rt.put_n(rt.live_objects() == before); rt.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 = 'include rt;\n' + generics_source 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' +generics_source += ''.join(f'total += size_of<int[{i + 1}]>();\n' for i in range(150)) +generics_source += 'rt.gc(); rt.put_n(total); rt.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 = 'include rt; 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' +captures_source += 'rt.gc(); rt.put_n(f()); rt.put_c(10); return 0; }\n' run(captures_source, '2415\n') -nested_source = 'fn main() { int captured = 7;\n' +nested_source = 'include rt; 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' +nested_source += '}; rt.gc(); rt.put_n(nested_0()); rt.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' +assembly_source = 'include rt; fn main() { int value = asm { ' + 'future _ ' * 300 +assembly_source += ''.join(f':label_{i} ' for i in range(200)) + ':future 7 }; rt.gc(); rt.put_n(value); rt.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/completion_test.js b/tests/drivers/completion_test.js new file mode 100644 index 0000000..22c5fa5 --- /dev/null +++ b/tests/drivers/completion_test.js @@ -0,0 +1,105 @@ +// compiler-backed completion, including incomplete source and position-aware lifetimes. +// usage: node tests/drivers/completion_test.js path/to/zclsp [server arguments] +const assert = require('assert'); +const cp = require('child_process'); +const path = require('path'); +const uri = 'file://' + path.resolve('tests/drivers/completion-fixture.zc'); +const frame = m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from(`Content-Length: ${b.length}\r\n\r\n`), b]); }; +function complete(source, extra = []) { + const at = source.indexOf('|'); assert.ok(at >= 0); + const before = source.slice(0, at), text = source.replace('|', ''); + const position = { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 }; + const msgs = [ + { id: 1, method: 'initialize', params: {} }, + { method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, text } } }, + { id: 2, method: 'textDocument/completion', params: { textDocument: { uri }, position } }, + ...extra.map(m => ({ ...m, params: { textDocument: { uri }, position, ...m.params } })), + { method: 'exit' } + ].map(m => ({ jsonrpc: '2.0', ...m })); + let output; + if (process.argv[2] === '--wasm' || process.argv[2] === '--vm') { + const wasm = process.argv[2] === '--wasm'; + const host = require(path.resolve('editors/vscode/client', wasm ? 'wasmproc.js' : 'vmproc.js')); + const proc = (wasm ? host.wasmProcess : host.vmProcess)(path.resolve(process.argv[3]), { log: () => {} }); + const chunks = []; proc.stdout.on('data', b => chunks.push(b)); + for (const m of msgs) proc.stdin.write(frame(m)); + output = Buffer.concat(chunks); + } else { + const r = cp.spawnSync(process.argv[2], process.argv.slice(3), { input: Buffer.concat(msgs.map(frame)), timeout: 30000, maxBuffer: 32 << 20 }); + assert.strictEqual(r.status, 0, String(r.stderr)); output = r.stdout; + } + let b = output; const replies = []; + while (b.length) { + const e = b.indexOf('\r\n\r\n'); assert.ok(e >= 0, b.toString()); + const n = Number(/Content-Length:\s*(\d+)/i.exec(b.subarray(0, e).toString())[1]); + replies.push(JSON.parse(b.subarray(e + 4, e + 4 + n))); b = b.subarray(e + 4 + n); + } + assert.ok(replies.find(m => m.id === 1).result.capabilities.completionProvider); + const reply = replies.find(m => m.id === 2); assert.ok(reply && reply.result, JSON.stringify(reply)); + return { items: reply.result.items, replies }; +} +const get = (items, label) => { const item = items.find(c => c.label === label); assert.ok(item, `${label} missing: ${items.map(c => c.label)}`); return item; }; +let r = complete('/* computes a total */\nint total(int count) { return count + 1; }\nfn main() {\n int tally = 3;\n ta|\n}\n'); +assert.match(get(r.items, 'tally').detail, /int tally/); +assert.ok(!r.items.some(c => c.label === 'count'), 'other function parameters are out of scope'); +r = complete('/* computes a total */\nint total(int count) { return count + 1; }\nfn main() { to| }'); +assert.match(get(r.items, 'total').detail, /int total\(int count\)/); +assert.match(get(r.items, 'total').documentation.value, /computes a total/); +r = complete('fn main() {\n int count = 1;\n { int hidden = 3; }\n |\n}\n'); +get(r.items, 'count'); assert.ok(!r.items.some(c => c.label === 'hidden')); +r = complete('include rt; fn main(int input) {\n let p = rt.new(int, 3);\n rt.free(p);\n |\n}\n'); +const p = get(r.items, 'p'); assert.deepStrictEqual(p.tags, [1]); +assert.match(p.documentation.value, /lifetime ended/); +get(r.items, 'input'); +assert.ok(r.items.findIndex(c => c.label === 'p') > r.items.findIndex(c => c.label === 'input')); +assert.ok(r.items.slice(r.items.findIndex(c => c.tags)).every(c => c.tags), 'all consumed variables sort last'); +r = complete('include rt; fn main() {\n let p = rt.new(int, 3);\n p|;\n rt.free(p);\n return 0;\n}'); +assert.ok(!get(r.items, 'p').tags, 'later consumption does not expire earlier completion'); +r = complete('fn main() {\n int old = 1;\n int bold = 2;\n int other_local_data = 3;\n old|\n}'); +assert.strictEqual(r.items[0].label, 'old', 'exact match comes first'); get(r.items, 'other_local_data'); +r = complete('include std;\nfn main() { std.Text.at| }'); +assert.match(get(r.items, 'atoi').detail, /atoi\(/); +assert.ok(r.items.every(c => !c.label.includes('.')), 'member labels omit receiver'); +r = complete('struct point { int x; int y; };\nfn main() { point p; p.| }'); +get(r.items, 'x'); get(r.items, 'y'); assert.ok(!r.items.some(c => c.label === 'main')); +r = complete('fn main() { Te| }'); assert.ok(!r.items.some(c => c.label === 'Text'), 'std is not imported automatically'); +r = complete('int shared;\nfn main() { char shared = 1; sh| }'); +assert.match(get(r.items, 'shared').detail, /char shared/); +assert.strictEqual(r.items.filter(c => c.label === 'shared').length, 1); +r = complete('fn main() { let hello = "hi"; hel| }'); +assert.match(get(r.items, 'hello').detail, /char\* hello/); +r = complete('fn main() { let bytes = b"hi"; byt| }'); +assert.match(get(r.items, 'bytes').detail, /bstr bytes/); +r = complete('fn main() { int count = 1; cou|nt; return count; }', [{ id: 3, method: 'textDocument/hover' }]); +assert.ok(r.replies.find(m => m.id === 3).result, 'hover still works after a partial completion check'); +assert.strictEqual(get(r.items, 'count').textEdit.newText, 'count'); +assert.strictEqual(get(r.items, 'count').textEdit.range.end.character - get(r.items, 'count').textEdit.range.start.character, 5); +r = complete('include rt; fn main() {\n let p = rt.new(int, 3);\n let alias = p;\n rt.free(p);\n |\n}'); +assert.deepStrictEqual(get(r.items, 'alias').tags, [1], 'freed aliases are also unavailable'); +r = complete('include rt; fn take(.d p) { rt.free(p); }\nfn main() {\n let p = rt.new(int, 3);\n take(d*p);\n |\n}'); +assert.deepStrictEqual(get(r.items, 'p').tags, [1], 'ownership transfers are unavailable'); +r = complete('fn main() {\n int outer = 1;\n let f = fn(int inner) {\n ou|\n };\n}'); +get(r.items, 'outer'); +r = complete('fn first() { int wrong = 1; return wrong; }\n|\nfn second() { int other = 2; return other; }'); +assert.ok(!r.items.some(c => c.label === 'wrong' || c.label === 'other'), 'top-level completion excludes function locals'); +r = complete('fn main() {\n /* the latest count */\n int latest = 1;\n la|'); +assert.match(get(r.items, 'latest').documentation.value, /the latest count/); +r = complete('include rt; fn main() { int[?] items = {}; items.| }'); +for (const name of ['len', 'cap', 'push', 'pop', 'resize']) get(r.items, name); +assert.match(get(r.items, 'push').detail, /push\(int value\)/); +assert.match(get(r.items, 'pop').detail, /int pop\(\)/); +assert.match(get(r.items, 'resize').documentation.value, /length/); +assert.ok(!r.items.some(c => c.label === 'main'), 'array member completion excludes globals'); +r = complete('fn main() { int[3] items = {1,2,3}; items.| }'); +get(r.items, 'len'); get(r.items, 'cap'); +assert.ok(!r.items.some(c => ['push', 'pop', 'resize'].includes(c.label)), 'fixed arrays omit mutation methods'); +r = complete('include rt; fn main() { fixed int[?] items = rt.new(fixed int[?], 3); items.| }'); +get(r.items, 'len'); get(r.items, 'cap'); +assert.ok(!r.items.some(c => ['push', 'pop', 'resize'].includes(c.label)), 'fixed handles omit mutation methods'); +r = complete('include std; fn main() { int[3] items = {1,2,3}; items.| }'); +for (const name of ['find', 'contains', 'find_if', 'any', 'all', 'count', 'map', 'filter', 'reduce']) get(r.items, name); +assert.match(get(r.items, 'map').documentation.value, /transform.*each element/); +assert.ok(!r.items.some(c => ['push', 'pop', 'resize'].includes(c.label)), 'library algorithms do not make a fixed array growable'); +r = complete('include rt; fn main() { int[?] items = {}; items.| }'); +assert.ok(!r.items.some(c => ['map', 'filter'].includes(c.label)), 'library methods require an explicit import'); +console.log('ok completion: signatures, docs, fuzzy ranking, scope, array members, shadowing and consumed lifetimes'); diff --git a/tests/drivers/core_lsp_test.js b/tests/drivers/core_lsp_test.js index 41d0095..983f9aa 100644 --- a/tests/drivers/core_lsp_test.js +++ b/tests/drivers/core_lsp_test.js @@ -4,7 +4,7 @@ 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 files = ['src/core/core.zc', 'src/runtime/rt.zc', 'src/compiler/compiler.zc', 'src/runtime/run.zc', 'src/lib/std.zc', 'src/lib/icon.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]); @@ -18,6 +18,7 @@ for (const file of files) { { 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', id: 4, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri } } }, { jsonrpc: '2.0', method: 'textDocument/didClose', params: { textDocument: { uri } } }, { jsonrpc: '2.0', id: 3, method: 'shutdown', params: null }, { jsonrpc: '2.0', method: 'exit' }, @@ -34,10 +35,11 @@ for (const file of files) { 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'); + assert.ok(diagnostics.length >= 3, file + ': open and change are checked before close, with streamed publications allowed'); 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'); + assert.ok(replies.find(m => m.id === 4).result.data.length, file + ': the changed source also has semantic tokens'); + assert.deepStrictEqual(diagnostics.at(-1).params.diagnostics, [], 'close clears diagnostics'); console.log('ok editor open, highlight, change, and close: ' + file); } diff --git a/tests/drivers/ext_cache_test.js b/tests/drivers/ext_cache_test.js index 11bf50b..f389fcc 100644 --- a/tests/drivers/ext_cache_test.js +++ b/tests/drivers/ext_cache_test.js @@ -29,8 +29,15 @@ const stub = { appendMarkdown(v) { this.value += v; return this; } appendCodeblock(v, language) { this.value += '\n```' + language + '\n' + v + '\n```\n'; return this; } }, + CompletionItem: class { constructor(label, kind) { Object.assign(this, { label, kind }); } }, + CompletionList: class { constructor(items, isIncomplete) { Object.assign(this, { items, isIncomplete }); } }, + CompletionItemKind: { Variable: 5 }, + CompletionItemTag: { Deprecated: 1 }, + CodeActionKind: { QuickFix: 'quickfix' }, + CodeAction: class { constructor(title, kind) { Object.assign(this, { title, kind }); } }, + WorkspaceEdit: class { constructor() { this.edits = []; } replace(uri, range, newText) { this.edits.push({ uri, range, newText }); } }, Hover: class { constructor(c) { this.contents = c; } }, - Uri: { parse: s => ({ toString: () => s, fsPath: decodeURIComponent(s.replace(/^file:\/\//, '')) }) }, + Uri: { file: p => ({ toString: () => 'file://' + p, fsPath: p }), parse: s => ({ toString: () => s, fsPath: decodeURIComponent(s.replace(/^file:\/\//, '')) }) }, workspace: { workspaceFolders: undefined, textDocuments: world.textDocuments, @@ -41,6 +48,7 @@ const stub = { for (const k of ['onDidChange', 'onDidCreate', 'onDidDelete']) w[k] = f => { w.handlers[k] = f; return { dispose() {} }; }; return w; }, + registerTextDocumentContentProvider: () => ({ dispose() {} }), ...['onDidChangeConfiguration', 'onDidOpenTextDocument', 'onDidChangeTextDocument', 'onDidSaveTextDocument', 'onDidCloseTextDocument'] .reduce((o, k) => { o[k] = f => { world.listeners[k] = f; return { dispose() {} }; }; return o; }, {}), }, @@ -54,7 +62,12 @@ const stub = { delete() {}, dispose() {}, }), registerDocumentSemanticTokensProvider: () => ({ dispose() {} }), + registerCompletionItemProvider: (sel, p) => { world.completion = p; return { dispose() {} }; }, + registerCodeActionsProvider: (sel, p) => { world.actions = p; return { dispose() {} }; }, registerHoverProvider: (sel, p) => { world.hover = p; return { dispose() {} }; }, + registerDocumentFormattingEditProvider: () => ({ dispose() {} }), + registerDefinitionProvider: (sel, p) => { world.definition = p; return { dispose() {} }; }, + registerReferenceProvider: (sel, p) => { world.references = p; return { dispose() {} }; }, }, commands: { registerCommand: () => ({ dispose() {} }) }, }; @@ -116,6 +129,9 @@ const mainUri = 'file://' + main; 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)); + const suggestions = await world.completion.provideCompletionItems(mainDoc, { line: 1, character: 4 }, { isCancellationRequested: false }); + check('completion provider returns signatures and replacement ranges', suggestions instanceof stub.CompletionList && suggestions.items.some(c => c.detail && c.range && c.insertText), JSON.stringify(suggestions && suggestions.items.slice(0, 2))); + check('cancelled completion requests are ignored', await world.completion.provideCompletionItems(mainDoc, { line: 1, character: 4 }, { isCancellationRequested: true }) === null); 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); @@ -146,6 +162,15 @@ const mainUri = 'file://' + main; 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))); + const expiredDoc = doc(path.join(root, 'expired.zc'), 'include rt; fn main() {\n let used = rt.new(int, 3);\n rt.free(used);\n \n}'); + const expiredList = await world.completion.provideCompletionItems(expiredDoc, { line: 3, character: 1 }, { isCancellationRequested: false }); + const expiredItem = expiredList && expiredList.items.find(c => c.label === 'used'); + check('completion maps consumed lifetimes to the editor strikethrough tag', expiredItem && expiredItem.tags?.[0] === stub.CompletionItemTag.Deprecated && /lifetime ended/.test(expiredItem.documentation?.value)); + const actionDoc = doc(path.join(root, 'imports.zc'), 'fn main() { rt.put_n(7); }'); + const actions = await world.actions.provideCodeActions(actionDoc, new stub.Range(0, 15, 0, 15), {}, { isCancellationRequested: false }); + check('quick fixes reach the editor as workspace edits', actions.some(a => a.kind === 'quickfix' && a.edit.edits.some(e => e.newText === 'include rt;\n'))); + const cancelled = await world.actions.provideCodeActions(actionDoc, new stub.Range(0, 15, 0, 15), {}, { isCancellationRequested: true }); + check('cancelled quick-fix requests produce no editor edits', cancelled.length === 0); ext.deactivate(); await sleep(700); fs.rmSync(root, { recursive: true, force: true }); diff --git a/tests/drivers/float_backends_test.py b/tests/drivers/float_backends_test.py index 2cb9473..cdcf48b 100644 --- a/tests/drivers/float_backends_test.py +++ b/tests/drivers/float_backends_test.py @@ -5,19 +5,19 @@ 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; } +sources = [(root / 'tests/programs/float.zc').read_text(), '''include rt; fn render(bits, precision, mode) { asm { bits @ precision @ mode @ #1236 } return 0; } fn convert(bits) { return asm { bits @ #1218 }; } fn main() { - [4]f32 values; + f32[4] 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); + rt.put_n(values[0] == 1.5); rt.put_c(32); rt.put_n(values[1] == -2.25); rt.put_c(32); + rt.put_n(values[2] > 0.0); rt.put_c(32); rt.put_n(values[3] == 1.0 / 0.0); rt.put_c(10); + render(0.125, 2, 1); rt.put_c(32); render(-0.125, 2, 1); rt.put_c(32); + render(1e30, 6, 1); rt.put_c(32); render(1.7976931348623157e308, 6, 1); rt.put_c(10); + render(0.1, 0, 0); rt.put_c(32); render(1e-5, 0, 0); rt.put_c(32); render(1e16, 0, 0); rt.put_c(10); + render(9.999, 3, 2); rt.put_c(32); render(5e-324, 3, 2); rt.put_c(32); render(0, 3, 2); rt.put_c(10); + let negative_zero = 1 << 63; render(negative_zero, 6, 1); rt.put_c(32); render(negative_zero, 0, 0); rt.put_c(10); + rt.put_n(convert(0.0 / 0.0)); rt.put_c(32); rt.put_n(convert(1.0 / 0.0)); rt.put_c(32); rt.put_n(convert(-1.0 / 0.0)); rt.put_c(10); return 0; } '''] @@ -40,7 +40,7 @@ for i, source in enumerate(sources): # 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' +source = f'include rt; fn main() {{ rt.put_n(({literal} as word) == 0x4024000000000000); rt.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) diff --git a/tests/drivers/format_test.js b/tests/drivers/format_test.js new file mode 100644 index 0000000..94890ae --- /dev/null +++ b/tests/drivers/format_test.js @@ -0,0 +1,88 @@ +// the pretty-printer: it never changes a program's tokens, it is stable (formatting twice is the same as once), +// it follows .zcformat and the editor's tab settings, and it leaves asm blocks, strings and comments alone. +// usage: node tests/drivers/format_test.js path/to/zclsp (run from the repository root) +const assert = require('assert'); +const cp = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const server = process.argv[2]; +const root = path.resolve(__dirname, '..', '..'); +const format = (text, args = []) => cp.spawnSync(server, ['--format', ...args], { input: text, cwd: root, maxBuffer: 1 << 28 }).stdout.toString(); +// tokens of a source, white space dropped; the lines of a block comment are compared without their indent +function tokens(src) { + const out = []; + let i = 0; + const n = src.length; + while (i < n) { + const c = src[i]; + if (/\s/.test(c)) { i++; continue; } + if (c === '`') { out.push(src.slice(i)); break; } + if (c === '/' && src[i + 1] === '/') { let j = src.indexOf('\n', i); if (j < 0) j = n; out.push(src.slice(i, j).trimEnd()); i = j; continue; } + if (c === '/' && src[i + 1] === '*') { let j = src.indexOf('*/', i + 2); j = j < 0 ? n : j + 2; out.push(src.slice(i, j).split('\n').map(l => l.trim()).join('\n')); i = j; continue; } + if (c === '"' || c === "'") { let j = i + 1; while (j < n && src[j] !== c && src[j] !== '\n') { if (src[j] === '\\') j++; j++; } out.push(src.slice(i, j + 1)); i = j + 1; continue; } + if (/[A-Za-z0-9_\u0080-￿]/.test(c)) { let j = i; while (j < n && /[A-Za-z0-9_\u0080-￿]/.test(src[j])) j++; out.push(src.slice(i, j)); i = j; continue; } + out.push(c); i++; + } + // adjacent symbol characters are compared one by one, so merged or split operators still match + return out; +} +const files = []; +for (const dir of ['examples', 'examples/learn', 'tests/programs', 'tests/errors', 'tests/typeerrors', 'tests/langerrors', 'src/lib', 'src/compiler', 'src/runtime', 'src/lsp', 'src/tools']) + for (const f of fs.readdirSync(path.join(root, dir))) if (f.endsWith('.zc')) files.push(path.join(dir, f)); +assert.ok(files.length > 100, 'the corpus: ' + files.length); +let checked = 0; +for (const f of files) { + const src = fs.readFileSync(path.join(root, f), 'utf8'); + const once = format(src); + assert.deepStrictEqual(tokens(once), tokens(src), f + ': formatting changed the tokens'); + assert.strictEqual(format(once), once, f + ': formatting is not stable'); + assert.ok(once === '' || once.endsWith('\n'), f + ': ends with a line break'); + if (!src.includes('`')) assert.ok(!/[ \t]+$/m.test(once), f + ': trailing white space'); + checked++; +} +// the behaviour +const sample = 'fn main(){\nint x=1+2 ;\n if(x>1){\n put_n( x );\n}\n\n\n\nreturn 0;}\n'; +assert.strictEqual(format(sample), 'fn main() {\n\tint x = 1+2;\n\tif (x>1) {\n\t\tput_n(x);\n\t}\n\n\treturn 0; }\n'); +assert.strictEqual(format('fn f(a,b){return a*b;}\n'), 'fn f(a, b) { return a*b; }\n'); +assert.strictEqual(format('fn main() {\n\tswitch (k) {\n\tcase 1: return 1;\n\tdefault:\n\t\treturn 2;\n\t}\n}\n'), 'fn main() {\n\tswitch (k) {\n\t\tcase 1: return 1;\n\t\tdefault:\n\t\t\treturn 2;\n\t}\n}\n'); +assert.strictEqual(format('fn main() {\nlet a = 1 +\n2;\nif (a)\nreturn 1;\nreturn 0;\n}\n'), 'fn main() {\n\tlet a = 1 +\n\t\t2;\n\tif (a)\n\t\treturn 1;\n\treturn 0;\n}\n'); +assert.strictEqual(format('fn put_f(x) { asm { x @ #1220 } return 0; }\n'), 'fn put_f(x) { asm { x @ #1220 } return 0; }\n'); +assert.strictEqual(format('fn k() { asm {\n 0 , ?\n} return 1; }\n'), 'fn k() { asm {\n\t\t0 , ?\n\t} return 1; }\n'); +assert.strictEqual(format('/* a\n * b\n */\nint g = 1; // note\nint h;\n'), '/* a\n * b\n */\nint g = 1; // note\nint h;\n'); +assert.strictEqual(format('fn main() {\n\tif (x)\n\t{\n\t\ty();\n\t}\n\telse\n\t{\n\t\tz();\n\t}\n}'), 'fn main() {\n\tif (x)\n\t{\n\t\ty();\n\t}\n\telse\n\t{\n\t\tz();\n\t}\n}\n'); +assert.strictEqual(format('fn main() { return 0; }\n`data stays\n as it is\n'), 'fn main() { return 0; }\n`data stays\n as it is\n'); +// .zcformat next to a document: found from the document's directory upward; the editor's tab settings apply without one +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zcfmt-')); +fs.mkdirSync(path.join(dir, 'a', 'b'), { recursive: true }); +fs.writeFileSync(path.join(dir, '.zcformat'), '# comment\nUseTab: false\nIndentWidth: 2\nMaxEmptyLines: 0\nNormalizeSpacing: false\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]); }; +function lsp(uri, text, options) { + const input = Buffer.concat([ + { 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/formatting', params: { textDocument: { uri }, options } }, + { jsonrpc: '2.0', method: 'exit' }].map(frame)); + const r = cp.spawnSync(server, process.argv.slice(3), { input, cwd: root, maxBuffer: 1 << 26 }); + let d = r.stdout, caps, res; + while (d.length) { + const e = d.indexOf('\r\n\r\n'), n = Number(/Content-Length:\s*(\d+)/i.exec(d.subarray(0, e).toString())[1]); + const m = JSON.parse(d.subarray(e + 4, e + 4 + n)); d = d.subarray(e + 4 + n); + if (m.id === 1) caps = m.result.capabilities; + if (m.id === 2) res = m.result; + } + return { caps, res }; +} +const messy = 'fn main(){\nif(1){\nput_n( 1 );\n\n\nput_c(10);}\nreturn 0;}\n'; +let r = lsp('file://' + path.join(dir, 'a', 'b', 'x.zc'), messy, { tabSize: 8, insertSpaces: false }); +assert.ok(r.caps.documentFormattingProvider, 'formatting is advertised'); +assert.strictEqual(r.res.length, 1); +assert.strictEqual(r.res[0].newText, 'fn main(){\n if(1){\n put_n( 1 );\n put_c(10);}\n return 0;}\n', '.zcformat from two directories up wins over the editor'); +assert.deepStrictEqual(r.res[0].range.start, { line: 0, character: 0 }); +fs.rmSync(path.join(dir, '.zcformat')); +r = lsp('file://' + path.join(dir, 'a', 'y.zc'), messy, { tabSize: 3, insertSpaces: true }); +assert.strictEqual(r.res[0].newText, 'fn main() {\n if (1) {\n put_n(1);\n\n put_c(10); }\n return 0; }\n', 'the editor settings without a .zcformat'); +r = lsp('file://' + path.join(dir, 'a', 'y.zc'), 'fn main() {\n\treturn 0;\n}\n', { tabSize: 4, insertSpaces: false }); +assert.deepStrictEqual(r.res, [], 'a formatted document gets no edit'); +fs.rmSync(dir, { recursive: true }); +console.log('ok formatter: ' + checked + ' files keep their tokens and format stably; options and edits'); diff --git a/tests/drivers/frame_growth_test.py b/tests/drivers/frame_growth_test.py index 74156e3..6e03cbc 100644 --- a/tests/drivers/frame_growth_test.py +++ b/tests/drivers/frame_growth_test.py @@ -4,22 +4,22 @@ import subprocess import sys command = sys.argv[1:] -source = '''unchecked int descend(int depth, int* root) { - [2000]int padding; +source = '''include rt; unchecked int descend(int depth, int* root) { + int[2000] padding; padding[0] = depth; - if (depth == 0) { gc(); return *root; } + if (depth == 0) { rt.gc(); return *root; } return descend(depth - 1, root) + padding[0]; } unchecked int oversized(int* root) { - [300000]int values; + int[300000] values; values[0] = *root; values[299999] = 9; - gc(); return values[0] + values[299999]; + rt.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); + rt.put_n(descend(300, &value)); rt.put_c(32); + rt.put_n(oversized(&value)); rt.put_c(32); + rt.gc(); rt.put_n(rt.frame_node == 0); rt.put_c(10); return 0; } ''' diff --git a/tests/drivers/fuzz_compiler.py b/tests/drivers/fuzz_compiler.py new file mode 100644 index 0000000..512ec26 --- /dev/null +++ b/tests/drivers/fuzz_compiler.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Deterministic grammar fuzzing for the parser, runtime, and ownership paths. + +Usage: python3 tests/drivers/fuzz_compiler.py [path/to/zy] [path/to/zym] [--js] +""" +import random +import subprocess +import sys + + +args = [arg for arg in sys.argv[1:] if arg != "--js"] +js = "--js" in sys.argv[1:] +zy = args[0] if len(args) > 0 else "bin/zy" +zym = args[1] if len(args) > 1 else "bin/zym" +rng = random.Random(0x5A17) +values = [] +library_values = [] + + +def expr(depth): + if depth == 0 or rng.randrange(4) == 0: + value = rng.randrange(10) + return str(value), value + left, a = expr(depth - 1) + right, b = expr(depth - 1) + op = rng.choice(("+", "-", "*")) + if op == "+": + value = a + b + elif op == "-": + value = a - b + else: + value = a * b + return f"({left} {op} {right})", value + + +lines = ["include std;", "fn churn(n) {", " int i = 0;", " while (i < n) {", " { int* p = new(int); *p = i; }", " i++;", " }", " return 0;", "}", "fn stable() { return 73; }", "fn main() {", " let base = live_objects();"] +for _ in range(128): + source, value = expr(4) + lines.append(f" put_n({source}); put_c(32);") + values.append(value) +for _ in range(32): + a = "".join(rng.choice("abc") for _ in range(rng.randrange(11))) + b = "".join(rng.choice("abc") for _ in range(rng.randrange(6))) + lines.append(f' put_n(Text.len("{a}")); put_c(32); put_n(Text.eq("{a}", "{b}")); put_c(32);') + lines.append(f' put_n(Text.starts("{a}", "{b}")); put_c(32); put_n(Text.ends("{a}", "{b}")); put_c(32); put_n(Text.find("{a}", "{b}")); put_c(32);') + lines.append(f' put_n(Bytes.len(b"{a}")); put_c(32); put_n(Bytes.eq(b"{a}", b"{b}")); put_c(32);') + lines.append(f' put_n(Bytes.starts(b"{a}", b"{b}")); put_c(32); put_n(Bytes.ends(b"{a}", b"{b}")); put_c(32); put_n(Bytes.find(b"{a}", b"{b}")); put_c(32);') + library_values += [len(a), int(a == b), int(a.startswith(b)), int(a.endswith(b)), a.find(b), len(a.encode()), int(a == b), int(a.startswith(b)), int(a.endswith(b)), a.find(b)] +lines += [ + " churn(300); gc(); put_n(live_objects() - base); put_c(32);", + ' char* source = "fn f() { return 7; }"; let parsed = tokens(source);', + ' put_n(parsed[0] > 0); put_c(32); put_n(eq_w(source, "fn f() { return 7; }")); put_c(32);', + ' char* eval_source = "return 42;"; int e = 0;', + ' while (e < 16) { let q = tokens(eval_source); put_n(eval(q)); put_c(32); e++; }', + ' put_n(stable()); put_c(32); put_n(eq_w(eval_source, "return 42;")); put_c(10);', + " return 0;", + "}", +] +program = "\n".join(lines) + "\n" +expected = " ".join(str(x) for x in values + library_values + [0, 1, 1, *([42] * 16), 73, 1]) + "\n" + + +def run(binary): + result = subprocess.run(binary, input=program, text=True, capture_output=True, timeout=30) + if result.returncode or result.stdout != expected: + actual_words = result.stdout.split() + expected_words = expected.split() + mismatch = next((i for i, pair in enumerate(zip(actual_words, expected_words)) if pair[0] != pair[1]), min(len(actual_words), len(expected_words))) + raise SystemExit( + f"{' '.join(binary)} failed deterministic fuzz seed 0x5a17\n" + f"exit={result.returncode}, token mismatch at {mismatch}\n" + f"expected={expected_words[max(0, mismatch - 3):mismatch + 4]!r}\n" + f"stdout={actual_words[max(0, mismatch - 3):mismatch + 4]!r}\n" + f"stderr={result.stderr!r}" + ) + return result.stdout + + +hosts = [(zy, [zy]), (zym, [zym])] +if js: + hosts.append(("javascript", ["node", "src/js/tinyvm.js"])) +outputs = [run(command) for _, command in hosts] +if any(output != outputs[0] for output in outputs[1:]): + raise SystemExit("compiler hosts disagreed on deterministic fuzz output") +print("ok compiler/runtime/stdlib fuzz: 128 generated expressions, 300 scoped allocations and GC, " + "16 eval recompiles, unchanged source strings, and 32 randomized Text/Bytes cases on " + + ", ".join(name for name, _ in hosts)) diff --git a/tests/drivers/fuzz_lsp.js b/tests/drivers/fuzz_lsp.js new file mode 100644 index 0000000..e22a44b --- /dev/null +++ b/tests/drivers/fuzz_lsp.js @@ -0,0 +1,89 @@ +// Deterministic source/protocol fuzzing for diagnostics, caching, and recovery. +// Usage: node tests/drivers/fuzz_lsp.js path/to/zclsp +const { spawnSync } = require('child_process'); +const server = process.argv[2] || 'bin/zclsp'; +let state = 0x5a17; +function next() { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state; } +function frame(m) { + const body = Buffer.from(JSON.stringify(m), 'utf8'); + return Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\r\n\r\n'), body]); +} +function parse(buf) { + const out = []; let at = 0; + while (at < buf.length) { + const end = buf.indexOf('\r\n\r\n', at); + if (end < 0) throw new Error('truncated LSP header'); + const match = /Content-Length: (\d+)/.exec(buf.slice(at, end).toString()); + if (!match) throw new Error('missing Content-Length'); + const size = Number(match[1]), start = end + 4; + if (start + size > buf.length) throw new Error('truncated LSP body'); + out.push(JSON.parse(buf.slice(start, start + size).toString('utf8'))); + at = start + size; + } + return out; +} +const msgs = [{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }]; +const docs = []; +for (let i = 0; i < 32; i++) { + const uri = `file:///fuzz-${i}.zc`, n = next() % 1000; + const broken = i % 3 === 0; + const initial = `fn calc_${i}(x) { return (x + ${n}) * 2; }\nfn main() { let y = calc_${i}(${n})${broken ? '' : ';'} return y; }\n`; + const fixed = `fn calc_${i}(x) { return (x + ${n}) * 2; }\nfn main() { let y = calc_${i}(${n}); return y; }\n`; + docs.push({ uri, broken }); + msgs.push({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'zc', version: 1, text: initial } } }); + msgs.push({ jsonrpc: '2.0', id: 100 + i, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri } } }); + msgs.push({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version: 2 }, contentChanges: [{ text: fixed }] } }); +} +const stdUri = 'file:///fuzz-std.zc'; +msgs.push({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri: stdUri, languageId: 'zc', version: 1, text: 'include std;\nfn main() { return Text.starts("alpha", "al"); }\n' } } }); + +// Keep one server alive while documents are repeatedly opened, made malformed, +// repaired, closed, and reopened at a new version. This exercises cache +// invalidation and stale-diagnostic cleanup across a long-lived editor session. +const churn = new Map(); +for (let i = 0; i < 96; i++) { + const slot = i % 4, uri = `file:///churn-${slot}.zc`, n = next() % 10000; + const malformed = `fn churn_${slot}(x) { let y = (x + ${n}); return y; }\nfn main() { return churn_${slot}(${n}) }\n`; + const valid = `fn churn_${slot}(x) { let y = (x + ${n}); return y; }\nfn main() { return churn_${slot}(${n}); }\n`; + const version = Math.floor(i / 4) * 3 + 1; + msgs.push({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'zc', version, text: malformed } } }); + msgs.push({ jsonrpc: '2.0', id: 1000 + i, method: 'textDocument/semanticTokens/full', params: { textDocument: { uri } } }); + msgs.push({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version: version + 1 }, contentChanges: [{ text: valid }] } }); + msgs.push({ jsonrpc: '2.0', method: 'textDocument/didClose', params: { textDocument: { uri } } }); + churn.set(uri, valid); +} +let churnVersion = 100; +for (const [uri, text] of churn) { + msgs.push({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'zc', version: churnVersion++, text } } }); +} +msgs.push({ jsonrpc: '2.0', method: 'exit' }); +const run = spawnSync(server, [], { input: Buffer.concat(msgs.map(frame)), timeout: 60000, maxBuffer: 1 << 28 }); +if (run.error || run.status !== 0) throw run.error || new Error(`server exit ${run.status}: ${run.stderr}`); +const out = parse(run.stdout); +const diagnostics = new Map(); +for (const m of out) { + if (m.method === 'textDocument/publishDiagnostics') { + const items = diagnostics.get(m.params.uri) || []; + items.push(m.params.diagnostics); + diagnostics.set(m.params.uri, items); + } +} +for (let i = 0; i < docs.length; i++) { + const { uri, broken } = docs[i], batches = diagnostics.get(uri) || []; + if (batches.length < 2) throw new Error(`${uri}: expected diagnostics for open and change`); + if (broken && !batches[0].some(d => d.severity === 1)) throw new Error(`${uri}: malformed source was not diagnosed`); + if (batches[batches.length - 1].some(d => d.severity === 1)) throw new Error(`${uri}: valid edit retained an error`); + const response = out.find(m => m.id === 100 + i); + if (!response || response.error || !response.result || response.result.data.length % 5) throw new Error(`${uri}: semantic tokens response was malformed`); +} +for (let i = 0; i < 96; i++) { + const response = out.find(m => m.id === 1000 + i); + if (!response || response.error || !response.result || response.result.data.length % 5) throw new Error(`churn request ${i}: malformed semantic tokens response`); +} +const stdDiag = diagnostics.get(stdUri) || []; +if (!stdDiag.length || stdDiag[stdDiag.length - 1].some(d => d.severity === 1)) throw new Error('stdlib include failed LSP source analysis'); +for (const uri of churn.keys()) { + const batches = diagnostics.get(uri) || []; + if (!batches.length || batches[batches.length - 1].some(d => d.severity === 1)) throw new Error(`${uri}: reopened document retained stale diagnostics`); +} +console.log(`ok lsp fuzz: ${docs.length} deterministic open/change sequences, 96 long-session cache churns, semantic tokens, and include std`); diff --git a/tests/drivers/fuzz_malformed.py b/tests/drivers/fuzz_malformed.py new file mode 100644 index 0000000..5bf97cf --- /dev/null +++ b/tests/drivers/fuzz_malformed.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Deterministic malformed-source differential fuzzing for compiler hosts. + +Usage: python3 tests/drivers/fuzz_malformed.py [native] [microcore] +""" +import random +import subprocess +import sys + + +bins = sys.argv[1:] or ["bin/zy", "bin/zym"] +if len(bins) == 1: + bins.append(bins[0]) +rng = random.Random(0xC0DE) +tokens = [ + b"fn", b"let", b"int", b"char*", b"word", b"return", b"if", b"else", + b"while", b"for", b"include", b"std", b";", b"{", b"}", b"(", b")", + b"[", b"]", b"=", b",", b".", b":", b"?", b"+", b"-", b"*", b"&", + b"|", b"!", b"~", b'"text"', b"'x'", b"0", b"1", b"_", b"NIF", + b"asm", b"unchecked", b"\x00", b"\xff", "λ".encode(), +] +cases = [] +for i in range(256): + words = [rng.choice(tokens) for _ in range(rng.randrange(1, 100))] + cases.append(b" ".join(words) + b"\n") + +# Mutate a small valid program around parser and analyzer boundaries. Keeping +# each mutation bounded makes a failing seed easy to reproduce and minimize. +base = b"include std;\nfn main() { let x = 1; return x; }\n" +for _ in range(64): + data = bytearray(base) + for __ in range(rng.randrange(1, 5)): + at = rng.randrange(len(data)) + if rng.randrange(2): + data[at:at + 1] = rng.choice(tokens) + else: + del data[at:at + 1] + cases.append(bytes(data)) + + +def run(binary, source): + return subprocess.run( + [binary], input=b"typecheck on;\n" + source, capture_output=True, timeout=3 + ) + + +for i, source in enumerate(cases): + results = [] + for binary in bins: + try: + result = run(binary, source) + except subprocess.TimeoutExpired: + raise SystemExit(f"{binary} timed out on malformed seed 0xC0DE case {i}: {source!r}") + if result.returncode < 0: + raise SystemExit( + f"{binary} crashed on malformed seed 0xC0DE case {i}: " + f"signal {-result.returncode}, source={source!r}, stderr={result.stderr!r}" + ) + results.append((result.returncode, result.stdout, result.stderr)) + if any(result != results[0] for result in results[1:]): + raise SystemExit(f"compiler hosts disagreed on malformed seed 0xC0DE case {i}: {source!r}") + +print(f"ok malformed-source fuzz: {len(cases)} bounded token and byte mutations agree on {len(bins)} compiler hosts") diff --git a/tests/drivers/fuzz_wasm.js b/tests/drivers/fuzz_wasm.js new file mode 100644 index 0000000..b0982d4 --- /dev/null +++ b/tests/drivers/fuzz_wasm.js @@ -0,0 +1,38 @@ +// Deterministic compiler/runtime/stdlib fuzzing through the WebAssembly host. +// Usage: node tests/drivers/fuzz_wasm.js path/to/zc.wasm +const fs = require('fs'); +const ZC = require('../../src/web/zc.js'); + +let state = 0x5a17; +function next() { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state; } +function expr(depth) { + if (!depth || next() % 4 === 0) { const n = next() % 10; return [String(n), n]; } + const [left, a] = expr(depth - 1), [right, b] = expr(depth - 1), op = ['+', '-', '*'][next() % 3]; + return [`(${left} ${op} ${right})`, op === '+' ? a + b : op === '-' ? a - b : a * b]; +} +function quote(s) { return '"' + s + '"'; } + +(async () => { + const lines = ['include std;', 'fn churn(n) { int i = 0; while (i < n) { { int* p = new(int); *p = i; } i++; } return 0; }', 'fn stable() { return 73; }', 'fn main() {', ' let base = live_objects();']; + const expected = []; + for (let i = 0; i < 128; i++) { + const [source, value] = expr(4); + lines.push(` put_n(${source}); put_c(32);`); expected.push(value); + } + for (let i = 0; i < 32; i++) { + const a = Array.from({ length: next() % 11 }, () => 'abc'[next() % 3]).join(''); + const b = Array.from({ length: next() % 6 }, () => 'abc'[next() % 3]).join(''); + lines.push(` put_n(Text.len(${quote(a)})); put_c(32); put_n(Text.eq(${quote(a)}, ${quote(b)})); put_c(32);`); + lines.push(` put_n(Text.starts(${quote(a)}, ${quote(b)})); put_c(32); put_n(Text.ends(${quote(a)}, ${quote(b)})); put_c(32); put_n(Text.find(${quote(a)}, ${quote(b)})); put_c(32);`); + lines.push(` put_n(Bytes.len(b${quote(a)})); put_c(32); put_n(Bytes.eq(b${quote(a)}, b${quote(b)})); put_c(32);`); + lines.push(` put_n(Bytes.starts(b${quote(a)}, b${quote(b)})); put_c(32); put_n(Bytes.ends(b${quote(a)}, b${quote(b)})); put_c(32); put_n(Bytes.find(b${quote(a)}, b${quote(b)})); put_c(32);`); + expected.push(a.length, Number(a === b), Number(a.startsWith(b)), Number(a.endsWith(b)), a.indexOf(b), a.length, Number(a === b), Number(a.startsWith(b)), Number(a.endsWith(b)), a.indexOf(b)); + } + lines.push(' churn(300); gc(); put_n(live_objects() - base); put_c(32);', ' put_n(stable()); put_c(10);', ' return 0;', '}'); + expected.push(0, 73); + const source = lines.join('\n') + '\n', want = expected.join(' ') + '\n'; + const zc = await ZC.load(fs.readFileSync(process.argv[2] || 'src/web/zc.wasm')); + const result = await zc.run(source); + if (result.output !== want) throw new Error(`wasm fuzz mismatch\nexpected: ${want.slice(0, 180)}\nactual: ${result.output.slice(0, 180)}\nmessages: ${result.messages}`); + console.log('ok wasm compiler/runtime/stdlib fuzz: 128 expressions, 300 scoped allocations and GC, and 32 Text/Bytes cases'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/drivers/hints_test.js b/tests/drivers/hints_test.js new file mode 100644 index 0000000..8bb1934 --- /dev/null +++ b/tests/drivers/hints_test.js @@ -0,0 +1,31 @@ +// hints are informational diagnostics (severity 3) and follow positional hints on / small / off directives. +// usage: node tests/drivers/hints_test.js path/to/zclsp +const assert = require('assert'); +const cp = require('child_process'); +const server = process.argv[2]; +const frame = m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); }; +function diagnostics(text) { + const uri = 'file:///hints.zc'; + const input = Buffer.concat([ + { 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', method: 'exit' }].map(frame)); + const r = cp.spawnSync(server, process.argv.slice(3), { input, timeout: 20000, maxBuffer: 4 << 20 }); + assert.strictEqual(r.status, 0, String(r.stderr)); + let d = r.stdout, out = []; + while (d.length) { + const e = d.indexOf('\r\n\r\n'), n = Number(/Content-Length:\s*(\d+)/i.exec(d.subarray(0, e).toString())[1]); + const m = JSON.parse(d.subarray(e + 4, e + 4 + n)); d = d.subarray(e + 4 + n); + if (m.method === 'textDocument/publishDiagnostics') out = m.params.diagnostics; + } + return out; +} +const names = ds => ds.map(d => d.message.split(' ')[0]).sort().join(','); +const flow = 'fn main() {\n let a = 1;\n hints small;\n let b = 2;\n int c = 5 * 8;\n hints off;\n let d = 3;\n int k = 3 * 4;\n hints on;\n let e = 4;\n return 0;\n}\n'; +const ds = diagnostics(flow); +assert.ok(ds.length > 0 && ds.every(d => d.severity === (d.range.start.line === 3 || d.range.start.line === 4 ? 4 : 3)), 'small uses compact severity 4; on uses informational severity 3: ' + JSON.stringify(ds)); +assert.strictEqual(names(ds), 'a,b,c,e,multiplying', 'off suppresses hints; small retains them with compact markers'); +const style = diagnostics('fn main() {\n int n = 1;\n n = n * 8;\n hints small;\n n = n * 16;\n return n;\n}\n'); +assert.strictEqual(style.filter(d => /is a shift/.test(d.message)).length, 2, 'small retains style hints with compact markers'); +assert.strictEqual(diagnostics('fn main() { int x = ; return 0; }').filter(d => d.severity === 1).length > 0, true); +console.log('ok hints are blue information diagnostics with positional on, small and off'); diff --git a/tests/drivers/hover_features_test.js b/tests/drivers/hover_features_test.js index 895e923..d6e1226 100644 --- a/tests/drivers/hover_features_test.js +++ b/tests/drivers/hover_features_test.js @@ -38,15 +38,15 @@ for (const type of ['word', 'int', 'char', 'bool', 'float', 'f64', 'f32', 'byte' 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'; +const array = 'fn main() {\n u8[4] 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/); +assert.match(hover(array, 'values', 1), /\(u8\[4\]\) let values/); 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/); +assert.match(hover(struct, 'value', 1), /\(pair\) let value/); 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/); +assert.match(hover(fields, 'left +'), /\(int\) let left \/\/ field;[\s\S]*left field/); +assert.match(hover(fields, 'right;'), /\(int\) let right \/\/ field;[\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'; @@ -63,23 +63,69 @@ const functionDocs = '/**\n * adds **one** to its argument.\n */\nint add(int x) 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/); +assert.match(hover(inferred, 'x *'), /\(int\) let x \/\/ parameter;/); +assert.match(hover(inferred, 'value;'), /\(int\) let value/); 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/); +assert.match(hover(pointer, 'p[0]'), /\(int\*\) let p \/\/ parameter;[\s\S]*pointer to `int`/); +const alias = 'type row = int[3];\nfn main() { row values; return values[0]; }\n'; +assert.match(hover(alias, 'row values'), /int\[3\][\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/); +assert.match(hover(enumeration, 'shade;'), /\(color\) let shade/); +const dynamic = 'fn main() { int[?] items; push(items, 3); return len(items); }\n'; +assert.match(hover(dynamic, 'items);'), /\(int\[\?\]\) let items[\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\)/); +assert.match(hover(callable, 'apply(2)'), /\(.*\([\s\S]*callable value/); +assert.match(hover(callable, 'apply(2)'), /\(int\(int\)\) let.m apply/); 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\*/); +assert.match(hover(floatClosure, 'result;'), /\(float\) let result/); +const inferredPointer = 'include rt;\nstruct 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'), /\(node\*\) let.m p/); +const aggregateAlias = 'fn main() { int[3] a; a[1] = 7; let alias = a; return alias[1]; }\n'; +assert.match(hover(aggregateAlias, 'alias[1]'), /\(int\*\) let alias/); +const sections = '// --- values ---\n// these declarations form a group.\nconst int limit = 7;\nfn main() { return limit; }'; +assert.ok(!hover(sections, 'limit;').includes('these declarations form a group')); +assert.ok(!hover(sections, 'limit;').includes('--- values ---')); +const blockSection = '/* ---- values ---- */\nint limit = 7;\nfn main() { return limit; }'; +assert.ok(!hover(blockSection, 'limit;').includes('---- values ----')); +const trailing = 'fn main() {\n int n = 7; // the current count\n int next = 2;\n return n + next;\n}'; +assert.match(hover(trailing, 'n +'), /the current count/); +assert.ok(!hover(trailing, 'next;').includes('the current count')); +const trailingBlock = 'fn main() { int a = 1, b = 2; /* final count */\n return a + b; }'; +assert.ok(!hover(trailingBlock, 'a +').includes('final count')); +assert.match(hover(trailingBlock, 'b;'), /final count/); +const adjacent = 'fn main() { int a /* first count */ = 1, b /* second count */ = 2; return a + b; }'; +assert.match(hover(adjacent, 'a +'), /first count/); +assert.ok(!hover(adjacent, 'a +').includes('second count')); +assert.match(hover(adjacent, 'b;'), /second count/); +// section headings end a doc block; trailing comments belong to their own line's declaration +const sectionDocs = '// ---- one ----\n\n// adds.\nint add(int a, int b) { return a + b; } // after add\n// overview\n// ---- two ----\nint sub(int a, int b) { return a - b; }\n// ---- three ----\n// own doc\nint mul(int a, int b) { return a * b; }\nint g = 3; /* after g */\nfn main() { return add(1, 2) + sub(3, 1) + mul(2, 2) + g; }\n'; +const addHover = hover(sectionDocs, 'add(1'); +assert.ok(addHover.includes('adds.') && addHover.includes('after add') && !addHover.includes('overview') && !addHover.includes('after g'), addHover); +assert.strictEqual(hover(sectionDocs, 'sub(3'), '```zc\nint sub(int a, int b)\n```'); +const mulHover = hover(sectionDocs, 'mul(2'); +assert.ok(!mulHover.includes('own doc') && !mulHover.includes('three') && !mulHover.includes('after'), mulHover); +assert.ok(hover(sectionDocs, 'g; }').includes('after g')); +// ownership and transfers explained in hovers +const lifeDocs = 'include rt; struct node { int v; node* next; };\nint*.u make(int n) { int* q = new(int); *q = n; return u*q; }\nfn take(.d p) { return p[0]; }\nfn main() {\n let a = make(1);\n node* b = new(node), c = new(node);\n b.next = c;\n node* r = b.next;\n let d = new(3);\n take(d*d);\n return a[0] + r.v;\n}\n'; +assert.match(hover(lifeDocs, 'q = new'), /let.m q[\s\S]*passed up: returned on line 2/); +assert.match(hover(lifeDocs, 'a ='), /let.m a[\s\S]*freed where its block ends on line 12/); +assert.match(hover(lifeDocs, 'r.v'), /borrows from `b`, declared on line 6/); +assert.match(hover(lifeDocs, 'c = new'), /passed down: stored in a container `b` on line 7/); +assert.match(hover(lifeDocs, 'd = new'), /passed down: given to a d parameter on line 10/); +const declaredResult = 'fn main() { let f = fn:float(int x) { return x; }; return f(2); }\n'; +assert.match(hover(declaredResult, 'f(2)'), /\(float\(int\)\) let.m f/); +const nonDocs = '//: scratch region overview\nint scratch = 1;\n/* public count */ int count = 2; //: implementation note\nfn main() { return scratch + count; }'; +assert.ok(!hover(nonDocs, 'scratch +').includes('scratch region overview')); +assert.match(hover(nonDocs, 'count;'), /public count/); +assert.ok(!hover(nonDocs, 'count;').includes('implementation note')); +const globals = 'let hello = "hello", prog[20000];\nint count = 3;\nfn main() { return hello[0] + prog[0] + count; }\n'; +for (const [name, type, use] of [['hello', 'char*', 'hello[0]'], ['prog', 'word[20000]', 'prog[0]'], ['count', 'int', 'count;']]) { + for (const needle of [name, use]) { + const value = hover(globals, needle); + assert.ok(value.startsWith('```zc\n(' + type + ') let ' + name + ' // global;\n```'), value); + assert.ok(!value.includes('; type '), value); + } +} +assert.match(hover('include run; fn main() { return run.sval[0]; }', 'sval'), /symbol[\s\S]*value/i); +assert.match(hover('include rt; fn main() { return rt.len_s(b"bytes"); }', 'len_s'), /number of bytes in a packed byte string/i); console.log('ok built-in and composite types, inferred signatures, and separate block docs'); diff --git a/tests/drivers/hover_surface_test.js b/tests/drivers/hover_surface_test.js new file mode 100644 index 0000000..13a9a3f --- /dev/null +++ b/tests/drivers/hover_surface_test.js @@ -0,0 +1,65 @@ +// hover declarations and instructions in the source-only documents opened by f12. +const assert = require('assert'); +const fs = require('fs'); +const cp = require('child_process'); +const path = require('path'); +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 messages = [{ id: 1, method: 'initialize', params: {} }], checks = []; +let id = 2; +function doc(uri, text) { + messages.push({ method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, text } } }); + return (needle, offset = 0) => { + const at = text.indexOf(needle) + offset; assert.ok(at >= 0, needle); + const before = text.slice(0, at), rid = id++; + messages.push({ id: rid, method: 'textDocument/hover', params: { textDocument: { uri }, position: { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 } } }); + return rid; + }; +} +const compiler = fs.readFileSync('src/compiler/compiler.zc', 'utf8'); +const ask = doc('tinyvm:/compiler.zc', compiler); +checks.push([ask('ifrcap = NIF', 9), /const int NIF = 431/, /431/]); +checks.push([ask('capture_width = NCAP', 16), /const int NCAP = 32/, /initial capture capacity/, /32/]); +checks.push([ask('closure_cap = NCLO', 14), /const int NCLO = 8/, /initial closure nesting capacity/]); +checks.push([ask('clo_n = clo_n_initial', 8), /word\[NCLO\] clo_n_initial/]); +checks.push([ask('int nclo, closure_cap', 10), /\(int\) let closure_cap/, /value at this position:[\s\S]*\n8\n/]); +const rt = doc('tinyvm:/rt.zc', fs.readFileSync('src/runtime/rt.zc', 'utf8')); +checks.push([rt('SYS_CLOSE = 2'), /const int SYS_CLOSE = 2/]); +const fixture = doc('tinyvm:/fixture.zc', 'const int FIRST = 1, SECOND = 2;\nword[4] backing; word active = backing;\nint x = SECOND;\n'); +checks.push([fixture('x = SECOND', 4), /const int SECOND = 2/]); +checks.push([fixture('active = backing', 9), /word\[4\] backing/]); +const mnemonic = 'q@!$_~cjzr,?.+-*&|^><%'; +for (const op of mnemonic) { + const get = doc('tinyvm:/asm-' + op.charCodeAt(0) + '.zc', `fn f() { asm { ${op} } return 0; }`); + checks.push([get(`{ ${op}`, 2), /opcode \d+/, /(stack:|return stack|data stack)/]); +} +const special = doc('tinyvm:/special.zc', 'fn f() { asm { #1224 } }\ninclude std;\n'); +checks.push([special('#1224'), /load byte/, /byte address/]); +checks.push([special('1224'), /load byte/]); +checks.push([special('std;'), /include std;/, /embedded standard library/, /nothing imports it automatically/]); +const normal = doc('file:///ordinary.zc', 'fn main() { int std = 7; int c = 2; return std + c; }'); +checks.push([normal('std +'), /\(int\) let std/, /value at this position/]); +checks.push([normal('c;'), /\(int\) let c/]); +messages.push({ method: 'exit' }); +let out; +if (process.argv[2] === '--wasm' || process.argv[2] === '--vm') { + const wasm = process.argv[2] === '--wasm', host = require(path.resolve('editors/vscode/client', wasm ? 'wasmproc.js' : 'vmproc.js')); + const p = (wasm ? host.wasmProcess : host.vmProcess)(path.resolve(process.argv[3]), { log: () => {} }); + const chunks = []; p.stdout.on('data', d => chunks.push(d)); + for (const m of messages) p.stdin.write(frame({ jsonrpc: '2.0', ...m })); + out = Buffer.concat(chunks); +} else { + const r = cp.spawnSync(process.argv[2], process.argv.slice(3), { input: Buffer.concat(messages.map(m => frame({ jsonrpc: '2.0', ...m }))), timeout: 60000, maxBuffer: 32 << 20 }); + assert.strictEqual(r.status, 0, String(r.stderr)); out = r.stdout; +} +const replies = new Map(); +while (out.length) { + const e = out.indexOf('\r\n\r\n'), n = Number(/Content-Length:\s*(\d+)/i.exec(out.subarray(0, e).toString())[1]); + const m = JSON.parse(out.subarray(e + 4, e + 4 + n)); replies.set(m.id, m); out = out.subarray(e + 4 + n); +} +for (const [rid, ...patterns] of checks) { + const value = replies.get(rid)?.result?.contents?.value || ''; + for (const pattern of patterns) assert.match(value, pattern, `hover #${rid}: ${value}`); +} +assert.ok(!replies.get(checks[2][0]).result.contents.value.includes('closures being compiled'), 'the section overview is not NCLO documentation'); +assert.ok(!replies.get(checks[checks.length - 1][0]).result.contents.value.includes('opcode'), 'ordinary c is a variable'); +console.log('ok embedded declaration hovers, constant values, asm instructions and explicit std'); diff --git a/tests/drivers/hover_values_test.js b/tests/drivers/hover_values_test.js new file mode 100644 index 0000000..badc2ed --- /dev/null +++ b/tests/drivers/hover_values_test.js @@ -0,0 +1,68 @@ +// value previews follow the compiler's bindings and the position of each use. +const assert = require('assert'); +const cp = require('child_process'); +const server = process.argv[2]; +function hover(text, needle, nth = 0) { + let at = -1; + for (let n = 0; n <= nth; n++) at = text.indexOf(needle, at + 1); + assert.ok(at >= 0, needle); + const before = text.slice(0, at), uri = 'file:///hover-values.zc'; + const messages = [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, text } } }, + { 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 } } }, + { jsonrpc: '2.0', method: 'exit' } + ]; + const input = Buffer.concat(messages.map(m => { + const body = Buffer.from(JSON.stringify(m)); + return Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\r\n\r\n'), body]); + })); + const result = cp.spawnSync(server, process.argv.slice(3), { input, timeout: 20000, maxBuffer: 8 << 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 n = Number(/Content-Length:\s*(\d+)/i.exec(data.subarray(0, end).toString())[1]); + const reply = JSON.parse(data.subarray(end + 4, end + 4 + n)); + data = data.subarray(end + 4 + n); + if (reply.id === 2) return reply.result?.contents?.value || ''; + } + assert.fail('missing hover response'); +} +function value(text, needle, expected, nth = 0) { + const md = hover(text, needle, nth); + if (expected === null) assert.ok(!md.includes('value at this position:'), md); + else assert.ok(md.includes('value at this position:\n```zc\n' + expected + '\n```'), md); +} +const flow = 'fn unknown() { return 9; }\nfn main() { let n = 42; put_n(n); n = 7; put_n(n); n = unknown(); return n; }'; +value(flow, 'n = 42', '42'); +value(flow, 'n);', '42'); +value(flow, 'n);', '7', 1); +value(flow, 'n;', null); +value('const answer = 6 * 7; fn main() { return answer; }', 'answer;', '42'); +value('const answer = 42; fn main() { return answer; }', 'answer =', '42'); +value('enum color { red, green = 5 }; fn main() { color shade = green; return shade; }', 'shade;', '5'); +value('fn main() { let n = -9223372036854775808; return n; }', 'n;', '-9223372036854775808'); +value('fn main() { u8 n = 300; return n; }', 'n;', '44'); +value('fn main() { i8 n = 255; return n; }', 'n;', '-1'); +value('fn main() { let n = 3.25; return n; }', 'n;', '3.25'); +value('fn main() { float n = 2; return n; }', 'n;', '2'); +value('fn main() { int n = 3.75; return n; }', 'n;', '3'); +value('fn main() { f32 n = 0.1; return n; }', 'n;', '0.10000000149011612'); +value('fn main() { let n = -0.0; return n; }', 'n;', '0'); +value('fn main() { let text = "hello\\nworld"; return text; }', 'text;', '"hello\\nworld"'); +value('fn main() { let text = b"hello"; return text; }', 'text;', 'b"hello"'); +value('fn main() { let n = 7; let copy = n; n = 8; return copy; }', 'copy;', '7'); +value('fn main() { let n = 1; { let n = 2; put_n(n); } return n; }', 'n);', '2'); +value('fn main() { let n = 1; { let n = 2; put_n(n); } return n; }', 'n;', '1'); +value('fn change(int* p) { p[0] = 9; } fn main() { int n = 7; let p = &n; n = 8; change(p); return n; }', 'n;', null, 1); +value('fn main() { int n = 7; let p = &n; n = 8; p[0] = 9; return n; }', 'n;', null, 1); +value('fn main() { let text = "hey"; text[0] = 120; return text; }', 'text;', null); +value('fn main() { let n = 1; if (1) n = 2; return n; }', 'n;', null); +value('fn main() { let n = 1; while (0) n = 2; return n; }', 'n;', null); +value('fn main() { let n = 1; let ignored = 0 && (n = 2); return n; }', 'n;', null); +value('fn main() { let p = new(int, 3); return p; }', 'p;', null); +value('fn main() { let n = clz(0); return n; }', 'n;', null); +value('let global = 7; fn main() { return global; }', 'global;', null); +value('let global = 7; fn main() { global = 8; return global; }', 'global;', '8'); +console.log('ok position-aware integer, float, string, constant, and alias values; unknown effects invalidate previews'); diff --git a/tests/drivers/icon_test.py b/tests/drivers/icon_test.py new file mode 100644 index 0000000..560c622 --- /dev/null +++ b/tests/drivers/icon_test.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# execute isolated icon acceptance, rejection and regression fixtures. +from pathlib import Path +import argparse +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument('vm', nargs='?', default='bin/zy') +parser.add_argument('--case', default='') +parser.add_argument('--js', action='store_true') +parser.add_argument('--source', action='store_true', help='test the working library source instead of the embedded module') +args = parser.parse_args() +root = Path(__file__).resolve().parents[2] +cases = sorted((root / 'tests/icon').glob('*/*.zc')) +count = 0 +for case in cases: + if args.case and args.case not in str(case): + continue + expected = case.with_suffix('.expected').read_text() + command = ['node', str(root / 'src/js/tinyvm.js')] if args.js else [str(root / args.vm)] + program = case.read_text() + if args.source: + # keep module imports while suppressing its embedded source loader. + library = (root / 'src/lib/icon.zc').read_text() + setup = '\nnamespace;\ninclude run;\nimmediate fn icon_working_source() { run.icon_done = 1; run.next(); return 0; }\nicon_working_source\n' + program = library + setup + program + proc = subprocess.run(command, input=program, text=True, capture_output=True, cwd=root, timeout=30) + assert proc.returncode == 0 and proc.stdout == expected, f'{case.relative_to(root)}\nstdout: {proc.stdout}\nstderr: {proc.stderr}\nexpected: {expected}' + print(f'ok {case.relative_to(root)}') + count += 1 +assert count, 'no icon fixtures selected' +print(f'{count} icon fixtures passed') diff --git a/tests/drivers/include_test.js b/tests/drivers/include_test.js new file mode 100644 index 0000000..6ba4e5f --- /dev/null +++ b/tests/drivers/include_test.js @@ -0,0 +1,94 @@ +// include: a path is found next to the file that has it, then in the workspace folder, then as written; the +// error for one that cannot be read is on its string; include std; needs no file; a server with no files of its own +// is sent the text of each (zc/file). usage: node tests/drivers/include_test.js path/to/zclsp [path/to/lsp.wasm] +const assert = require('assert'); +const cp = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const server = path.resolve(process.argv[2]); +const wasm = process.argv[3]; +const frame = m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); }; +function parse(out) { + const res = []; + let d = out; + while (d.length) { + const e = d.indexOf('\r\n\r\n'), n = Number(/Content-Length:\s*(\d+)/i.exec(d.subarray(0, e).toString())[1]); + res.push(JSON.parse(d.subarray(e + 4, e + 4 + n))); d = d.subarray(e + 4 + n); + } + return res; +} +// runs one session on the native server (cwd is where includes are read as written) or the wasm host +function check(uri, text, { files = [], root = null, cwd = os.tmpdir(), host = 'native', navigation = false } = {}) { + const msgs = [{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { rootUri: root } }, + ...files.map(([p, t]) => ({ jsonrpc: '2.0', method: 'zc/file', params: { path: p, text: t } })), + { jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, languageId: 'zc', text } } }, + ...(navigation ? [2, 3].map(id => ({ jsonrpc: '2.0', id, method: 'textDocument/definition', params: { textDocument: { uri }, position: { line: 2, character: 20 } } })) : []), + { jsonrpc: '2.0', method: 'exit' }]; + let out; + if (host === 'native') out = cp.spawnSync(server, [], { input: Buffer.concat(msgs.map(frame)), cwd, maxBuffer: 1 << 26 }).stdout; + else { + const { wasmProcess } = require(path.join(__dirname, '..', '..', 'editors', 'vscode', 'client', 'wasmproc.js')); + const p = wasmProcess(path.resolve(wasm), { log: () => {} }); + const chunks = []; + p.stdout.on('data', d => chunks.push(d)); + for (const m of msgs) p.stdin.write(frame(m)); + out = Buffer.concat(chunks); + } + const replies = parse(out); + if (navigation) { + for (const id of [2, 3]) assert.deepStrictEqual(replies.find(m => m.id === id).result, [{ uri: 'file:///nowhere/proj/lib/virt.zc', range: { start: { line: 0, character: 4 }, end: { line: 0, character: 10 } } }], host + ': navigation uses client-provided includes repeatedly'); + } + const note = replies.filter(m => m.method === 'textDocument/publishDiagnostics').pop(); + return note ? note.params.diagnostics : null; +} +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zcinc-')); +const w = (f, t) => { fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); fs.writeFileSync(path.join(dir, f), t); }; +w('src/lib/util.zc', 'int twice(int v) { return v * 2; }\n'); +w('src/lib/deep.zc', 'include "util.zc";\nint quad(int v) { return twice(twice(v)); }\n'); +w('src/tools/main.zc', 'include "../lib/deep.zc";\nfn main() { return quad(1); }\n'); +w('top.zc', 'int fromtop() { return 7; }\n'); +const uri = f => 'file://' + path.join(dir, f); +const read = f => fs.readFileSync(path.join(dir, f), 'utf8'); +const errors = d => d.filter(x => x.severity === 1); +// next to the file, and nested includes next to the file that has them (the cwd is not the document's directory) +assert.deepStrictEqual(errors(check(uri('src/tools/main.zc'), read('src/tools/main.zc'))), [], 'a relative include, nested'); +// the original build.zc reproduction, checked from an unrelated working directory +const buildFile = path.resolve(__dirname, '../../src/tools/build.zc'); +const buildText = fs.readFileSync(buildFile, 'utf8').replace('include std;', 'include "../lib/std.zc";'); +assert.deepStrictEqual(errors(check('file://' + buildFile, buildText)), [], 'build.zc finds ../lib/std.zc'); +// the workspace folder +assert.deepStrictEqual(errors(check(uri('src/tools/a.zc'), 'include "top.zc";\nfn main() { return fromtop(); }\n', { root: 'file://' + dir })), [], 'found in the workspace folder'); +// as written, from the working directory +assert.deepStrictEqual(errors(check(uri('src/tools/b.zc'), 'include "top.zc";\nfn main() { return fromtop(); }\n', { cwd: dir })), [], 'found from the working directory'); +// an include that is not there: the error is on its string +let d = check(uri('src/tools/c.zc'), 'include "../lib/nothere.zc";\nfn main() { return 0; }\n'); +assert.strictEqual(errors(d).length, 1); +assert.deepStrictEqual(errors(d)[0].range, { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, 'the error is on the string: ' + JSON.stringify(errors(d)[0].range)); +assert.match(errors(d)[0].message, /can't read that include/); +// include std; and the same twice +assert.deepStrictEqual(errors(check(uri('src/tools/d.zc'), 'include std;\ninclude std;\nfn main() { return std.Text.atoi("4"); }\n')), [], 'include std;'); +d = check(uri('src/tools/e.zc'), 'include std\nfn main() { return 0; }\n'); +assert.ok(errors(d).length >= 1, 'include std without a semicolon is an error'); +d = check(uri('src/tools/f.zc'), 'include other;\nfn main() { return 0; }\n'); +assert.match(errors(d)[0].message, /can't read that module include/); +assert.deepStrictEqual(errors(d)[0].range.start, { line: 0, character: 8 }, 'an unknown library is reported at its name'); +// findings inside an included file: hints are left out, an error is shown on the include with where it is +w('src/lib/loud.zc', 'int a() { int unused = 1; return 2 * 2; }\nint b() { return 1 }\n'); +d = check(uri('src/tools/g.zc'), 'include "../lib/loud.zc";\nfn main() { return a(); }\n'); +assert.strictEqual(d.filter(x => x.severity !== 1).length, 0, 'no hints from a library: ' + JSON.stringify(d)); +assert.strictEqual(errors(d).length, 1); +assert.match(errors(d)[0].message, /^in .*loud\.zc, line 2: .*expected ';'/); +assert.deepStrictEqual(errors(d)[0].range.start, { line: 0, character: 8 }); +// a server that cannot read files is sent them +const virtual = '/nowhere/proj/lib/virt.zc'; +const vdoc = 'file:///nowhere/proj/app/main.zc'; +const vtext = 'include "../lib/virt.zc";\ninclude "../lib/gone.zc";\nfn main() { return thrice(2); }\n'; +const files = [[virtual, 'int thrice(int v) { return v * 3; }\n']]; +for (const host of wasm ? ['native', 'wasm'] : ['native']) { + d = check(vdoc, vtext, { files, host, navigation: true }); + assert.strictEqual(errors(d).length, 1, host + ': ' + JSON.stringify(d)); + assert.deepStrictEqual(errors(d)[0].range.start, { line: 1, character: 8 }, host + ': only the missing file is an error'); +} +fs.rmSync(dir, { recursive: true }); +console.log('ok includes: found next to the file, in the workspace and as written; errors on the string; include std; files sent by the client'); diff --git a/tests/drivers/lsp_longevity_test.js b/tests/drivers/lsp_longevity_test.js new file mode 100644 index 0000000..5d450ad --- /dev/null +++ b/tests/drivers/lsp_longevity_test.js @@ -0,0 +1,44 @@ +// repeated document changes must not accumulate unbounded live analysis storage. +const assert = require('assert'); +const cp = require('child_process'); +const host = cp.spawn(process.argv[2], process.argv.slice(3), { stdio: ['pipe', 'pipe', 'pipe'] }); +const cycles = Number(process.env.LSP_CYCLES || 256), uri = 'file:///longevity.zc'; +let buffer = Buffer.alloc(0), errors = '', next = 1; +const pending = new Map(); +const timeout = setTimeout(() => { host.kill(); console.error('FAIL lsp longevity timed out'); process.exitCode = 1; }, 60000); +function send(m) { const b = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...m })); host.stdin.write(Buffer.concat([Buffer.from(`Content-Length: ${b.length}\r\n\r\n`), b])); } +function request(method, params) { const id = next++; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); send({ id, method, params }); }); } +host.stderr.on('data', b => { errors += b; }); +host.stdout.on('data', b => { + buffer = Buffer.concat([buffer, b]); + while (true) { + const e = buffer.indexOf('\r\n\r\n'); if (e < 0) break; + const n = Number(/Content-Length:\s*(\d+)/i.exec(buffer.subarray(0, e).toString())[1]); if (buffer.length < e + 4 + n) break; + const m = JSON.parse(buffer.subarray(e + 4, e + 4 + n)); buffer = buffer.subarray(e + 4 + n); + if (pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); } + if (m.method === 'textDocument/publishDiagnostics') assert.ok(!m.params.diagnostics.some(d => d.severity === 1), JSON.stringify(m.params)); + } +}); +host.on('error', e => { for (const p of pending.values()) p.reject(e); }); +function rss() { const r = cp.spawnSync('ps', ['-o', 'rss=', '-p', String(host.pid)], { encoding: 'utf8' }); assert.strictEqual(r.status, 0); return Number(r.stdout.trim()); } +(async () => { + try { + await request('initialize', {}); + const text = i => `fn square(int n) { return n * n; } fn main() { let answer = square(${i % 16}); return answer; }`; + send({ method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, text: text(0) } } }); + let baseline; + for (let i = 0; i < cycles; i++) { + if (i) send({ method: 'textDocument/didChange', params: { textDocument: { uri, version: i + 1 }, contentChanges: [{ text: text(i) }] } }); + const tokens = await request('textDocument/semanticTokens/full', { textDocument: { uri } }); + assert.ok(tokens.data.length); + if (i === 31 || (cycles <= 32 && i === 0)) baseline = rss(); + if (i % 32 === 0) { + const r = await request('textDocument/definition', { textDocument: { uri }, position: { line: 0, character: text(i).lastIndexOf('answer') } }); assert.ok(r?.length); + } + } + const end = rss(); assert.ok(end - baseline < 96 * 1024, `rss grew by ${end - baseline} KiB`); + send({ method: 'textDocument/didClose', params: { textDocument: { uri } } }); + await request('shutdown', null); send({ method: 'exit' }); host.stdin.end(); + console.log(`ok ${cycles} editor changes with tokens/navigation; post-warmup rss delta ${end - baseline} KiB`); + } finally { clearTimeout(timeout); host.kill(); } +})().catch(e => { console.error(e); console.error(errors); process.exitCode = 1; }); diff --git a/tests/drivers/lsp_test.js b/tests/drivers/lsp_test.js index a783e1e..ab43c41 100644 --- a/tests/drivers/lsp_test.js +++ b/tests/drivers/lsp_test.js @@ -51,7 +51,7 @@ check(nf && nf.error && nf.error.code === -32601, 'unknown request gets MethodNo 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'; +const good = 'fn main() {\n\treturn 1 + 2;\n}\n'; r = run([init(1), open(A, good)]); let d = diags(r.msgs, A); check(d.length === 1 && d[0].params.diagnostics.length === 0, 'a clean program publishes an empty list', d); @@ -64,7 +64,7 @@ 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'; +const uni = '// café 😀\nfn main() { let text = "é😀"; return text[0] + undefined_name; }\n'; r = run([init(1), open(A, uni)]); d = diags(r.msgs, A); const e2 = d.length && d[0].params.diagnostics[0]; @@ -82,7 +82,7 @@ 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 pipeDef = 'include run; immediate fn pipe_op() { run.next(); run.add_binop("|>", 1, "c"); return 0; }\npipe_op\n'; const pipeUse = 'fn double(x) { return x * 2; }\nfn main() { return 4 |> double; }\n'; r = run([init(1), open(A, pipeDef + pipeUse), open(B2, pipeUse)]); check(diags(r.msgs, A)[0].params.diagnostics.filter(x => x.severity === 1).length === 0, 'a parse word that adds an operator is understood'); @@ -108,11 +108,11 @@ function tokens(text) { } 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 meth = 'struct point {\n\tlet x, y;\n\tfn sum() { return this.x + this.y; }\n}\nfn main() {\n\tpoint p;\n\treturn p.sum();\n}\n'; const tk = tokens(meth); check(tk.filter(t => t === 'sum:function').length === 2, 'a method is a function where it is declared and where it is called', tk); check(tk.includes('this:parameter') && tk.includes('x:property'), 'this is a parameter and a field is a property', tk); -check(tk.filter(t => t === 'point:type').length >= 3, 'a user type is a type, also before a postfix star', tk); +check(tk.filter(t => t === 'point:type').length >= 2, 'a user type is a type, also before a postfix star', tk); // a large document goes through the streaming reader let big = ''; @@ -120,7 +120,8 @@ for (let i = 0; i < 2500; i++) big += 'fn f' + i + '(a, b) { return a * b + ' + 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); +// a large check streams what it has found, so there may be several lists; the last is the whole answer +check(d.length >= 1 && d[d.length - 1].params.diagnostics.filter(x => x.severity === 1).length === 0, 'a document of ' + big.length + ' bytes is checked', d.length); // local tables grow past their initial reservation, and the next document still checks let many = 'fn main() {\n'; @@ -141,54 +142,58 @@ const hsrc = '// adds two numbers\nint add(int a, int b) { return a + b; }\nint 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); +const unicodeHover = 'fn main() { let text = "é😀"; int value = 1; return value; }\n'; +hv = hover(unicodeHover, 0, at(unicodeHover, 0, 'value')); +check(hv && hv.includes('(int) let value'), 'hover converts UTF-16 columns after BMP and astral characters', hv); hv = hover(hsrc, 0, hsrc.split('\n')[0].length + 10); check(hv === null, 'an out-of-range hover column cannot select a name on the following line', hv); hv = hover(hsrc, 6, at(hsrc, 6, 'twice')); check(hv && hv.includes('int twice(int a)') && !hv.includes('adds two numbers'), 'a function with no doc still shows its signature', hv); hv = hover(hsrc, 1, at(hsrc, 1, 'a', 2)); -check(hv && hv.includes('parameter') && hv.includes('int a'), 'hover on a parameter shows its declaration', hv); +check(hv && hv.includes('(int) let a // parameter;'), 'hover on a parameter shows its declaration', hv); hv = hover(hsrc, 5, at(hsrc, 5, 'i')); check(hv && hv.includes('let i') && hv.includes('local'), 'hover on a local shows its declaration', hv); hv = hover(hsrc, 6, at(hsrc, 6, 'i', 1)); check(hv && hv.includes('let i') && hv.includes('local'), 'hover on a use of a local shows its declaration', hv); -const tsrc = 'fn main() {\n\tlet i = 3;\n\tlet p = new(int);\n\treturn i;\n}\n'; +const tsrc = 'fn main() {\n\tlet i = 3;\n\tint* p;\n\treturn i;\n}\n'; // an untyped parameter and an untyped function show the types annotate gives them const isrc = 'fn twice(x) { return x * 2; }\nfn main() {\n\tlet n = twice(4);\n\treturn n;\n}\n'; let hi = hover(isrc, 0, 9); -check(hi && hi.includes('x // parameter; type int'), 'an untyped parameter shows its inferred type', hi); +check(hi && hi.includes('(int) let x // parameter;'), 'an untyped parameter shows its inferred type', hi); hi = hover(isrc, 0, 3); check(hi && hi.includes('int twice(int x)'), 'an untyped function shows its inferred parameter and return types', hi); hv = hover(tsrc, 1, 5); -check(hv && hv.includes('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); +check(hv && hv.includes('(int) let i'), 'an untyped number local shows the type annotate gives it', hv); +hv = hover(tsrc, 2, 6); +check(hv && hv.includes('(int*) let p; // local'), 'a pointer local shows its pointer type on its declaration line', hv); hv = hover('fn main() {\n\tlet i = 3;\n\treturn i;\n}\n', 2, 8); check(hv && hv.includes('let i'), 'a return of a local is a use, not a declaration', hv); hv = hover(hsrc, 6, at(hsrc, 6, 'return')); check(hv && hv.includes('leaves the function with a value'), 'a keyword shows what it does', hv); -hv = hover('fn main() {\n\tput_c(32);\n\treturn 0;\n}\n', 1, 1); +const builtinSource = 'include rt; fn main() {\n\trt.put_c(32);\n\treturn 0;\n}\n'; +hv = hover(builtinSource, 1, at(builtinSource, 1, 'put_c')); check(hv && hv.includes('put_c('), 'a built-in function shows its signature', hv); -const kwsrc = 'fn main() {\n\tint* p = new(int);\n\tfree(p);\n\treturn 0;\n}\n'; +const kwsrc = 'include rt; fn main() {\n\tint* p = new(int);\n\trt.free(p);\n\treturn 0;\n}\n'; hv = hover(kwsrc, 1, at(kwsrc, 1, 'new')); -check(hv && hv.includes('new(T)') && hv.includes('fresh object'), 'hover on new shows its form and what it does', hv); +check(hv && hv.includes('new(T)') && hv.includes('fresh heap object') && hv.includes('owns it'), 'hover on new shows its form and ownership behavior', hv); hv = hover(kwsrc, 2, at(kwsrc, 2, 'free')); -check(hv && hv.includes('frees a heap object'), 'hover on free shows its doc', hv); +check(hv && hv.includes('releases a heap object') && hv.includes('consumes an owned object automatically'), 'hover on free shows inferred consumption', hv); const kdoc = '// the number of lines\nfn main() {\n\treturn 0;\n}\n'; hv = hover(kdoc, 2, 1); check(hv === null || !hv.includes('number of lines'), 'a doc comment is not attached to a keyword in a body', hv); // a method of a struct is indented in the body, and its hover is still its line and doc -const msrc = 'struct point {\n\tlet x;\n\t// the sum of the parts\n\tfn sum() { return this.x; }\n\tstatic fn zero() { return 0; }\n}\nfn main() {\n\tpoint* p = new(point);\n\treturn p.sum() + point.zero();\n}\n'; +const msrc = 'struct point {\n\tlet x;\n\t// the sum of the parts\n\tfn sum() { return this.x; }\n\tstatic fn zero() { return 0; }\n}\nfn main() {\n\tpoint p;\n\treturn p.sum() + point.zero();\n}\n'; hv = hover(msrc, 8, at(msrc, 8, 'sum')); check(hv && hv.includes('fn sum()') && hv.includes('the sum of the parts'), 'a method called through a pointer shows its signature and doc', hv); hv = hover(msrc, 8, at(msrc, 8, 'zero')); check(hv && hv.includes('static fn zero()'), 'a static function of a struct shows its signature', hv); +hv = hover(msrc, 4, at(msrc, 4, 'static')); +check(hv && hv.includes('static fn name') && hv.includes('hidden this'), 'static keyword hover explains the declaration form', hv); +check(tokens(msrc).includes('static:keyword'), 'static is highlighted as a keyword', tokens(msrc)); // a typed method shows its return type and parameters -const tmsrc = 'struct point {\n\tlet x;\n\t// the sum of the parts\n\tint sum(int k) { return this.x + k; }\n}\nfn main() {\n\tpoint* p = new(point);\n\treturn p.sum(1);\n}\n'; +const tmsrc = 'struct point {\n\tlet x;\n\t// the sum of the parts\n\tint sum(int k) { return this.x + k; }\n}\nfn main() {\n\tpoint p;\n\treturn p.sum(1);\n}\n'; hv = hover(tmsrc, 7, at(tmsrc, 7, 'sum')); check(hv && hv.includes('int sum(int k)') && hv.includes('the sum of the parts'), 'a typed method shows its return type and parameters', hv); @@ -219,7 +224,8 @@ check(dirty.length === 0, 'every example and test program checks clean (' + clea 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 wantLine = fs.readFileSync('tests/' + dir + '/' + f, 'utf8').split(/\r?\n/).find(line => line.startsWith('// expect: ')); + const want = wantLine ? wantLine.slice('// expect: '.length) : ''; const out = cli('tests/' + dir + '/' + f); n++; if (!out.includes(want)) missed.push(dir + '/' + f + ' wanted [' + want + '] got [' + out.split('\n')[0] + ']'); @@ -236,7 +242,7 @@ 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); +check(hcv && hcv.includes('(int) let cap') && hcv.includes('local'), 'hover on a use of a local named like a built-in shows the local', hcv); hcv = hover(scp, 1, at(scp, 1, 'cap', 1)); check(hcv && hcv.includes('parameter'), 'hover on a use of a parameter named like a built-in shows the parameter', hcv); hcv = hover(scp, 4, at(scp, 4, 'len')); diff --git a/tests/drivers/lsp_vm.js b/tests/drivers/lsp_vm.js index 24c1a5b..e1f5d7a 100644 --- a/tests/drivers/lsp_vm.js +++ b/tests/drivers/lsp_vm.js @@ -79,6 +79,20 @@ const diagsOf = (s, uri) => s.notes.filter(m => m.method === 'textDocument/publi 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); } +// definition navigation returns the embedded runtime source through the same vm host. +{ + const src = 'include rt; fn main() { return rt.s_to_n(b"42"); }\n'; + const s = session([['file:///nav.zc', src]]); + const at = src.indexOf('s_to_n'), before = src.slice(0, at); + s.send({ jsonrpc: '2.0', id: 7, method: 'textDocument/definition', params: { textDocument: { uri: 'file:///nav.zc' }, position: { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 } } }); + s.send({ jsonrpc: '2.0', id: 8, method: 'zc/source', params: { path: '/rt.zc' } }); + s.send({ jsonrpc: '2.0', method: 'exit' }); + const loc = s.replies[7] && s.replies[7].result && s.replies[7].result[0]; + check(loc && loc.uri === 'tinyvm:/rt.zc', 's_to_n opens the embedded runtime source', loc); + const source = s.replies[8] && s.replies[8].result; + const line = source && loc && source.split('\n')[loc.range.start.line]; + check(line && line.slice(loc.range.start.character, loc.range.end.character) === 's_to_n' && source === fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'runtime', 'rt.zc'), 'utf8'), 'the virtual runtime source matches its declaration'); +} // a document that is bigger than one feed: the input is split and comes out whole { let big = 'int main() {\n'; diff --git a/tests/drivers/lsp_wasm.js b/tests/drivers/lsp_wasm.js index 4ff2d16..e49bcc5 100644 --- a/tests/drivers/lsp_wasm.js +++ b/tests/drivers/lsp_wasm.js @@ -79,6 +79,20 @@ const diagsOf = (s, uri) => s.notes.filter(m => m.method === 'textDocument/publi 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); } +// definition navigation returns the embedded runtime source through the wasm host. +{ + const src = 'include rt; fn main() { return rt.s_to_n(b"42"); }\n'; + const s = session([['file:///nav.zc', src]]); + const at = src.indexOf('s_to_n'), before = src.slice(0, at); + s.send({ jsonrpc: '2.0', id: 7, method: 'textDocument/definition', params: { textDocument: { uri: 'file:///nav.zc' }, position: { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 } } }); + s.send({ jsonrpc: '2.0', id: 8, method: 'zc/source', params: { path: '/rt.zc' } }); + s.send({ jsonrpc: '2.0', method: 'exit' }); + const loc = s.replies[7] && s.replies[7].result && s.replies[7].result[0]; + check(loc && loc.uri === 'tinyvm:/rt.zc', 's_to_n opens the embedded runtime source', loc); + const source = s.replies[8] && s.replies[8].result; + const line = source && loc && source.split('\n')[loc.range.start.line]; + check(line && line.slice(loc.range.start.character, loc.range.end.character) === 's_to_n' && source === fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'runtime', 'rt.zc'), 'utf8'), 'the virtual runtime source matches its declaration'); +} // a document that is bigger than one feed: the input is split and comes out whole { let big = 'int main() {\n'; diff --git a/tests/drivers/nav-library.zc b/tests/drivers/nav-library.zc new file mode 100644 index 0000000..55943d0 --- /dev/null +++ b/tests/drivers/nav-library.zc @@ -0,0 +1,2 @@ +struct NavValue { int value; }; +int nav_helper(int x) { return x + 1; } diff --git a/tests/drivers/nav_host_test.js b/tests/drivers/nav_host_test.js new file mode 100644 index 0000000..3ffb563 --- /dev/null +++ b/tests/drivers/nav_host_test.js @@ -0,0 +1,52 @@ +// verify embedded built-in navigation through the extension's vm and wasm hosts. +// usage: node tests/drivers/nav_host_test.js vm|wasm path/to/server +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const mode = process.argv[2]; +const server = path.resolve(process.argv[3]); +const client = path.resolve(__dirname, '..', '..', 'editors', 'vscode', 'client'); +const processHost = mode === 'vm' + ? require(path.join(client, 'vmproc.js')).vmProcess + : mode === 'wasm' + ? require(path.join(client, 'wasmproc.js')).wasmProcess + : null; +assert.ok(processHost, 'host must be vm or wasm'); +const proc = processHost(server, { log: () => {} }); +const replies = new Map(); +let inbox = Buffer.alloc(0); +proc.stdout.on('data', chunk => { + inbox = Buffer.concat([inbox, chunk]); + while (true) { + const end = inbox.indexOf('\r\n\r\n'); + if (end < 0) return; + const size = Number(/Content-Length:\s*(\d+)/i.exec(inbox.subarray(0, end).toString())[1]); + if (inbox.length < end + 4 + size) return; + const message = JSON.parse(inbox.subarray(end + 4, end + 4 + size).toString()); + inbox = inbox.subarray(end + 4 + size); + if (message.id !== undefined) replies.set(message.id, message); + } +}); +function send(message) { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message })); + proc.stdin.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body])); +} +const source = 'include rt; fn main() { return rt.s_to_n(b"42"); }\n'; +const needle = 's_to_n'; +const offset = source.indexOf(needle); +const before = source.slice(0, offset); +const position = { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 }; +send({ id: 1, method: 'initialize', params: { processId: 1, capabilities: {} } }); +send({ method: 'initialized', params: {} }); +send({ method: 'textDocument/didOpen', params: { textDocument: { uri: 'file:///nav.zc', languageId: 'zc', version: 1, text: source } } }); +send({ id: 2, method: 'textDocument/definition', params: { textDocument: { uri: 'file:///nav.zc' }, position } }); +send({ id: 3, method: 'zc/source', params: { path: '/rt.zc' } }); +send({ method: 'exit' }); +const location = replies.get(2)?.result?.[0]; +assert.ok(location, 's_to_n has a definition'); +assert.strictEqual(location.uri, 'tinyvm:/rt.zc'); +const runtime = replies.get(3)?.result; +assert.strictEqual(runtime, fs.readFileSync(path.resolve(__dirname, '..', '..', 'src', 'runtime', 'rt.zc'), 'utf8')); +const line = runtime.split('\n')[location.range.start.line]; +assert.strictEqual(line.slice(location.range.start.character, location.range.end.character), needle); +console.log(`ok ${mode} host opens the embedded runtime source for s_to_n`); diff --git a/tests/drivers/nav_test.js b/tests/drivers/nav_test.js new file mode 100644 index 0000000..23afbf8 --- /dev/null +++ b/tests/drivers/nav_test.js @@ -0,0 +1,75 @@ +// go to definition and find references, in the document and across includes. +// usage: node tests/drivers/nav_test.js path/to/zclsp (run from the repository root) +const assert = require('assert'); +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const server = process.argv[2]; +const root = path.resolve(__dirname, '..', '..'); +const uri = 'file://' + path.join(root, 'tests', 'drivers', 'nav-fixture.zc'); +const frame = m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); }; +function request(text, needle, method, extra = {}) { + const at = text.indexOf(needle); + assert.ok(at >= 0, needle); + const before = text.slice(0, at); + const position = { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 }; + const input = Buffer.concat([ + { 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, params: { textDocument: { uri }, position, ...extra } }, + { jsonrpc: '2.0', method: 'exit' }].map(frame)); + const r = cp.spawnSync(server, process.argv.slice(3), { input, cwd: root, timeout: 30000, maxBuffer: 8 << 20 }); + assert.strictEqual(r.status, 0, String(r.stderr)); + let d = r.stdout, init; + while (d.length) { + const e = d.indexOf('\r\n\r\n'), n = Number(/Content-Length:\s*(\d+)/i.exec(d.subarray(0, e).toString())[1]); + const m = JSON.parse(d.subarray(e + 4, e + 4 + n)); d = d.subarray(e + 4 + n); + if (m.id === 1) init = m.result.capabilities; + if (m.id === 2) return { result: m.result, init }; + } + assert.fail('no reply'); +} +const lines = r => (r || []).map(l => l.range.start.line + ':' + l.range.start.character).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); +const src = 'include "tests/drivers/nav-library.zc";\nstruct pt { int x; int y; };\nint twice(int v) { return v * 2; }\nfn main() {\n\tpt p;\n\tp.x = twice(2);\n\tlet n = p.x + twice(3);\n\tNavValue value;\n\treturn n + p.y + nav_helper(value.value);\n}\n'; +const def = (needle, nth) => request(src, needle, 'textDocument/definition').result; +assert.ok(request(src, 'twice(2', 'textDocument/definition').init.definitionProvider && request(src, 'twice(2', 'textDocument/definition').init.referencesProvider); +assert.deepStrictEqual(lines(def('twice(2')), ['2:4'], 'a function goes to its definition'); +assert.deepStrictEqual(lines(def('p.y')), ['4:4'], 'a local goes to its declaration'); +assert.deepStrictEqual(lines(def('pt p')), ['1:7'], 'a struct goes to its declaration'); +const inc = def('NavValue value'); +assert.ok(inc && inc.length === 1 && inc[0].uri === 'file://' + path.join(root, 'tests', 'drivers', 'nav-library.zc'), 'a type from an include goes to the included file: ' + JSON.stringify(inc)); +const includeText = fs.readFileSync(path.join(root, 'tests', 'drivers', 'nav-library.zc'), 'utf8').split('\n'); +assert.ok(/NavValue/.test(includeText[inc[0].range.start.line].slice(inc[0].range.start.character, inc[0].range.end.character)), 'the range covers the name'); +assert.strictEqual(request(src, 'return', 'textDocument/definition').result, null, 'a keyword has no definition'); +const refs = (needle, includeDeclaration) => request(src, needle, 'textDocument/references', { context: { includeDeclaration } }).result; +assert.deepStrictEqual(lines(refs('twice(2', true)), ['2:4', '5:7', '6:15'], 'references with the declaration'); +assert.deepStrictEqual(lines(refs('twice(2', false)), ['5:7', '6:15'], 'references without the declaration'); +assert.deepStrictEqual(lines(refs('n + p.y', true)), ['6:5', '8:8'], 'a local: declaration and uses'); +const shared = refs('nav_helper(value', true); +assert.ok(shared.some(loc => loc.uri === 'file://' + path.join(root, 'tests', 'drivers', 'nav-library.zc')), 'references include the declaration in another file'); +assert.deepStrictEqual(lines(refs('y; };', true)).length >= 1, true); +// built-ins: the image carries its sources. the runtime, the compiler and (for include std;) the standard library +const std = 'include std, rt;\nfn main() {\n\trt.put_s("hi");\n\tlet n = std.Text.atoi("4");\n\tlet m = rt.s_to_n(b"1");\n\treturn n + m + std.Host.now();\n}\n'; +const bdef = needle => request(std, needle, 'textDocument/definition').result; +const source = name => request(std, 'main', 'zc/source', { path: name }).result; +const slice = (text, loc) => { const l = text.split('\n')[loc.range.start.line]; return l.slice(loc.range.start.character, loc.range.end.character); }; +let loc = bdef('put_s'); +assert.ok(loc && loc.length === 1 && loc[0].uri === 'tinyvm:/rt.zc', 'a runtime function goes to the runtime source: ' + JSON.stringify(loc)); +assert.strictEqual(slice(source('/rt.zc'), loc[0]), 'put_s'); +assert.strictEqual(source('/rt.zc'), fs.readFileSync(path.join(root, 'src', 'runtime', 'rt.zc'), 'utf8'), 'the embedded runtime is the file'); +loc = bdef('Text.atoi'); +assert.ok(loc && loc[0].uri === 'tinyvm:/std.zc', 'a std type goes to the std source'); +assert.strictEqual(slice(source('std.zc'), loc[0]), 'Text'); +loc = bdef('atoi('); +assert.strictEqual(loc[0].uri, 'tinyvm:/std.zc'); +assert.strictEqual(slice(source('/std.zc'), loc[0]), 'atoi', 'a method goes to its own line'); +loc = bdef('now('); +assert.strictEqual(slice(source('/std.zc'), loc[0]), 'now'); +assert.strictEqual(source('/std.zc'), fs.readFileSync(path.join(root, 'src', 'lib', 'std.zc'), 'utf8'), 'the embedded std is the file'); +assert.strictEqual(source('/compiler.zc'), fs.readFileSync(path.join(root, 'src', 'compiler', 'compiler.zc'), 'utf8'), 'the embedded compiler is the file'); +assert.strictEqual(source('/server.zc'), fs.readFileSync(path.join(root, 'src', 'lsp', 'server.zc'), 'utf8'), 'the embedded server is the file'); +loc = bdef('s_to_n('); +assert.ok(loc && loc.length === 1 && loc[0].uri === 'tinyvm:/rt.zc', 's_to_n opens the runtime source: ' + JSON.stringify(loc)); +assert.strictEqual(slice(source('/rt.zc'), loc[0]), 's_to_n'); +assert.strictEqual(source('/nope.zc'), null); +console.log('ok definition and references across the document and includes'); diff --git a/tests/drivers/parser_growth_test.py b/tests/drivers/parser_growth_test.py index 4cbfbb2..9aee57e 100644 --- a/tests/drivers/parser_growth_test.py +++ b/tests/drivers/parser_growth_test.py @@ -22,6 +22,13 @@ 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') +# grabfn owns a single token buffer while repeated growth moves replacements +# into its released slot; the replayed method must survive compilation and gc. +source = 'struct long_method { int seed; int count() { int value = 0;\n' +source += 'value += 1;\n' * 400 +source += 'return value + this.seed; } };\n' +source += 'fn main() { long_method item; item.seed = 7; gc(); put_n(item.count()); put_c(10); return 0; }\n' +run(source, '407\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' diff --git a/tests/drivers/runtime_image_test.py b/tests/drivers/runtime_image_test.py new file mode 100644 index 0000000..c1f4b9c --- /dev/null +++ b/tests/drivers/runtime_image_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""self-hosted run-only images retain enhancements and generic method bodies.""" +import argparse +import subprocess +import tempfile +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--case', default='') +parser.add_argument('--host', default='bin/zy') +parser.add_argument('--mkgenome', default='bin/mkgenome') +args = parser.parse_args() +cases = [ + ('enhancements', 'include rt; fn main() { rt.put_n(17 % 5); rt.put_c(32); rt.put_n(17 / 5); }', b'2 3'), + ('array algorithms', Path('examples/learn/31-array-algorithms.zc').read_text(), Path('examples/learn/31-array-algorithms.out').read_bytes()), + ('owned pop', 'include rt; int*.u take() { int*[?] a = rt.new(int*[?]); int* p = rt.new(int); *p = 8; a.push(p); return a.pop(); } fn main() { let p = take(); rt.put_n(rt.isobj(p)); rt.put_c(32); rt.put_n(*p); }', b'1 8'), +] +prefix = b''.join(Path(f).read_bytes() + b'\n' for f in ['src/runtime/rt.zc', 'src/core/core.zc', 'src/runtime/run.zc']) +for name, source, expected in cases: + if args.case and args.case not in name: + continue + with tempfile.TemporaryDirectory(prefix='tinyvm-image-') as work: + directory = Path(work) + r = subprocess.run([args.host], input=Path('src/tools/build.zc').read_bytes() + b'`' + prefix + source.encode(), capture_output=True, timeout=30) + assert r.returncode == 0 and len(r.stdout) >= 56 and not r.stdout.startswith(b'zc:'), (name, r.stdout[:1000], r.stderr) + header = subprocess.run([args.mkgenome], input=r.stdout, capture_output=True, timeout=5) + assert header.returncode == 0, (name, header.stderr) + (directory / 'genome.h').write_bytes(header.stdout) + for f in ['src/core/memmap.h', 'src/core/fops.h', 'src/core/bops.h', 'src/core/stack.h', 'src/micro/micro.c', 'src/micro/micro_port.h', 'src/micro/port_linux.c']: + (directory / Path(f).name).write_bytes(Path(f).read_bytes()) + subprocess.run(['cc', '-O2', '-w', '-fwrapv', '-pthread', '-DZY_THREADS', str(directory / 'micro.c'), str(directory / 'port_linux.c'), '-I' + work, '-o', str(directory / 'image')], check=True, timeout=30) + r = subprocess.run([str(directory / 'image')], capture_output=True, timeout=10) + assert r.returncode == 0 and r.stdout == expected, (name, r.returncode, r.stdout, r.stderr, expected) + print('ok self-hosted run-only image: ' + name) diff --git a/tests/drivers/sourceprovider_test.js b/tests/drivers/sourceprovider_test.js new file mode 100644 index 0000000..f9551da --- /dev/null +++ b/tests/drivers/sourceprovider_test.js @@ -0,0 +1,31 @@ +// verify the extension's virtual source provider and its language-server request. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { createSourceProvider } = require('../../editors/vscode/client/sourceprovider.js'); + +async function main() { + const calls = []; + const runtime = fs.readFileSync(path.resolve(__dirname, '..', '..', 'src', 'runtime', 'rt.zc'), 'utf8'); + const provider = createSourceProvider(async (method, params) => { + calls.push({ method, params }); + return runtime; + }, () => true); + assert.strictEqual(await provider.provideTextDocumentContent({ path: '/rt.zc' }), runtime); + assert.deepStrictEqual(calls, [{ method: 'zc/source', params: { path: '/rt.zc' } }]); + + const missing = createSourceProvider(async () => null, () => true); + assert.strictEqual(await missing.provideTextDocumentContent({ path: '/missing.zc' }), '// no such built-in source\n'); + const failed = createSourceProvider(async () => { throw new Error('closed'); }, () => true); + assert.strictEqual(await failed.provideTextDocumentContent({ path: '/rt.zc' }), '// the language server could not give this source\n'); + + let readyChecks = 0, delays = 0; + const starting = createSourceProvider(async () => runtime, () => ++readyChecks > 2, async () => { delays++; }); + assert.strictEqual(await starting.provideTextDocumentContent({ path: '/rt.zc' }), runtime); + assert.strictEqual(delays, 2); + const stopped = createSourceProvider(async () => runtime, () => false, async () => {}); + assert.strictEqual(await stopped.provideTextDocumentContent({ path: '/rt.zc' }), '// the language server is not running\n'); + console.log('ok virtual source provider waits for the server and returns embedded source text'); +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/drivers/storage_analysis_test.js b/tests/drivers/storage_analysis_test.js index da967f5..774fa89 100644 --- a/tests/drivers/storage_analysis_test.js +++ b/tests/drivers/storage_analysis_test.js @@ -26,7 +26,7 @@ function check(text, needle) { 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]]) { +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/); @@ -56,16 +56,21 @@ const safe = [ 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]; }', + 'unchecked 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]; }', + prefix + ' let p = new(int[?], 0); push(p, 9); return p[0]; }', ]; for (const source of safe) assert.deepStrictEqual(check(source).warnings, [], source); +const preserved = check('int read(int* p) { return p[0]; }\n' + prefix + ' let p = new(int, 3); read(p); return p[9]; }'); +assert.match(preserved.warnings[0]?.message || '', /index 9.*length 3/); 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/); +const replacement = check('fn main() {\n let owner = new(1), replacement = new(2);\n free(owner); owner = replacement;\n return owner[0];\n}\n', 'replacement;'); +assert.ok(!replacement.diagnostics.some(d => d.severity === 1), JSON.stringify(replacement.diagnostics)); +assert.match(replacement.hover, /moved: replaces a released owner in `owner` on line 3/); console.log('ok definite bounds, free and scope lifetimes, alias hovers, and conservative unknown cases'); diff --git a/tests/drivers/stream_host_test.js b/tests/drivers/stream_host_test.js new file mode 100644 index 0000000..99213e2 --- /dev/null +++ b/tests/drivers/stream_host_test.js @@ -0,0 +1,48 @@ +// the extension's two in-process servers (wasm and the javascript vm) stream partial diagnostics and drop a +// check when the next message is already written. a step of either host is synchronous, so the server +// suspends at its poll and the host lets the event loop deliver input before it goes on. +// usage: node tests/drivers/stream_host_test.js path/to/lsp.wasm path/to/vmserver-dir +const assert = require('assert'); +const path = require('path'); +const client = path.join(__dirname, '..', '..', 'editors', 'vscode', 'client'); +const { wasmProcess } = require(path.join(client, 'wasmproc.js')); +const { vmProcess } = require(path.join(client, 'vmproc.js')); +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 doc = (n, tail) => { let t = ''; for (let i = 0; i < n; i++) t += `int f${i}(int a) { return a + ${i}; }\n`; return t + tail; }; +function run(make, steps) { + return new Promise(resolve => { + const p = make(); + const got = []; + 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; + got.push(JSON.parse(inbox.slice(e + 4, e + 4 + n).toString('utf8'))); + inbox = inbox.slice(e + 4 + n); + } + }); + for (const m of steps) p.stdin.write(frame(m)); + setTimeout(() => { p.stdin.write(frame({ jsonrpc: '2.0', method: 'exit' })); setTimeout(() => resolve(got), 200); }, 4000); + }); +} +const init = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }; +const open = (uri, version, text) => ({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version, languageId: 'zc', text } } }); +const change = (uri, version, text) => ({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version }, contentChanges: [{ text }] } }); +const diags = (got, uri) => got.filter(m => m.method === 'textDocument/publishDiagnostics' && m.params.uri === uri); +(async () => { + const A = 'file:///host-stream.zc'; + const ok = doc(300, 'fn main() { return f0(1); }\n'); + const hosts = [['wasm', () => wasmProcess(path.resolve(process.argv[2]), { log: () => {} })], ['vm', () => vmProcess(path.resolve(process.argv[3]), { log: () => {} })]]; + for (const [name, make] of hosts) { + let got = await run(make, [init, open(A, 1, 'int broken( { }\n' + ok)]); + let d = diags(got, A); + assert.ok(d.length >= 2 && d[0].params.diagnostics.some(x => x.severity === 1), name + ': a large check streams: ' + d.length); + got = await run(make, [init, open(A, 1, ok), change(A, 2, 'fn main() { return 0; }\n')]); + d = diags(got, A); + assert.ok(d.length >= 1 && d.every(m => m.params.version === 2), name + ': the stale check is dropped: ' + JSON.stringify(d.map(m => m.params.version))); + console.log('ok ' + name + ' server streams and drops stale checks'); + } +})(); diff --git a/tests/drivers/stream_test.js b/tests/drivers/stream_test.js new file mode 100644 index 0000000..e783862 --- /dev/null +++ b/tests/drivers/stream_test.js @@ -0,0 +1,55 @@ +// large checks stream partial diagnostics, and a check is dropped when newer input is already waiting. +// usage: node tests/drivers/stream_test.js path/to/zclsp +const assert = require('assert'); +const cp = require('child_process'); +const server = process.argv[2]; +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 doc = (n, tail) => { let t = ''; for (let i = 0; i < n; i++) t += `int f${i}(int a) { return a + ${i}; }\n`; return t + tail; }; +function messages(out) { + const res = []; + let d = out; + while (d.length) { + const e = d.indexOf('\r\n\r\n'), n = Number(/Content-Length:\s*(\d+)/i.exec(d.subarray(0, e).toString())[1]); + res.push(JSON.parse(d.subarray(e + 4, e + 4 + n))); d = d.subarray(e + 4 + n); + } + return res; +} +const published = (msgs, uri) => msgs.filter(m => m.method === 'textDocument/publishDiagnostics' && m.params.uri === uri); +// spawn the server and write each step after the previous one has had time to run (delay in ms) +function session(steps) { + return new Promise(resolve => { + const p = cp.spawn(server, process.argv.slice(3)); + let out = Buffer.alloc(0); + p.stdout.on('data', c => { out = Buffer.concat([out, c]); }); + p.on('exit', () => resolve(messages(out))); + let at = 0; + const next = () => { + if (at >= steps.length) { p.stdin.write(frame({ jsonrpc: '2.0', method: 'exit' })); return; } + const [delay, msgs] = steps[at++]; + p.stdin.write(Buffer.concat(msgs.map(frame))); + setTimeout(next, delay); + }; + next(); + }); +} +const init = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }; +const open = (uri, version, text) => ({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version, languageId: 'zc', text } } }); +const change = (uri, version, text) => ({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version }, contentChanges: [{ text }] } }); +(async () => { + const A = 'file:///stream.zc'; + // a large document with an error at the top: partial lists arrive before the end, the last holds everything + const bad = 'int broken( { }\n' + doc(1500, 'fn main() { return f0(1); }\n'); + let msgs = published(await session([[3000, [init, open(A, 1, bad)]]]), A); + assert.ok(msgs.length >= 2, 'a large check streams several lists: ' + msgs.length); + assert.ok(msgs[0].params.diagnostics.some(d => d.severity === 1), 'the first list already has the error'); + assert.ok(msgs[msgs.length - 1].params.diagnostics.length >= msgs[0].params.diagnostics.length, 'later lists grow'); + // a change that is already waiting drops the stale check: only the newer version is published + const ok = doc(1500, 'fn main() { return f0(1); }\n'); + msgs = published(await session([[3000, [init, open(A, 1, ok), change(A, 2, 'fn main() { return 0; }\n')]]]), A); + assert.ok(msgs.length >= 1 && msgs.every(m => m.params.version === 2), 'the stale version 1 is never published: ' + JSON.stringify(msgs.map(m => m.params.version))); + // an interrupted open of another document is still answered once nothing newer waits + const B = 'file:///other.zc'; + msgs = await session([[3000, [init, open(A, 1, ok), open(B, 1, 'fn main() { return 0; }\n')]]]); + assert.ok(published(msgs, A).length >= 1 && published(msgs, B).length >= 1, 'both documents are published'); + console.log('ok large checks stream partial diagnostics and drop stale checks'); +})(); diff --git a/tests/drivers/thread_context_test.py b/tests/drivers/thread_context_test.py new file mode 100644 index 0000000..fa3f144 --- /dev/null +++ b/tests/drivers/thread_context_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# verify actual threads, independent vm state, parallelism, cancellation and join. +from pathlib import Path +import argparse +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument('vm', nargs='?', default='bin/zy') +parser.add_argument('--case', default='') +parser.add_argument('--library', default='') +parser.add_argument('--backend', choices=('native', 'micro'), default='native') +args = parser.parse_args() +root = Path(__file__).resolve().parents[2] +expected = {'isolation': '21 16 7 16 7 4 1 1\n', 'parallel': '11 21\n', 'cancel': '0\n', 'cancel_o3': '0\n', 'invalid': '-1 -1\n', 'recycle': '66\n', 'channels': '-1 0\n', 'failed_worker': '5 7\n', 'ffi_o3': '1 5050 0\n', 'actor_identity': '1\n', 'resources': '1 24 0 0 0\n', 'close_order': '0 0 -1\n', 'resources_counts': '24 0 0 0\n', 'nested': '1 0 0 0\n'} +count = 0 +for case in sorted((root / 'tests/threads').glob('*/*.zc')): + if args.case and args.case not in str(case): + continue + if args.backend == 'micro' and case.stem in ('ffi_o3', 'resources'): + print(f'skip {case.relative_to(root)} (native ffi fixture)') + continue + source = case.read_text() + if args.library: + source = source.replace('include rt, actor;', f'include rt; include \"{args.library}\"; include actor;') + proc = subprocess.run([str(root / args.vm)], input=source, text=True, capture_output=True, cwd=root, timeout=12) + actual = proc.stdout + if case.stem in ('parallel', 'cancel', 'cancel_o3'): + lines = actual.splitlines(keepends=True) + assert len(lines) == 2, f'{case}: invalid output {actual!r}, stderr={proc.stderr!r}' + milliseconds = int(lines[-1]) + assert 0 <= milliseconds < (580 if case.stem == 'parallel' else 500), f'{case}: elapsed {milliseconds}ms' + actual = lines[0] + assert proc.returncode == 0 and actual == expected[case.stem], f'{case}: exit={proc.returncode}, stdout={proc.stdout!r}, stderr={proc.stderr!r}' + print(f'ok {case.relative_to(root)}') + count += 1 +assert count +print(f'{count} thread context fixtures passed') diff --git a/tests/drivers/toolchain_lsp_contract_test.js b/tests/drivers/toolchain_lsp_contract_test.js new file mode 100644 index 0000000..0d1b515 --- /dev/null +++ b/tests/drivers/toolchain_lsp_contract_test.js @@ -0,0 +1,306 @@ +// pending requirements have explicit protocol oracles, selectable in small groups. +const assert = require('assert'); +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const host = process.argv[2], group = process.env.CONTRACT_GROUP; +const frame = m => { const b = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...m })); return Buffer.concat([Buffer.from(`Content-Length: ${b.length}\r\n\r\n`), b]); }; +function run(text, requests = [], uri = 'file:///contracts.zc', files = []) { + const messages = [{ id: 1, method: 'initialize', params: {} }, ...files.map(([file, content]) => ({ method: 'zc/file', params: { path: file, text: content } })), { method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, text } } }, ...requests.map((r, i) => { + const at = text.indexOf(r.needle) + (r.offset || 0); assert.ok(at >= 0, r.needle); + const before = text.slice(0, at); + return { id: i + 2, method: r.method || 'textDocument/hover', params: { textDocument: { uri }, ...(r.method === 'textDocument/codeAction' ? {} : { position: { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 } }), ...(r.params || {}) } }; + }), { method: 'exit' }]; + let output; + if (host === '--wasm' || host === '--vm') { + const wasm = host === '--wasm'; + const bridge = require(path.resolve('editors/vscode/client', wasm ? 'wasmproc.js' : 'vmproc.js')); + const proc = (wasm ? bridge.wasmProcess : bridge.vmProcess)(path.resolve(process.argv[3]), { log: () => {} }); + const chunks = []; proc.stdout.on('data', b => chunks.push(b)); + for (const message of messages) proc.stdin.write(frame(message)); + output = Buffer.concat(chunks); + } else { + const r = cp.spawnSync(host, process.argv.slice(3), { input: Buffer.concat(messages.map(frame)), timeout: 60000, maxBuffer: 32 << 20 }); + assert.strictEqual(r.status, 0, String(r.stderr)); output = r.stdout; + } + const replies = []; let data = output; + 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]); + assert.ok(data.length >= end + 4 + n); + replies.push(JSON.parse(data.subarray(end + 4, end + 4 + n))); data = data.subarray(end + 4 + n); + } + const ds = replies.filter(r => r.method === 'textDocument/publishDiagnostics').at(-1)?.params.diagnostics || []; + return { ds, result: id => replies.find(r => r.id === id)?.result }; +} +let failures = 0, checks = 0; +function test(g, name, f) { + if (group && group !== g) return; + checks++; + try { f(); console.log('ok ' + g + ': ' + name); } + catch (e) { failures++; console.error('FAIL ' + g + ': ' + name + '\n' + e.message); } +} +for (const [uri, prefix] of [['file:///contracts.zc', ''], ['tinyvm:/fixture.zc', '']]) { + test('hovers', 'word-string global at declaration and use ' + uri, () => { + const text = prefix + 'let hello = "hi"; fn main() { return hello[0]; }'; + const r = run(text, [{ needle: 'hello =' }, { needle: 'hello[0]' }], uri); + for (const id of [2, 3]) { const md = r.result(id)?.contents.value || ''; assert.match(md, /\(char\*\) let hello \/\/ global;/); assert.ok(!md.includes('; type ')); } + }); + test('hovers', 'fixed global array at declaration and use ' + uri, () => { + const r = run('let prog[20000]; fn main() { return prog[0]; }', [{ needle: 'prog[20000]' }, { needle: 'prog[0]' }], uri); + for (const id of [2, 3]) assert.match(r.result(id)?.contents.value || '', /\(word\[20000\]\) let prog \/\/ global;/); + }); +} +test('strings', 'packed declaration, parameter, return and uses keep bstr', () => { + const r = run('fn echo(s) { return s; } fn main() { let hello = b"hi"; let other = echo(hello); return other[0]; }', [{ needle: 'hello =' }, { needle: 'hello);' }, { needle: 'other[0]' }, { needle: 'echo(hello)' }, { needle: 'return s;', offset: 7 }]); + assert.ok(!r.ds.some(d => d.severity === 1), JSON.stringify(r.ds)); + for (const id of [2, 3, 4, 6]) { const md = r.result(id)?.contents.value || ''; assert.match(md, /\(bstr\) let/); assert.ok(!/\(word\)/.test(md)); } + assert.match(r.result(5)?.contents.value || '', /bstr echo\(bstr s\)/); +}); +test('strings', 'grouped word-string local stays char*', () => { + const r = run('fn main() { let n = 1, text = "hi"; return text[0] + n; }', [{ needle: 'text[0]' }]); + assert.match(r.result(2)?.contents.value || '', /\(char\*\) let text/); +}); +test('strings', 'inferred packed return is shown in hover', () => { + const text = 'fn text() { return b"hi"; } fn main() { let result = text(); return result[0]; }'; + const r = run(text, [{ needle: 'text()' }, { needle: 'result = ' }, { needle: 'result[0]' }]); + assert.match(r.result(2)?.contents.value || '', /bstr text\(\)/); + for (const id of [3, 4]) assert.match(r.result(id)?.contents.value || '', /\(bstr\) let result/); +}); +test('strings', 'inferred word-string return is shown in hover', () => { + const text = 'fn text() { return "hi"; } fn main() { let result = text(); return result[0]; }'; + const r = run(text, [{ needle: 'text()' }, { needle: 'result = ' }, { needle: 'result[0]' }]); + assert.match(r.result(2)?.contents.value || '', /char\* text\(\)/); + for (const id of [3, 4]) assert.match(r.result(id)?.contents.value || '', /\(char\*\) let result/); +}); +for (const [name, src, expected, severity = 3] of [ + ['unused parameter', 'fn ignore(int unused) { return 0; }', true], + ['used parameter', 'fn use(int used) { return used; }', false], + ['explicit discard parameter', 'fn ignore(int _) { return 0; }', false], + ['duplicate discard parameters', 'fn ignore(int _, int _) { return 0; }', false], + ['write-only parameter', 'fn write(int unused) { unused = 2; return 0; }', true], + ['shadowed parameter', 'fn shadow(int unused) { { int unused = 2; return unused; } }', true], + ['unused method argument', 'struct point { fn f(int unused) { return 0; } }', true], + ['unused closure argument', 'fn main() { let f = fn:int(int unused) { return 0; }; return f(2); }', true], + ['unused generic argument', 'T first<T>(T used, T unused) { return used; } fn main() { return first<int>(1, 2); }', true], + ['on directive', 'hints on; fn ignore(int unused) { return 0; }', true, 3], + ['small directive', 'hints small; fn ignore(int unused) { return 0; }', true, 4], + ['off directive', 'hints off; fn ignore(int unused) { return 0; }', false] +]) test('arguments', name, () => { + const r = run(src + '\nfn marker() { return 0; }'); + const hints = r.ds.filter(d => /unused.*(argument|parameter)|(?:argument|parameter).*unused/.test(d.message)); + assert.strictEqual(hints.length > 0, expected, JSON.stringify(r.ds)); + if (expected) { assert.strictEqual(hints[0].severity, severity); assert.strictEqual(src.split('\n')[hints[0].range.start.line].slice(hints[0].range.start.character, hints[0].range.end.character), 'unused'); } +}); +for (const [name, src, warning] of [ + ['discard read warns', 'fn f(int _) { return _; }', true], + ['discard assignment is not a read', 'fn f(int _) { _ = 2; return 0; }', false], + ['duplicate payload discards', 'include rt; enum pair { item { int x; int y; }, other }; fn main() { let p = pair.item(1, 2); switch(p) { case item(_, _): return 0; case _: return 1; } }', false], + ['bound payload discard read', 'include rt; enum pair { item { int x; int y; }, other }; fn main() { let p = pair.item(1, 2); switch(p) { case item(_, _): return _; case _: return 1; } }', true], + ['numeric catch-all', 'fn f(int n) { switch(n) { case 1: return 1; case _: return 2; } }', false] +]) test('discards', name, () => { + const r = run(src); + assert.ok(!r.ds.some(d => d.severity === 1), JSON.stringify(r.ds)); + const ds = r.ds.filter(d => /discard.*read|read.*discard/.test(d.message)); + assert.strictEqual(ds.length > 0, warning, JSON.stringify(r.ds)); + for (const d of ds) { assert.strictEqual(d.severity, 2); assert.strictEqual(src.slice(d.range.start.character, d.range.end.character), '_'); } +}); +for (const [name, src, count, severity] of [ + ['uncommented literal', 'fn f(int n) { return n + 0x40ff; }', 1, 3], + ['commented literal', 'fn f(int n) { return n + /* offset in memory */ 0x40ff; }', 0, 3], + ['empty comment escape', 'fn f(int n) { return n + /**/ 0x40ff; }', 0, 3], + ['nearby comment does not hide it', 'fn f(int n) { return /* explanation of n */ n + 0x40ff; }', 1, 3], + ['compact dots', 'hints small; fn f(int n) { return n + 0x40ff; }', 1, 4], + ['off directive', 'hints off; fn f(int n) { return n + 0x40ff; }', 0, 3] +]) test('magic', name, () => { const ds = run(src).ds.filter(d => /magic|numeric literal|number .*has no name/i.test(d.message)); assert.strictEqual(ds.length, count, JSON.stringify(ds)); if (count) { assert.strictEqual(ds[0].severity, severity); assert.match(ds[0].message, /inline block comment/); assert.ok(!ds[0].message.includes('a const says what it means')); } }); +test('navigation', 's_to_n points to its runtime declaration', () => { + const r = run('include rt; fn main() { return rt.s_to_n(b"42"); }', [{ method: 'textDocument/definition', needle: 's_to_n' }]); + const loc = r.result(2)?.[0]; assert.ok(loc, 'missing definition'); assert.strictEqual(loc.uri, 'tinyvm:/rt.zc'); + const line = fs.readFileSync('src/runtime/rt.zc', 'utf8').split('\n')[loc.range.start.line]; + assert.strictEqual(line.slice(loc.range.start.character, loc.range.end.character), 's_to_n'); +}); +for (const [module, file] of [['core', 'core.zc'], ['rt', 'rt.zc']]) test('navigation', 'qualified duplicate names open the correct module: ' + module, () => { + const source = 'include rt, core; fn main() { return ' + module + '.syscall(9,0,0,0); }'; + const r = run(source, [{ needle: 'syscall(', method: 'textDocument/definition' }, { needle: 'syscall(' }]); + const location = r.result(2)?.[0]; + assert.strictEqual(location?.uri, 'tinyvm:/' + file); + const sourcePath = module === 'core' ? 'src/core/core.zc' : 'src/runtime/rt.zc'; + const line = fs.readFileSync(sourcePath, 'utf8').split('\n')[location.range.start.line]; + assert.strictEqual(line.slice(location.range.start.character, location.range.end.character), 'syscall'); + if (module === 'core') assert.match(r.result(3)?.contents.value || '', /int syscall\(int operation/); +}); +test('navigation', 'qualified references retain their resolved module identity', () => { + const source = 'include rt, core; fn main() { core.syscall(9,0,0,0); rt.syscall(9,0,0,0); return core.syscall(9,0,0,0); }'; + const r = run(source, [{ needle: 'core.syscall', offset: 5, method: 'textDocument/references', params: { context: { includeDeclaration: false } } }]); + const references = r.result(2) || []; + assert.strictEqual(references.length, 2); + for (const location of references) assert.ok(source.slice(location.range.start.character, location.range.end.character).includes('syscall')); +}); +test('modules', 'qualified completion exports only imported module', () => { + const r = run('include rt; fn main() { rt.put_', [{ method: 'textDocument/completion', needle: 'rt.put_', offset: 7 }]); + const items = r.result(2)?.items || r.result(2) || []; + assert.ok(items.some(i => i.label === 'put_s')); assert.ok(!items.some(i => i.label === 'evals')); +}); +test('modules', 'lsp exports require an explicit include and qualified name', () => { + const accepted = run('include lsp; fn main() { return lsp.hid(97); }'); + assert.ok(!accepted.ds.some(d => d.severity === 1), JSON.stringify(accepted.ds)); + const missing = run('fn main() { return lsp.hid(97); }'); + assert.ok(missing.ds.some(d => d.severity === 1 && /include lsp/.test(d.message)), JSON.stringify(missing.ds)); + const bare = run('include lsp; fn main() { return hid(97); }'); + assert.ok(bare.ds.some(d => d.severity === 1), JSON.stringify(bare.ds)); +}); +test('modules', 'missing include reports error at qualified member', () => { + const r = run('fn main() { rt.put_s("hi"); return 0; }'); + assert.ok(r.ds.some(d => d.severity === 1 && /include rt/.test(d.message)), JSON.stringify(r.ds)); +}); +for (const [name, description] of [['rt', /runtime/], ['run', /resident compiler/], ['core', /vm host/], ['lsp', /language server/], ['std', /standard library/], ['icon', /syntax transformations/]]) { + test('modulehover', 'module documentation in comma-separated include: ' + name, () => { + const text = 'include rt, run, core, lsp, std, icon; fn main() { return 0; }'; + const r = run(text, [{ needle: name + (name === 'icon' ? ';' : ',') }]); + const hover = r.result(2)?.contents; + assert.strictEqual(hover?.kind, 'markdown'); + assert.match(hover.value, new RegExp('```zc\\ninclude ' + name + ';\\n```')); + assert.match(hover.value, description); + assert.match(hover.value, new RegExp('`' + name + '\\.[^`]+`')); + }); +} +test('modulehover', 'a local with the same name retains its variable hover', () => { + const r = run('fn main() { int rt = 3; return rt; }', [{ needle: 'rt;' }]); + assert.match(r.result(2)?.contents.value || '', /\(int\) let rt/); + assert.ok(!r.result(2).contents.value.includes('include rt;')); +}); +test('modulehover', 'comments and newlines preserve the include list', () => { + const r = run('include /* runtime */ rt,\n /* compiler */ run; fn main() { return 0; }', [{ needle: 'run;' }]); + assert.match(r.result(2)?.contents.value || '', /include run;[\s\S]*resident compiler/); +}); +for (const [name, member] of [['rt', 'put_s'], ['run', 'evals'], ['core', 'syscall'], ['lsp', 'hid'], ['std', 'array_map'], ['icon', 'find_replace']]) { + test('namespacecompletion', 'qualified members, signatures and documentation: ' + name, () => { + const r = run('include rt, run, core, lsp, std, icon; fn main() { ' + name + '.', [{ needle: name + '.', offset: name.length + 1, method: 'textDocument/completion' }]); + const items = r.result(2)?.items || []; + const item = items.find(i => i.label === member); + assert.ok(item, JSON.stringify(items.map(i => i.label))); + assert.ok(item.detail && item.documentation?.value, JSON.stringify(item)); + assert.ok(!items.some(i => i.label === 'main')); + assert.strictEqual(new Set(items.map(i => i.label)).size, items.length); + }); +} +function applyWorkspaceEdit(text, edit, uri) { + const edits = edit.documentChanges?.flatMap(c => c.textDocument.uri === uri ? c.edits : []) || edit.changes?.[uri] || []; + const offset = p => text.split('\n').slice(0, p.line).reduce((n, l) => n + l.length + 1, 0) + p.character; + for (const e of edits.sort((a, b) => offset(b.range.start) - offset(a.range.start))) text = text.slice(0, offset(e.range.start)) + e.newText + text.slice(offset(e.range.end)); + return text; +} +for (const [name, source, needle] of [ + ['qualified runtime import', 'fn main() { rt.put_n(7); }', 'put_n'], + ['qualified compiler import', 'fn main() { run.evals("return 0;"); }', 'evals'], + ['qualified host import', 'fn main() { return core.syscall(9,0,0,0); }', 'syscall'], + ['bare runtime call needs qualification too', 'fn main() { put_n(7); }', 'put_n'], + ['imported module still needs a qualified name', 'include rt; fn main() { put_n(7); }', 'put_n'], + ['namespace and header are preserved', '// file overview\nnamespace demo;\n\n// prints the result\nfn main() { rt.put_n(7); }', 'put_n'], + ['crlf stays crlf', 'namespace demo;\r\nfn main() { rt.put_n(7); }\r\n', 'put_n'], +]) test('quickfix', name, () => { + const uri = 'file:///contracts.zc', before = source.slice(0, source.indexOf(needle)); + const position = { line: before.split('\n').length - 1, character: before.length - before.lastIndexOf('\n') - 1 }; + const r = run(source, [{ needle, method: 'textDocument/codeAction', params: { range: { start: position, end: position }, context: { diagnostics: [] } } }], uri); + assert.ok(r.result(1)?.capabilities.codeActionProvider); + const action = r.result(2)?.find(a => a.kind === 'quickfix' && a.edit); + assert.ok(action, JSON.stringify(r.result(2))); + const changed = applyWorkspaceEdit(source, action.edit, uri); + assert.notStrictEqual(changed, source); + if (source.includes('\r\n')) assert.ok(!/(?<!\r)\n/.test(changed)); + if (source.includes('// prints the result')) assert.ok(changed.includes('// prints the result\nfn main()')); + if (source.startsWith('include rt;')) assert.strictEqual((changed.match(/include rt;/g) || []).length, 1); + const checked = run(changed); + assert.ok(!checked.ds.some(d => d.severity === 1), JSON.stringify({ changed, ds: checked.ds })); +}); +for (const source of ['include rt; fn main() { rt.put_n(7); }', 'fn main() { int put_n = 7; return put_n; }', 'fn main() { return unknown_name; }']) test('quickfix', 'no unrelated import edits: ' + source, () => { + const needle = source.includes('put_n') ? 'put_n' : 'unknown_name'; + const r = run(source, [{ needle, method: 'textDocument/codeAction' }]); + assert.deepStrictEqual(r.result(2), []); +}); +test('qualifiers', 'fixed const array hover preserves both qualifiers and shape', () => { + const r = run('fn main() { fixed const int[2] data = {1,2}; return data[0]; }', [{ needle: 'data[0]' }]); + assert.ok(!r.ds.some(d => d.severity === 1), JSON.stringify(r.ds)); + assert.match(r.result(2)?.contents.value || '', /\(const int\[2\]\) let data/); +}); +test('qualifiers', 'const fixed global hover preserves its type', () => { + const r = run('const fixed int[2] data = {1,2}; fn main() { return data[0]; }', [{ needle: 'data[0]' }]); + assert.match(r.result(2)?.contents.value || '', /\(const int\[2\]\) let data \/\/ global;/); +}); +test('qualifiers', 'readonly qualifiers reject aliasing without optional type checking', () => { + const r = run('typecheck off; fn mutate(int* p) { p[0] = 9; } fn main() { fixed const int[2] data = {1,2}; mutate(data); }'); + assert.ok(r.ds.some(d => d.severity === 1 && /const storage cannot be converted/.test(d.message)), JSON.stringify(r.ds)); +}); +test('inference', 'typed callee constrains caller arguments and returned values', () => { + const source = 'int seed(int cap) { return cap + 1; } fn squares(n) { let l = seed(n); return l; } fn main() { return squares(3); }'; + const r = run(source, [{ needle: 'squares(n)' }, { needle: 'seed(n)', offset: 5 }, { needle: 'return l;', offset: 7 }]); + assert.match(r.result(2)?.contents.value || '', /int squares\(int n\)/); + assert.match(r.result(3)?.contents.value || '', /\(int\) let n/); + assert.match(r.result(4)?.contents.value || '', /\(int\) let l/); +}); +test('inference', 'twelve caller layers converge in reverse declaration order', () => { + let source = ''; + for (let i = 12; i > 0; i--) source += `fn layer${i}(n) { return layer${i - 1}(n); }\n`; + source += 'int layer0(int n) { return n + 1; } fn main() { return layer12(3); }'; + const r = run(source, [{ needle: 'layer12(n)' }, { needle: 'layer7(n)' }, { needle: 'layer1(n)' }]); + for (const [id, name] of [[2, 'layer12'], [3, 'layer7'], [4, 'layer1']]) assert.match(r.result(id)?.contents.value || '', new RegExp('int ' + name + '\\(int n\\)')); +}); +test('inference', 'recursive calls retain an anchored integer return and argument type', () => { + const source = 'int seed(int n) { return n; } fn recursive(n) { if (n <= 0) return seed(n); return recursive(n - 1); } fn main() { return recursive(3); }'; + const r = run(source, [{ needle: 'recursive(n)' }]); + assert.match(r.result(2)?.contents.value || '', /int recursive\(int n\)/); +}); +test('inference', 'uncalled caller parameters inherit typed callee requirements', () => { + const r = run('int seed(int n) { return n; } fn wrapper(n) { return seed(n); }', [{ needle: 'wrapper(n)' }]); + assert.match(r.result(2)?.contents.value || '', /int wrapper\(int n\)/); +}); +test('inference', 'conflicting caller types do not become a single inferred signature', () => { + const r = run('fn identity(value) { return value; } fn main() { identity(3); return identity("text")[0]; }', [{ needle: 'identity(value)' }]); + assert.match(r.result(2)?.contents.value || '', /fn identity\(value\)/); +}); +test('inference', 'unknown reassignment invalidates the previous initializer flow', () => { + const r = run('int seed(int n) { return n; } fn external(); fn changed(n) { let result = seed(n); result = external(); return result; }', [{ needle: 'changed(n)' }]); + assert.ok(!/int changed/.test(r.result(2)?.contents.value || ''), JSON.stringify(r.result(2))); +}); +test('inference', 'owned list allocation constrains capacity and caller types', () => { + const text = 'include rt; .u mklist(cap) { let l = rt.new(cap + 1); l[0] = 0; return l; } .u squares(n) { let l = mklist(n); return l; }'; + const r = run(text, [{ needle: 'mklist(cap)' }, { needle: 'squares(n)' }, { needle: 'mklist(n)', offset: 7 }]); + assert.ok(!r.ds.some(d => d.severity === 1), JSON.stringify(r.ds)); + assert.match(r.result(2)?.contents.value || '', /word\.u mklist\(int cap\)/); + assert.match(r.result(3)?.contents.value || '', /word\.u squares\(int n\)/); + assert.match(r.result(4)?.contents.value || '', /\(int\) let n/); +}); +test('inference', 'array indices do not constrain the type of an element result', () => { + const r = run('include rt; fn lookup(index) { let data = rt.new(4); return data[index]; } fn main() { return lookup(1); }', [{ needle: 'lookup(index)' }]); + assert.ok(!/int lookup/.test(r.result(2)?.contents.value || ''), JSON.stringify(r.result(2))); +}); +test('inference', 'typed fields do not constrain the containing parameter to a scalar', () => { + const r = run('struct point { int x; } fn coordinate(p) { return p.x; }', [{ needle: 'coordinate(p)' }]); + assert.ok(!/coordinate\(int p\)/.test(r.result(2)?.contents.value || ''), JSON.stringify(r.result(2))); +}); +test('quickfix', 'an entire-line request finds the compiler error inside the range', () => { + const source = 'fn main() { rt.put_n(7); }'; + const r = run(source, [{ needle: 'fn', method: 'textDocument/codeAction', params: { range: { start: { line: 0, character: 0 }, end: { line: 0, character: source.length } }, context: { diagnostics: [{ range: { start: { line: 99, character: 99 }, end: { line: 99, character: 99 } }, message: 'stale' }] } } }]); + assert.ok(r.result(2)?.some(a => a.title === 'include rt;'), JSON.stringify(r.result(2))); +}); +test('quickfix', 'an owner-name cursor can import a qualified module', () => { + const source = 'fn main() { rt.put_n(7); }', start = { line: 0, character: source.indexOf('rt.') }; + const r = run(source, [{ needle: 'rt.', method: 'textDocument/codeAction', params: { range: { start, end: start }, context: { diagnostics: [] } } }]); + assert.ok(r.result(2)?.some(a => a.title === 'include rt;')); +}); +test('modulehover', 'source library overview is available without a built-in module entry', () => { + const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'zc-module-hover-')); + try { + fs.mkdirSync(path.join(folder, 'src/lib'), { recursive: true }); + fs.writeFileSync(path.join(folder, 'src/lib/widget.zc'), 'namespace widget;\n\n// converts widget coordinates into grid positions.\n\nint position(int x) { return x; }\n'); + const r = run('include widget; fn main() { return widget.position(3); }', [{ needle: 'widget;' }], 'file://' + path.join(folder, 'main.zc'), [[path.join(folder, 'src/lib/widget.zc'), fs.readFileSync(path.join(folder, 'src/lib/widget.zc'), 'utf8')]]); + assert.ok(!r.ds.some(d => d.severity === 1), JSON.stringify(r.ds)); + assert.match(r.result(2)?.contents.value || '', /converts widget coordinates into grid positions/); + fs.writeFileSync(path.join(folder, 'src/lib/widget.zc'), 'namespace widget;\n\n/** converts grid positions into widget coordinates. */\n\nint position(int x) { return x; }\n'); + const block = run('include widget; fn main() { return widget.position(3); }', [{ needle: 'widget;' }], 'file://' + path.join(folder, 'main.zc'), [[path.join(folder, 'src/lib/widget.zc'), fs.readFileSync(path.join(folder, 'src/lib/widget.zc'), 'utf8')]]); + assert.match(block.result(2)?.contents.value || '', /converts grid positions into widget coordinates/); + } finally { fs.rmSync(folder, { recursive: true, force: true }); } +}); +assert.ok(checks, 'no selected protocol contracts: ' + group); +process.exitCode = failures ? 1 : 0; diff --git a/tests/drivers/tools_contract_test.py b/tests/drivers/tools_contract_test.py new file mode 100644 index 0000000..df02b2b --- /dev/null +++ b/tests/drivers/tools_contract_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""bounded accept/reject tests for standalone build tools and generated images.""" +import argparse +import re +import struct +import subprocess +import tempfile +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--group', choices=['image', 'map', 'runners'], default='image') +args = parser.parse_args() + +def command(argv, data=None): + r = subprocess.run(argv, input=data, capture_output=True, timeout=15) + assert r.returncode >= 0, (argv, r.returncode, r.stderr) + return r + +if args.group == 'image': + source = Path('src/core/genome.h').read_text() + body = source.split('{', 1)[1].split('}', 1)[0] + words = [int(x) for x in re.findall(r'-?\d+', body)] + pack = lambda xs: b''.join(struct.pack('<q', x) for x in xs) + good = command(['bin/mkgenome'], pack(words)) + assert good.returncode == 0 and f'#define GLEN {len(words)}'.encode() in good.stdout + for data in [b'', b'x', pack(words[:6]), pack(words) + b'x']: + r = command(['bin/mkgenome'], data) + assert r.returncode != 0 and b'incomplete' in r.stderr + for index in range(3): + changed = words[:]; changed[index] ^= 2 + r = command(['bin/mkgenome'], pack(changed)) + assert r.returncode != 0 and b'not a genome' in r.stderr + print('ok mkgenome roundtrip, truncation, alignment and map-header mutation contracts') +elif args.group == 'map': + for preset in ['default', 'small', 'run', 'lsp', 'fixed']: + with tempfile.TemporaryDirectory(prefix='tinyvm preset ') as directory: + r = command(['bin/genmap', '--preset', preset, '-o', directory]) + assert r.returncode == 0, (preset, r.stderr) + assert (Path(directory) / 'memmap.h').is_file() + assert command(['bin/genmap', '--preset', 'default', '--check']).returncode == 0 + for options in [['--words', '-1'], ['--heap', '0'], ['--data', '0'], ['--symbols', '-1'], ['--total', '999999999999999999999'], ['--preset', 'imaginary']]: + r = command(['bin/genmap', *options, '--check']) + assert r.returncode != 0, options + with tempfile.TemporaryDirectory(prefix='tinyvm map ') as directory: + r = command(['bin/genmap', '--preset', 'small', '--locals', '32', '--symbols', '64', '-o', directory]) + assert r.returncode == 0, r.stderr + for name in ['memmap.h', 'rt.zc', 'compiler.zc', 'map.txt']: + assert (Path(directory) / name).is_file(), name + print('ok genmap presets, invalid capacities and generated paths containing spaces') +else: + r = command(['bash', '-n', 'tinyvm.sh']) + assert r.returncode == 0, r.stderr + for script in ['src/tools/automap.sh', 'src/tools/mklsp.sh', 'src/tools/mklspwasm.sh', 'src/tools/mkvsix.sh', 'src/tools/embed.sh']: + assert command(['bash', '-n', script]).returncode == 0 + assert command(['bash', 'tinyvm.sh', 'imaginary-command']).returncode != 0 + assert Path('tinyvm.bat').is_file() + assert not list(Path('.').glob('run*.bat')), 'windows run files must be consolidated' + print('ok runner and build script syntax; unsupported commands fail explicitly') diff --git a/tests/drivers/vscode_hover_test.js b/tests/drivers/vscode_hover_test.js index 378dda2..8239afd 100644 --- a/tests/drivers/vscode_hover_test.js +++ b/tests/drivers/vscode_hover_test.js @@ -27,7 +27,7 @@ exports.run = async () => { 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'); + fs.writeFileSync(storageFile, 'include rt; unchecked fn main() {\n let p = rt.new(int, 3);\n let /* borrowed array */ q = p;\n rt.free(p);\n return q[0];\n}\n'); const storageDoc = await vscode.workspace.openTextDocument(storageFile); await vscode.window.showTextDocument(storageDoc); let storageHover; @@ -42,7 +42,24 @@ exports.run = async () => { 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'; + const valueFile = path.join(root, 'build', 'review', 'hover-values.zc'); + fs.writeFileSync(valueFile, 'include rt; fn unknown() { return 9; }\nfn main() {\n int count = 42;\n rt.put_n(count);\n count = unknown();\n return count;\n}\n'); + const valueDoc = await vscode.workspace.openTextDocument(valueFile); + await vscode.window.showTextDocument(valueDoc); + let valueHover; + for (let attempt = 0; attempt < 100; attempt++) { + const replies = await vscode.commands.executeCommand('vscode.executeHoverProvider', valueDoc.uri, new vscode.Position(3, 11)); + valueHover = replies && replies.find(reply => reply.contents.some(md => md.value.includes('value at this position:'))); + if (valueHover) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.ok(valueHover, 'real editor returns a known value before the unknown assignment'); + assert.ok(valueHover.contents[0] instanceof vscode.MarkdownString); + assert.match(valueHover.contents[0].value, /value at this position:\n```zc\n42\n```/); + const unknownHover = await vscode.commands.executeCommand('vscode.executeHoverProvider', valueDoc.uri, new vscode.Position(5, 8)); + assert.ok(unknownHover.length, 'the later variable still has a type hover'); + assert.ok(!unknownHover.some(reply => reply.contents.some(md => md.value.includes('value at this position:')))); + const result = 'ok real vscode markdown, code fences, block docs, inferred types, lifetime metadata, and position-aware values\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 index ddab3be..e4f7ecb 100644 --- a/tests/drivers/wasm_test.js +++ b/tests/drivers/wasm_test.js @@ -13,6 +13,7 @@ const ZC = require('../../src/web/zc.js'); ['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'], + ['tests/programs/replace_owner.zc', '98 7 9 0\n'], ]; let ok = 0, bad = 0; for (const [file, want] of cases) { @@ -24,5 +25,5 @@ const ZC = require('../../src/web/zc.js'); 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); + process.exitCode = bad ? 1 : 0; })(); |
