aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar zinc <zinc@downsta.ir> 2026-10-14 15:37:28 +0330
committerGravatar zinc <zinc@downsta.ir> 2026-10-14 15:39:03 +0330
commit397b4f5ca283175411677c9e9ee667f647263884 (patch)
tree2cd0c46d2877161ce5504504a0ccaa27b2f41117
parenttodo: record each agent's state at the end of the session and the merge order (diff)
agent/xref-report: unmerged work from previous session
-rw-r--r--Makefile10
-rw-r--r--ONBOARD.md2
-rw-r--r--README.md4
-rw-r--r--src/tools/xref.js446
-rwxr-xr-xsrc/tools/xref.sh17
-rw-r--r--tests/drivers/cli_help_test.py2
6 files changed, 477 insertions, 4 deletions
diff --git a/Makefile b/Makefile
index 75eb058..8779cc3 100644
--- a/Makefile
+++ b/Makefile
@@ -9,6 +9,9 @@
# make test JOBS=4 the same, four sections at a time
# make parity compile the test programs with zc0 and with the self-hosted
# compiler and compare what they print (src/tools/parity.sh)
+# make xref who writes and who reads each global of the compiler
+# (src/tools/xref.sh): build/xref/report.txt and report.json;
+# make xref XREF_ARGS=--check compares with tests/xref.baseline
# make genome build/core/genome.bin from src/, with the fixpoint check
# make build/bin/zyi the interpreter alone (zy has a jit on arm64 and on x86-64 linux)
# make lsp build/bin/zclsp, the language server
@@ -156,6 +159,11 @@ run:
parity: $(BIN)/zc0$(EXE) $(BIN)/mkgenome$(EXE) $(BIN)/zy$(EXE)
bash src/tools/parity.sh
+# the compiler's globals, by writer and reader; the language server supplies the exact
+# scoping. XREF_ARGS passes options: --check (ci), --update (rewrite the baseline).
+xref: $(BIN)/zclsp$(EXE)
+ bash src/tools/xref.sh $(XREF_ARGS)
+
# JOBS sets how many sections run at once: make test JOBS=4
test:
bash tests/test.sh -j $(or $(JOBS),1)
@@ -167,4 +175,4 @@ check:
clean:
rm -rf $(B)
-.PHONY: all lsp genome js lspimage vsix web run parity test check clean
+.PHONY: all lsp genome js lspimage vsix web run parity xref test check clean
diff --git a/ONBOARD.md b/ONBOARD.md
index 47e1cba..35d5e35 100644
--- a/ONBOARD.md
+++ b/ONBOARD.md
@@ -130,6 +130,8 @@ read in this order:
to see the compiler as one text, as the bootstrap does, run
`cat $(sed 's|^|src/compiler/|' src/compiler/parts)`.
+the state is mostly global arrays and scalars that distant functions read and write: run `make xref` before changing compiler state, and read `build/xref/report.txt` for who writes the global and who only reads it (`make xref XREF_ARGS=--check` compares with `tests/xref.baseline`).
+
you should be able to say where a new keyword is registered, how a keyword
works at the top level and inside a function, and how the compiler emits a
byte-string literal into the code stream.
diff --git a/README.md b/README.md
index 6a122db..0644d4e 100644
--- a/README.md
+++ b/README.md
@@ -38,13 +38,13 @@ make js
make run PROG=examples/hello.zc
```
-the cores embed their genome with c23 `#embed`, so the c compiler has to be clang 19 or gcc 15 or later. `src/tools/findcc.sh` finds one (`CC` set on the command line or in the environment is used as given, and must support it too). `make genome` bootstraps the compiler, compares two self-compiled images, and writes `build/core/genome.bin` only after they agree; `make` reruns it whenever a source of the genome changes, and the cores that embed it are rebuilt after it. `make check` runs the smaller suite while iterating. `make parity` (`src/tools/parity.sh`, `--help` for options) compiles the test and example programs with zc0 and with the self-hosted compiler and compares what they print, which the fixpoint check cannot see; programs zc0 rejects are skipped and counted, intended differences are listed in `tests/parity.known`, and work files go to `build/parity/`. `ONBOARD.md` is a reading and verification order for getting to know the code.
+the cores embed their genome with c23 `#embed`, so the c compiler has to be clang 19 or gcc 15 or later. `src/tools/findcc.sh` finds one (`CC` set on the command line or in the environment is used as given, and must support it too). `make genome` bootstraps the compiler, compares two self-compiled images, and writes `build/core/genome.bin` only after they agree; `make` reruns it whenever a source of the genome changes, and the cores that embed it are rebuilt after it. `make check` runs the smaller suite while iterating. `make parity` (`src/tools/parity.sh`, `--help` for options) compiles the test and example programs with zc0 and with the self-hosted compiler and compares what they print, which the fixpoint check cannot see; programs zc0 rejects are skipped and counted, intended differences are listed in `tests/parity.known`, and work files go to `build/parity/`. `make xref` (`src/tools/xref.sh`, `--help` for options) lists, for every top-level variable of the compiler, the functions that write it and the functions that only read it, in `build/xref/report.txt` and `report.json`; the exact scoping comes from the language server's `textDocument/references` on the joined compiler source, so a local that shadows a global is not counted, and a write is an `=`, a compound assignment, `++` or `--`, an indexed store, or `&x`. `src/tools/xref.sh --check` (or `make xref XREF_ARGS=--check`) exits 1 when a global gains a writer that `tests/xref.baseline` does not list, so a change that lets a new function write core compiler state shows up in review; `--update` rewrites the baseline. the language server is interpreted without the arm64 jit, so a run takes minutes. `ONBOARD.md` is a reading and verification order for getting to know the code.
### command-line help
the native vm, portable vm, language server, bootstrap compiler, image checker and map generator accept `--help` or `-h`. help exits before reading input or allocating vm memory, so it also works with stdin left open. `build/bin/zy` reads source from stdin and runs `main`; `build/bin/zym` runs the embedded image, and compiler images can read source from stdin. run-only images read program input directly. `build/bin/zclsp --format`, `--annotate` and `--types` remain available for source on stdin; without one of these options it reads language-server protocol messages. `build/bin/zc0` compiles bootstrap source from stdin to a genome image on stdout; `build/bin/mkgenome` checks an image from stdin against its memory map and copies it to stdout, and when the input is a compiler's error report instead of an image it prints the report. `build/bin/genmap` accepts a preset or compiler/run mode, optional output directory, and build-time map sizing options. the help for each tool lists its input/output format and memory environment variables where applicable.
-the same early help option is available in `tinyvm.sh`, `tinyvm.bat`, `src/tools/zcc`, the public `src/tools/*.sh` build scripts, and the javascript vm and extraction utilities. `src/tools/zcc [-o OUT] [-k] FILE.zc` builds a standalone run-only binary. `src/tools/automap.sh run|small|default|lsp OUTDIR FILE...` generates a sized map (`OUTDIR/memmap.h`) and image (`OUTDIR/genome.bin`). `src/tools/mklsp.sh [OUTDIR]`, `src/tools/mklspwasm.sh [OUTFILE.wasm]` and `src/tools/mkvsix.sh [OUTFILE.vsix]` build the language server and extension. `src/tools/findcc.sh [--wasm]` prints a compiler with `#embed` (and the wasm32 target), and `src/tools/bootstrap-check.sh` runs the first two bootstrap stages and prints the compiler's diagnostics on its own source. the javascript vm accepts an optional source file; without one it reads source from stdin. `src/js/mklayout.js` and `src/js/extract_lsp.js` write the javascript layout and the language server's image from the maps and images under `build/`. `python3 tests/drivers/cli_help_test.py` checks the native, micro, LSP, bootstrap and image tools, shell scripts, and javascript utilities with open stdin and invalid memory settings, rejects unknown options, and confirms the language server source options still work.
+the same early help option is available in `tinyvm.sh`, `tinyvm.bat`, `src/tools/zcc`, the public `src/tools/*.sh` build scripts, and the javascript vm and extraction utilities. `src/tools/zcc [-o OUT] [-k] FILE.zc` builds a standalone run-only binary. `src/tools/automap.sh run|small|default|lsp OUTDIR FILE...` generates a sized map (`OUTDIR/memmap.h`) and image (`OUTDIR/genome.bin`). `src/tools/mklsp.sh [OUTDIR]`, `src/tools/mklspwasm.sh [OUTFILE.wasm]` and `src/tools/mkvsix.sh [OUTFILE.vsix]` build the language server and extension. `src/tools/findcc.sh [--wasm]` prints a compiler with `#embed` (and the wasm32 target), and `src/tools/xref.sh` cross-references the compiler's global variables (see above), `src/tools/bootstrap-check.sh` runs the first two bootstrap stages and prints the compiler's diagnostics on its own source. the javascript vm accepts an optional source file; without one it reads source from stdin. `src/js/mklayout.js` and `src/js/extract_lsp.js` write the javascript layout and the language server's image from the maps and images under `build/`. `python3 tests/drivers/cli_help_test.py` checks the native, micro, LSP, bootstrap and image tools, shell scripts, and javascript utilities with open stdin and invalid memory settings, rejects unknown options, and confirms the language server source options still work.
`make js` writes the javascript vm into `build/js/`: `tinyvm.js`, its `layout.js` and a copy of `genome.bin` that it reads at start-up. it needs node.js and the typescript compiler (`tsc`). running the built `build/js/tinyvm.js` directly needs node.js only.
diff --git a/src/tools/xref.js b/src/tools/xref.js
new file mode 100644
index 0000000..8d99c25
--- /dev/null
+++ b/src/tools/xref.js
@@ -0,0 +1,446 @@
+#!/usr/bin/env node
+// cross-reference of the compiler's global state: which function writes a global and which only read it.
+//
+// the compiler is the files of src/compiler/parts read as one unit. its state is global arrays and scalars
+// that functions far apart read and write. this tool lists, for every top-level variable of the compiler,
+// the functions that write it and the functions that only read it, and can fail ci when a global gains a
+// writer that tests/xref.baseline does not list.
+//
+// how it works:
+// 1. the parts are joined (the same text the language server serves as tinyvm:/compiler.zc) and lexed
+// here; the top-level declarations are found by parsing the lexed text, and so are the brace ranges
+// of the top-level functions.
+// 2. exact scoping is not guessed: the joined text is opened as a document in build/bin/zclsp and one
+// textDocument/references request per global is sent, all in one server run. the server answers with
+// the uses the compiler itself resolved to that symbol, so a local that shadows a global is not
+// counted.
+// 3. each reference is classified from its neighbouring tokens (see classify) and attributed to the
+// function whose range holds it.
+const fs = require('fs');
+const path = require('path');
+const cp = require('child_process');
+const os = require('os');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+
+// ---- lexing ----
+
+const OPS = ['<<=', '>>=', '...', '->', '++', '--', '+=', '-=', '*=', '/=', '%=', '&=', '^=', '|=', '==', '!=', '<=', '>=', '&&', '||', '<<', '>>', '::'];
+const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '^=', '|=', '<<=', '>>=']);
+const IDSTART = /[A-Za-z_]/, IDCHAR = /[A-Za-z0-9_]/;
+
+// tokens: {k: 'id'|'num'|'str'|'chr'|'op', s, off, line, col}; comments are skipped, and a backtick outside
+// a comment or literal ends the source, as it does for the compiler.
+function lex(text) {
+ const toks = [];
+ let i = 0, line = 0, lineStart = 0;
+ const n = text.length;
+ const push = (k, s, off) => toks.push({ k, s, off, line, col: off - lineStart });
+ while (i < n) {
+ const c = text[i];
+ if (c === '\n') { line++; i++; lineStart = i; continue; }
+ if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
+ if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i++; continue; }
+ if (c === '/' && text[i + 1] === '*') {
+ i += 2;
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') { line++; lineStart = i + 1; } i++; }
+ i += 2; continue;
+ }
+ if (c === '`') break;
+ if (c === '"' || c === "'") {
+ const start = i, startLine = line, startCol = i - lineStart;
+ i++;
+ while (i < n && text[i] !== c) { if (text[i] === '\\') i++; if (text[i] === '\n') { line++; lineStart = i + 1; } i++; }
+ i++;
+ toks.push({ k: c === '"' ? 'str' : 'chr', s: text.slice(start, i), off: start, line: startLine, col: startCol });
+ continue;
+ }
+ if (IDSTART.test(c)) { const s = i; while (i < n && IDCHAR.test(text[i])) i++; push('id', text.slice(s, i), s); continue; }
+ if (c >= '0' && c <= '9') {
+ const s = i;
+ // digits, hex, and a fraction: a dot only when a digit follows, so "1.u" style tags are not eaten
+ while (i < n && (IDCHAR.test(text[i]) || (text[i] === '.' && text[i + 1] >= '0' && text[i + 1] <= '9'))) i++;
+ push('num', text.slice(s, i), s); continue;
+ }
+ let op = null;
+ for (const o of OPS) if (text.startsWith(o, i)) { op = o; break; }
+ if (!op) op = c;
+ push('op', op, i);
+ i += op.length;
+ }
+ return toks;
+}
+
+// ---- top-level structure ----
+
+const NOT_DECL = new Set(['const', 'struct', 'enum', 'namespace', 'include', 'embed', 'typedef', 'type', 'enhance', 'fn', 'macro', 'immediate', 'extern', 'cfunc', 'import', 'static', 'quote']);
+
+// finds the top-level functions (name, token range, source range) and the top-level variable declarations.
+// returns {functions, globals, problems}
+function structure(toks) {
+ const functions = [], globals = [], problems = [];
+ let i = 0;
+ const n = toks.length;
+ const closeOf = { '(': ')', '[': ']', '{': '}' };
+ // index of the token matching the opener at i
+ const match = j => {
+ let depth = 0;
+ for (; j < n; j++) {
+ const s = toks[j].k === 'op' ? toks[j].s : '';
+ if (s === '(' || s === '[' || s === '{') depth++;
+ else if (s === ')' || s === ']' || s === '}') { depth--; if (depth === 0) return j; }
+ }
+ return n - 1;
+ };
+ while (i < n) {
+ const start = i;
+ // scan the statement head: up to ';' or '{' at depth 0
+ let j = i, paren = -1;
+ while (j < n) {
+ const t = toks[j];
+ if (t.k === 'op') {
+ if (t.s === ';' || t.s === '{') break;
+ if (t.s === '(' && paren < 0) paren = j;
+ if (t.s === '(' || t.s === '[') { j = match(j) + 1; continue; }
+ }
+ j++;
+ }
+ if (j >= n) break;
+ const first = toks[start];
+ if (toks[j].s === '{') {
+ const end = match(j);
+ const isFn = paren >= 0 && !NOT_DECL.has(first.s) || (first.s === 'fn' && paren >= 0);
+ if (isFn) {
+ // name: the identifier before the first '(' (a method is Type.name)
+ let k = paren - 1, name = toks[k].s;
+ while (k >= 2 && toks[k - 1].s === '.' && toks[k - 2].k === 'id') { name = toks[k - 2].s + '.' + name; k -= 2; }
+ functions.push({ name, startTok: start, endTok: end, from: toks[start].off, to: toks[end].off + 1, line: toks[paren - 1].line });
+ }
+ i = end + 1;
+ if (i < n && toks[i].s === ';') i++;
+ continue;
+ }
+ // a ';' statement
+ const head = toks.slice(start, j);
+ i = j + 1;
+ if (!head.length || NOT_DECL.has(first.s) || first.k !== 'id') continue;
+ const decl = parseDecl(head);
+ if (decl === null) continue;
+ if (decl.error) { problems.push(decl.error + ' at line ' + (first.line + 1)); continue; }
+ for (const d of decl.names) globals.push(d);
+ }
+ return { functions, globals, problems };
+}
+
+// a type (identifier, then * [..] .tag), then declarators: name [dims] [= init] separated by commas.
+function parseDecl(head) {
+ let i = 0;
+ const n = head.length;
+ if (head[i].k !== 'id') return null;
+ i++;
+ for (;;) {
+ const t = head[i];
+ if (!t) return null;
+ if (t.s === '*') { i++; continue; }
+ if (t.s === '.' && head[i + 1] && head[i + 1].k === 'id') { i += 2; continue; }
+ if (t.s === '[') { i = skipGroup(head, i) + 1; continue; }
+ break;
+ }
+ // a type needs a name after it; "a.b c" and "x = 1" and calls are not declarations
+ const names = [];
+ for (;;) {
+ const t = head[i];
+ if (!t || t.k !== 'id') return names.length ? { error: 'unparsed declarator' } : null;
+ names.push({ name: t.s, tok: t });
+ i++;
+ while (head[i] && head[i].s === '[') i = skipGroup(head, i) + 1;
+ if (head[i] && head[i].s === '=') {
+ // initializer: to the next ',' at depth 0
+ i++;
+ while (i < n && head[i].s !== ',') { i = (head[i].s === '(' || head[i].s === '[' || head[i].s === '{') ? skipGroup(head, i) + 1 : i + 1; }
+ }
+ if (i >= n) return { names };
+ if (head[i].s !== ',') return { error: 'unparsed declaration after ' + t.s };
+ i++;
+ }
+}
+function skipGroup(toks, i) {
+ let depth = 0;
+ for (; i < toks.length; i++) {
+ const s = toks[i].k === 'op' ? toks[i].s : '';
+ if (s === '(' || s === '[' || s === '{') depth++;
+ else if (s === ')' || s === ']' || s === '}') { depth--; if (depth === 0) return i; }
+ }
+ return toks.length - 1;
+}
+
+// ---- classification ----
+
+const KEYWORD_BEFORE_OPERAND = new Set(['return', 'else', 'case', 'do', 'in', 'as', 'new', 'sizeof', 'print']);
+
+// is the token at index i the end of an operand (so that a following & is binary)?
+function endsOperand(t) {
+ if (!t) return false;
+ if (t.k === 'num' || t.k === 'str' || t.k === 'chr') return true;
+ if (t.k === 'id') return !KEYWORD_BEFORE_OPERAND.has(t.s);
+ return t.s === ')' || t.s === ']';
+}
+
+// 'w' when the reference at token index i is stored to: the target of = or a compound assignment, the
+// operand of ++ or --, an element or field of it stored the same way, or its address is taken; else 'r'.
+function classify(toks, i) {
+ // the lvalue chain around the name: a.b.name[..].f
+ let a = i;
+ while (a >= 2 && (toks[a - 1].s === '.' || toks[a - 1].s === '->') && toks[a - 2].k === 'id') a -= 2;
+ const before = toks[a - 1], before2 = toks[a - 2];
+ if (before && before.k === 'op') {
+ if (before.s === '++' || before.s === '--') return 'w';
+ if (before.s === '&' && !endsOperand(before2)) return 'w';
+ }
+ let j = i + 1;
+ for (;;) {
+ const t = toks[j];
+ if (!t || t.k !== 'op') break;
+ if (t.s === '[') { j = skipGroup(toks, j) + 1; continue; }
+ if ((t.s === '.' || t.s === '->') && toks[j + 1] && toks[j + 1].k === 'id') { j += 2; continue; }
+ break;
+ }
+ const t = toks[j];
+ if (t && t.k === 'op' && (ASSIGN_OPS.has(t.s) || t.s === '++' || t.s === '--')) return 'w';
+ return 'r';
+}
+
+// ---- the language server ----
+
+const frame = m => { const b = Buffer.from(JSON.stringify(m)); return Buffer.concat([Buffer.from('Content-Length: ' + b.length + '\r\n\r\n'), b]); };
+
+// one server run: open text, ask for the references of every position in `positions` ({line, character}),
+// resolve to the array of location lists, in the same order. the server is spawned, not run synchronously, so
+// several runs can share the work.
+function referencesRun(server, text, positions, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ const uri = 'file:///xref/compiler.zc';
+ const msgs = [
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
+ { jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, version: 1, languageId: 'zc', text } } }];
+ positions.forEach((position, k) => msgs.push({ jsonrpc: '2.0', id: 2 + k, method: 'textDocument/references', params: { textDocument: { uri }, position, context: { includeDeclaration: true } } }));
+ msgs.push({ jsonrpc: '2.0', method: 'exit' });
+ const child = cp.spawn(server, [], { stdio: ['pipe', 'pipe', 'pipe'] });
+ const out = [], err = [];
+ const timer = setTimeout(() => { child.kill(); reject(new Error('language server timed out after ' + Math.round(timeoutMs / 1000) + 's (ZC_TEST_TIMEOUT_SCALE stretches it)')); }, timeoutMs);
+ child.stdout.on('data', c => out.push(c));
+ child.stderr.on('data', c => err.push(c));
+ child.stdin.on('error', () => {});
+ child.on('error', e => { clearTimeout(timer); reject(new Error('language server ' + server + ': ' + e.message)); });
+ child.on('close', status => {
+ clearTimeout(timer);
+ if (status !== 0) return reject(new Error('language server exited with status ' + status + ': ' + Buffer.concat(err).toString().slice(0, 400)));
+ const result = new Array(positions.length).fill(undefined);
+ let d = Buffer.concat(out);
+ while (d.length) {
+ const e = d.indexOf('\r\n\r\n');
+ if (e < 0) break;
+ const len = Number(/Content-Length:\s*(\d+)/i.exec(d.subarray(0, e).toString())[1]);
+ const m = JSON.parse(d.subarray(e + 4, e + 4 + len));
+ d = d.subarray(e + 4 + len);
+ if (m.id >= 2) result[m.id - 2] = m.result || [];
+ }
+ resolve(result);
+ });
+ child.stdin.end(Buffer.concat(msgs.map(frame)));
+ });
+}
+// the requests are dealt out to `jobs` server processes (each checks the document once, about half a
+// minute interpreted); one process per core is about as fast as it gets.
+async function references(server, text, positions, timeoutMs, jobs) {
+ jobs = Math.max(1, Math.min(jobs, positions.length));
+ const slices = Array.from({ length: jobs }, () => []);
+ positions.forEach((p, k) => slices[k % jobs].push(k));
+ const parts = await Promise.all(slices.map(ks => referencesRun(server, text, ks.map(k => positions[k]), timeoutMs)));
+ const out = new Array(positions.length);
+ slices.forEach((ks, j) => ks.forEach((k, n) => { out[k] = parts[j][n]; }));
+ return out;
+}
+
+// ---- the analysis ----
+
+// parts: [{name, text}] in order. returns {globals: [{name, part, line, kind, writes: {fn: n}, reads: {fn: n}}], notes}
+async function analyze(compilerDir, server, timeoutMs, jobs) {
+ const names = fs.readFileSync(path.join(compilerDir, 'parts'), 'utf8').split(/\r?\n/).filter(Boolean);
+ const parts = names.map(name => ({ name, text: fs.readFileSync(path.join(compilerDir, name), 'utf8') }));
+ const text = parts.map(p => p.text).join('');
+ const toks = lex(text);
+ const { functions, globals, problems } = structure(toks);
+ // where each part starts, in lines of the joined text
+ const starts = []; let acc = 0;
+ for (const p of parts) { starts.push(acc); acc += p.text.split('\n').length - 1; }
+ const partOf = line => { let k = starts.length - 1; while (k > 0 && starts[k] > line) k--; return k; };
+ const byPos = new Map(toks.map((t, k) => [t.line + ':' + t.col, k]));
+ const notes = [...problems.map(p => 'declaration not parsed: ' + p)];
+
+ // duplicates would mean one name is declared twice; the second is ignored with a note
+ const seen = new Map(), decls = [];
+ for (const g of globals) {
+ if (seen.has(g.name)) { notes.push('declared twice, the first is used: ' + g.name); continue; }
+ seen.set(g.name, g); decls.push(g);
+ }
+ const answers = await references(server, text, decls.map(g => ({ line: g.tok.line, character: g.tok.col })), timeoutMs, jobs);
+
+ // a function of the token index, by binary search over the sorted, non-overlapping ranges
+ const fnAt = k => {
+ let lo = 0, hi = functions.length - 1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1, f = functions[mid];
+ if (k < f.startTok) hi = mid - 1; else if (k > f.endTok) lo = mid + 1; else return f.name;
+ }
+ return '(top level)';
+ };
+ const result = [];
+ let unmapped = 0, noDecl = [];
+ decls.forEach((g, gi) => {
+ const locs = answers[gi];
+ const entry = { name: g.name, part: parts[partOf(g.tok.line)].name, line: g.tok.line - starts[partOf(g.tok.line)] + 1, writes: {}, reads: {} };
+ const declKey = g.tok.line + ':' + g.tok.col;
+ let sawDecl = false;
+ for (const loc of locs || []) {
+ const key = loc.range.start.line + ':' + loc.range.start.character;
+ if (key === declKey) { sawDecl = true; continue; }
+ const k = byPos.get(key);
+ if (k === undefined || toks[k].s !== g.name) { unmapped++; continue; }
+ const into = classify(toks, k) === 'w' ? entry.writes : entry.reads;
+ const f = fnAt(k);
+ into[f] = (into[f] || 0) + 1;
+ }
+ if (!sawDecl) noDecl.push(g.name);
+ // a function that writes a global is a writer; it is a reader only when it never writes
+ for (const f of Object.keys(entry.writes)) delete entry.reads[f];
+ result.push(entry);
+ });
+ if (noDecl.length) notes.push('the server did not return the declaration for ' + noDecl.length + ' names (not symbols it knows?): ' + noDecl.join(' '));
+ if (unmapped) notes.push(unmapped + ' references did not land on an identifier of that name and were ignored');
+ result.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
+ return { globals: result, notes, functions: functions.length, tokens: toks.length };
+}
+
+// ---- output ----
+
+const sortedKeys = o => Object.keys(o).sort();
+function reportText(res) {
+ const lines = [];
+ for (const g of res.globals) {
+ lines.push(g.name + ' (' + g.part + ':' + g.line + ')');
+ lines.push(' writes: ' + (sortedKeys(g.writes).map(f => f + ' (' + g.writes[f] + ')').join(', ') || '-'));
+ lines.push(' reads: ' + (sortedKeys(g.reads).map(f => f + ' (' + g.reads[f] + ')').join(', ') || '-'));
+ lines.push('');
+ }
+ return lines.join('\n');
+}
+function reportJson(res) {
+ const globals = {};
+ for (const g of res.globals) {
+ const w = {}, r = {};
+ for (const f of sortedKeys(g.writes)) w[f] = g.writes[f];
+ for (const f of sortedKeys(g.reads)) r[f] = g.reads[f];
+ globals[g.name] = { part: g.part, line: g.line, writers: sortedKeys(g.writes), writes: w, readers: sortedKeys(g.reads), reads: r };
+ }
+ return JSON.stringify({ generated_by: 'src/tools/xref.js', globals }, null, 1) + '\n';
+}
+// the baseline: one global per line, the name then its sorted writer functions
+function baselineText(res) {
+ return res.globals.map(g => [g.name, ...sortedKeys(g.writes)].join(' ')).join('\n') + '\n';
+}
+function parseBaseline(text) {
+ const m = new Map();
+ for (const line of text.split(/\r?\n/)) {
+ const w = line.trim().split(/\s+/).filter(Boolean);
+ if (w.length) m.set(w[0], new Set(w.slice(1)));
+ }
+ return m;
+}
+// {added: [[global, fn]], removed: [[global, fn]], newGlobals, goneGlobals}
+function compare(res, base) {
+ const added = [], removed = [], newGlobals = [], goneGlobals = [];
+ const now = new Set(res.globals.map(g => g.name));
+ for (const g of res.globals) {
+ const had = base.get(g.name);
+ if (!had) { newGlobals.push(g.name); }
+ for (const f of sortedKeys(g.writes)) if (!had || !had.has(f)) added.push([g.name, f]);
+ if (had) for (const f of [...had].sort()) if (!g.writes[f]) removed.push([g.name, f]);
+ }
+ for (const name of base.keys()) if (!now.has(name)) goneGlobals.push(name);
+ return { added, removed, newGlobals, goneGlobals };
+}
+
+// ---- command line ----
+
+const USAGE = `usage: src/tools/xref.sh [options]
+cross-reference of the compiler's global variables: for each top-level variable of src/compiler/parts,
+the functions that write it and the functions that only read it. exact scoping comes from the language
+server (textDocument/references), so a local that shadows a global is not counted. a write is the target
+of = or a compound assignment, a ++ or --, an indexed or field store, or an address taken (&x).
+options:
+ (none) write build/xref/report.txt and report.json
+ --check also compare the writers with tests/xref.baseline: exit 1 and print the difference when
+ a global gains a writer the baseline does not list (a new reader is fine; a removed
+ writer is only noted)
+ --update rewrite tests/xref.baseline from the current writers
+ --server PATH language server to use (default build/bin/zclsp; build it with make)
+ --compiler-dir DIR compiler parts directory (default src/compiler)
+ --baseline FILE baseline file (default tests/xref.baseline)
+ --out DIR directory for the reports (default build/xref)
+ --jobs N language server processes to share the work (default: one per core, or XREF_JOBS)
+ --help, -h show this help and exit
+exit status: 0 ok, 1 --check found a new writer, 2 bad usage, 3 the analysis failed.
+the language server is interpreted on hosts without the arm64 jit, so a run takes tens of seconds.`;
+
+async function main(argv) {
+ const opt = { check: false, update: false, server: path.join(ROOT, 'build', 'bin', 'zclsp'), dir: path.join(ROOT, 'src', 'compiler'), baseline: path.join(ROOT, 'tests', 'xref.baseline'), out: path.join(ROOT, 'build', 'xref'), jobs: Number(process.env.XREF_JOBS) || Math.min(os.cpus().length, 8) };
+ const needs = (k) => { if (k + 1 >= argv.length) { process.stderr.write('xref: ' + argv[k] + ' needs an argument; use --help for usage\n'); process.exit(2); } return argv[k + 1]; };
+ for (let k = 0; k < argv.length; k++) {
+ const a = argv[k];
+ if (a === '--help' || a === '-h') { process.stdout.write(USAGE + '\n'); return 0; }
+ else if (a === '--check') opt.check = true;
+ else if (a === '--update') opt.update = true;
+ else if (a === '--server') opt.server = path.resolve(needs(k++));
+ else if (a === '--compiler-dir') opt.dir = path.resolve(needs(k++));
+ else if (a === '--baseline') opt.baseline = path.resolve(needs(k++));
+ else if (a === '--out') opt.out = path.resolve(needs(k++));
+ else if (a === '--jobs') { opt.jobs = Number(needs(k++)); if (!(opt.jobs >= 1)) { process.stderr.write('xref: --jobs needs a positive number\n'); return 2; } }
+ else { process.stderr.write('xref: unknown option ' + a + '; use --help for usage\n'); return 2; }
+ }
+ if (opt.check && opt.update) { process.stderr.write('xref: --check and --update exclude each other\n'); return 2; }
+ let res;
+ try {
+ if (!fs.existsSync(opt.server)) throw new Error('no language server at ' + opt.server + ' (run make)');
+ const scale = Number(process.env.ZC_TEST_TIMEOUT_SCALE) || 1;
+ res = await analyze(opt.dir, opt.server, 3600000 * scale, opt.jobs);
+ } catch (e) { process.stderr.write('xref: ' + e.message + '\n'); return 3; }
+ fs.mkdirSync(opt.out, { recursive: true });
+ fs.writeFileSync(path.join(opt.out, 'report.txt'), reportText(res));
+ fs.writeFileSync(path.join(opt.out, 'report.json'), reportJson(res));
+ for (const n of res.notes) process.stderr.write('xref: note: ' + n + '\n');
+ const rel = p => path.relative(process.cwd(), p) || p;
+ process.stdout.write('xref: ' + res.globals.length + ' globals, ' + res.functions + ' functions; wrote ' + rel(path.join(opt.out, 'report.txt')) + ' and report.json\n');
+ if (opt.update) {
+ fs.writeFileSync(opt.baseline, baselineText(res));
+ process.stdout.write('xref: wrote ' + rel(opt.baseline) + '\n');
+ return 0;
+ }
+ if (opt.check) {
+ let base;
+ try { base = parseBaseline(fs.readFileSync(opt.baseline, 'utf8')); } catch (e) { process.stderr.write('xref: cannot read the baseline ' + opt.baseline + ' (use --update)\n'); return 3; }
+ const d = compare(res, base);
+ for (const [g, f] of d.removed) process.stdout.write('note: ' + g + ' is no longer written by ' + f + '\n');
+ for (const g of d.goneGlobals) process.stdout.write('note: global ' + g + ' is gone\n');
+ if (d.added.length) {
+ process.stdout.write('FAIL: new writers of compiler globals (a new reader is fine; review these, then run src/tools/xref.sh --update):\n');
+ for (const [g, f] of d.added) process.stdout.write(' ' + g + ' is now written by ' + f + (d.newGlobals.includes(g) ? ' (new global)' : '') + '\n');
+ return 1;
+ }
+ process.stdout.write('xref: check ok, no new writers against ' + rel(opt.baseline) + '\n');
+ }
+ return 0;
+}
+
+if (require.main === module) main(process.argv.slice(2)).then(c => { process.exitCode = c; });
+else module.exports = { lex, structure, classify, analyze, main };
diff --git a/src/tools/xref.sh b/src/tools/xref.sh
new file mode 100755
index 0000000..b014dae
--- /dev/null
+++ b/src/tools/xref.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+# cross-reference of the compiler's global state: who writes each global, who only reads it.
+# a shell wrapper around src/tools/xref.js (node). run `src/tools/xref.sh --help` for the options.
+# needs build/bin/zclsp (make): exact scoping comes from the language server's references.
+set -e
+here=$(cd "$(dirname "$0")" && pwd)
+if ! command -v node >/dev/null 2>&1; then
+ case "$1" in
+ --help|-h)
+ echo "usage: src/tools/xref.sh [--check|--update] [--server PATH] [--compiler-dir DIR] [--baseline FILE] [--out DIR] [--jobs N]"
+ echo "(node is required to run it)"
+ exit 0 ;;
+ esac
+ echo "xref: node is required" >&2
+ exit 3
+fi
+exec node "$here/xref.js" "$@"
diff --git a/tests/drivers/cli_help_test.py b/tests/drivers/cli_help_test.py
index 1231f74..af3df2e 100644
--- a/tests/drivers/cli_help_test.py
+++ b/tests/drivers/cli_help_test.py
@@ -14,7 +14,7 @@ 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/findcc.sh', 'src/tools/bootstrap-check.sh', 'src/tools/parity.sh', 'src/tools/mklsp.sh', 'src/tools/mklspwasm.sh', 'src/tools/mkvsix.sh', 'src/web/build.sh', 'src/baremetal/run.sh']]
+ commands += [['bash', name] for name in ['tinyvm.sh', 'src/tools/zcc', 'src/tools/automap.sh', 'src/tools/findcc.sh', 'src/tools/bootstrap-check.sh', 'src/tools/parity.sh', 'src/tools/xref.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 [str(Path(args.js_dir) / 'tinyvm.js'), 'src/js/mklayout.js', 'src/js/extract_lsp.js']]