Builds a lossless, typed syntax tree over circom source using
rowan. It consumes the flat event stream produced by the
parser crate and exposes both a generic SyntaxNode tree and a typed AST.
"Lossless" means whitespace, comments, and even parse errors are preserved in the tree, so it round-trips to the original source exactly.
crates/syntax/src/
├── lib.rs # crate root
├── node.rs # CircomLanguage, SyntaxNode/Token aliases
├── tree.rs # syntax_tree() / build_green(): events -> rowan green tree
├── tree/test_utils.rs # view_ast() snapshot helper
├── abstract_syntax_tree/ # typed AST nodes (AstNode impls)
│ ├── mod.rs
│ ├── program.rs
│ ├── definition.rs
│ ├── declaration.rs
│ ├── statement.rs
│ ├── expression.rs
│ ├── block.rs
│ └── name.rs
├── test_files/ # circom fixtures (happy/ + error_recovery/)
└── snapshots/ # committed insta snapshots
rowan is parameterized by a Language. CircomLanguage binds rowan's SyntaxKind to the
parser's TokenKind (a #[repr(u16)] enum), with a compile-time
size_of::<TokenKind>() == 2 assertion guaranteeing the transmute in kind_from_raw is
sound. The module then defines the conventional aliases: SyntaxNode, SyntaxToken,
SyntaxElement, SyntaxNodeChildren, PreorderWithTokens.
syntax_tree(source)tokenizes, parses as a whole circom program, and builds the tree.syntax_node_from_source(source, scope)parses starting from a specific entryScope(used by targeted grammar tests).build_green(tokens, events, builder)drives arowan::GreenNodeBuilderstraight from the parser's event stream (Open/Close/Token/ErrorReport).
Design points worth knowing:
- Fresh
NodeCacheper parse — identical tokens/subtrees are deduplicated within one parse, but nothing accumulates across parses. A process-global cache would leak every distinct identifier ever parsed for the whole server lifetime (unbounded in a long LSP session); a per-parse cache bounds memory to one parse's working set. - Defense-in-depth against malformed streams —
build_greenis robust against a strayClose, an unclosedOpen, or a stream with no rootOpen, soGreenNodeBuilder::finish(which asserts exactly one top-level node) never panics even on a grammar bug. An empty stream yields an emptyParserErrorroot. - Error nodes are zero-width —
Event::ErrorReport(msg)becomes a childlessErrornode. The message is diagnostic metadata, not source text: emitting it as a token would make rowan size the node by the message length and break offset/range math.has_error/parses_cleankey off the node kind, not text. - Tokens are wrapped — each consumed token becomes a single-child node of the same kind;
this wrapping is load-bearing for
AstNode::cast.
The generic SyntaxNode tree is lossless but awkward to walk. The AST modules add typed
wrappers (AstCircomProgram, AstTemplateDef, AstSignalDecl, AstComponentDecl,
AstIdentifier, etc.) that implement rowan's AstNode trait, so consumers (the LSP
resolver and symbol_table) navigate the tree ergonomically via cast and accessor
methods rather than raw kind() checks.
- Fixtures live in
test_files/happy/(valid) andtest_files/error_recovery/(malformed). - The
test_syntax!(path, scope)macro parses a fixture and snapshots both the tree and an AST view viaview_ast(). - Snapshots are committed under
snapshots/. - Error-recovery tests pin behavior on missing
;, unclosed{, and stray top-level tokens. build_green_testssynthesize raw event streams to assertbuild_greennever panics on malformed input.
Run them with cargo test -p syntax.