The .cypcb language is read twice. This is the measurement behind choosing
which reader survives, written before any of the work, so the decision can be
checked against numbers rather than taste.
Deleted on 2026-08-07. There is one reader now - cypcb-parser's - and
this section is why. Everything below it is written in the tense of the day the
decision was taken; what changed since is marked as it happened, and the last
section says where the work ended.
cypcb-parser is a tree-sitter grammar with a generated C parser. C does not
reach the browser: the wasm build compiles the crate with
default-features = false, which left it with no parser at all, so
viewer/src/wasm.ts read the DSL a second time in TypeScript - parseSource,
a hand-written line reader of 439 lines.
What that cost was measured, not argued
(viewer/src/__tests__/parser-drift.test.ts, since deleted with the reader it
graded, every board in examples/ through both):
| board | what the browser shows against what the CLI exports |
|---|---|
v2-imports.cypcb |
an empty board; the CLI sees 6 parts on 7 nets |
v2-modules.cypcb |
2 parts under their in-module names on 1 net; the CLI instantiates and gets 7 parts on 5 nets |
v2-interfaces.cypcb |
2 parts and 2 nets out of a body nothing instantiates; the CLI correctly draws nothing |
The third row is the sharpest: the screen shows a board that does not exist. A fourth defect - a pin-to-pin trace drawn in the Gerber and invisible on screen - was fixed in both readers on 2026-08-07, which is what a fix in this shape costs: twice.
Fourteen of the seventeen boards that parse agree on every field the test compares, so this is a gap in what the second reader implements, not in how it reads what it covers.
The build already refuses to ship a wrong-architecture artifact
(cypcb-parser/build.rs checks that the first object starts with \0asm),
so the failure is loud. What it would take to satisfy it:
- our generated
grammar/src/parser.c, and - the tree-sitter runtime itself: 13,647 lines of C across 13 files in
tree-sitter-0.25.10/src, plus 2,204 lines of headers, which the crate compiles for every target.
Both must be built by clang against a wasm sysroot, and the engine targets
wasm32-unknown-unknown, which has no libc. The sysroot that exists is
wasi-libc, for wasm32-wasip1. That mismatch is the real cost of route (a),
and it buys nothing beyond the status quo: the browser would parse, and the
duplicate TypeScript reader would still be there to delete separately.
What a Rust reader has to cover. The sizes moved while this document sat still - every figure below was written in August and every one of them was wrong by the time the reader shipped - so each row carries the command that gives it, and the reading is dated:
| piece | size, 2026-08-27 | how it is counted | fate under (b) |
|---|---|---|---|
grammar/grammar.js |
914 lines, 102 rules | wc -l; grep -oE "^ [a-zA-Z_0-9]+:" | wc -l |
replaced; about a sixth of the rules are lexical (identifier, number, string, units, comments) and become a tokenizer |
src/parser.rs (CST to AST) |
3,541 lines | wc -l |
replaced, not added to - a recursive-descent reader produces the AST directly |
src/ast.rs |
1,500 lines, 53 public types | wc -l; grep -cE "^pub (struct|enum) " |
unchanged; this is the contract |
src/imports.rs |
554 lines | wc -l |
unchanged; it works on the AST |
src/errors.rs |
508 lines | wc -l |
unchanged; miette diagnostics keyed by span |
viewer/src/wasm.ts parseSource |
439 lines | gone; grep -n parseSource viewer/src/wasm.ts finds two comments about its deletion and no code |
deleted |
| tree-sitter C runtime | 13,647 lines of C | as vendored by the crate | gone from the build |
The figures this table carried before - 663 lines and 83 rules of grammar,
3,226 of parser.rs, 1,224 and 47 types of ast.rs, 404 of imports.rs, 485
of errors.rs - are what the same commands gave when the decision was taken.
The language grew by about a third since; the argument did not change, which is
why the decision below still stands.
Nothing consumes the concrete syntax tree. Every caller - cypcb-cli (parse,
check, export, route, score), cypcb-lsp (document.rs calls parse()),
cypcb-render, cypcb-world::sync - takes the AST. The LSP's hover, goto,
completion and diagnostics work off AST spans, so a reader that fills ast.rs
with correct spans serves the tooling unchanged.
Route (b). Not because it is smaller - it is not - but because route (a) leaves the defect table above intact. The browser would still need modules and imports implemented a second time in TypeScript, and every future construct would land twice.
The oracle already exists, which is why this is a project rather than a rewrite:
crates/cypcb-world/tests/language_conformance.rs- one fixture that uses every construct, asserting what reaches the board model.crates/cypcb-cli/tests/the_examples_still_say_what_they_show.rs- every example parses, and the two written to fail still fail.cypcb parse -o astprints the tree-sitter AST as JSON today. A new reader is correct when it prints byte-identical JSON for every example, which is a differential test against the parser being replaced rather than against a hand-written expectation.Done. The file is gone, which is what "stop being needed" meant. What replaced it isviewer/src/__tests__/parser-drift.test.ts- the drift list must reach zero and the file must stop being needed.viewer/src/__tests__/one-reader-of-the-language.test.ts: the drift test graded a second reader, and this one holds that there is not a second one to grade.
Order of work, each step shippable on its own:
-
Tokenizer plus the reader for the constructsDone.parseSourcealready coverssrc/lexer.rsandsrc/reader.rs, behindrust-parser. -
Modules, imports, interfaces and assertionsDone. The differential test covers every example except the two written to fail parsing: 147 definitions across 17 boards, identical. Error parity is pinned separately intests/error_parity.rs. -
Flip the default featureDone.rust-parseriscypcb-parser's default andparseis the reader everywhere: the CLI's five commands, the LSP,cypcb-world::sync, and both builds ofcypcb-render. Nothing in the workspace depends on thetree-sittercrate any more -cargo tree --workspace -i tree-sitterfinds no such package - so a default build compiles no C at all.The grammar,
parser.rsand the generatedparser.cstay behindtree-sitter-parser, because they are what the reader is checked against:differential.rsanderror_parity.rsneed both parsers in one binary. Deleting them would delete the evidence. -
DeleteDone. The viewer reads boards through the engine; the second reader, its helpers and the drift test are gone.parseSourceand the drift test with it
Spans were the open risk, because the LSP is built on them and the differential
test strips them before comparing. They were then measured rather than assumed,
in tests/spans_point_at_the_source.rs: every identifier's span spells the
identifier and every string's span is the quoted literal, checked against the
source text rather than against another parser; and the diagnostic in the same
file reports 147 definitions with identical spans, none different, widest gap
0 bytes. The two parsers agree on boundaries exactly.
What the browser build paid for it, measured on 2026-08-07:
| before | after | |
|---|---|---|
cypcb_render_bg.wasm |
751,995 | 1,044,164 |
| the same, gzipped | 290,004 | 411,147 |
all JS in viewer/dist |
14,791,848 | 14,781,791 |
This is not a size win and should not be sold as one: 292KB of wasm against
10KB of JavaScript. What it buys is that a board using module or import
draws on screen what the CLI exports, which is what the table at the top of
this document says it did not.
The grammar file stays regardless: it is what the editor's syntax highlighting and the LSP's future incremental parsing would use, and it is the readable statement of the language.
# The drift this document is about, board by board
cd viewer && npx vitest run src/__tests__/parser-drift.test.ts
# The sizes quoted above
wc -l crates/cypcb-parser/grammar/grammar.js crates/cypcb-parser/src/*.rs
grep -cE '^pub (struct|enum) ' crates/cypcb-parser/src/ast.rs
wc -l ~/.cargo/registry/src/*/tree-sitter-0.25.10/src/*.c # inside the container
# Who consumes the parser, and what they take
grep -rln 'cypcb_parser::parse\|CypcbParser' crates/*/srcLast verified: 2026-08-07.