Version: 0.4.3 Date: 2026-06-11 Language: ReScript 12 (+ @rescript/core) Target Audience: Developers extending or maintaining the parser
This guide covers the V2 parser (src/parser/v2/, syntax v2.3). The V1 pipeline is summarized in Legacy V1 Architecture.
- Introduction
- Repository Layout
- V2 Pipeline Overview
- Grid-Aware Lexer
- Priority Dispatch and the Registry
- Creating a Custom Element Parser
- Heuristics
- Error Recovery
- Layout Inference
- Validator
- Mutability Policy
- Emoji Registry
- Testing
- Performance Targets
- Legacy V1 Architecture
- V1/V2 Migration Path
The V2 parser converts syntax v2.3 ASCII wireframes into an immutable AST. Its core design decisions:
- Grid-aware lexer — tokens carry both a 1D offset and 2D
(row, col)visual coordinates; aGridIndexenables the random column lookups ASCII-art parsing needs. - Heuristics are first-class — every tolerance lives in one
Heuristicsmodule; every heuristic-driven warning carries aruleId. - Single dispatch mechanism —
V2ParserRegistry.tryParsewalks parsers in priority order; disambiguation lives inside each parser'scanParse. - No data duplication in layout —
layoutInfoaddresses children by index range; nodes live exactly once inparent.children. - Bounded error recovery — each recoverable error has a named synchronization rule.
- Narrow mutability —
ParseContextand theTokenStreamcursor are the only mutable surfaces.
- ReScript 12 / @rescript/core basics (variants, records,
option,result) - Vitest (tests are written with
rescript-vitestand plain Vitest)
Authoritative design documents live in .claude/specs/syntax-v2-parser/ (requirements, design, tasks).
src/parser/
├── Core/ # Shared V1 types (Position, Bounds)
├── Semantic/ # V1 parser implementation
├── Detector/ # V1 element detection
├── Errors/ # V1 error handling
├── Interactions/ # V1 Interaction DSL parsing
├── Fixer/ # V1 auto-fix
├── Parser.res # V1 entry point
├── ParserTypes.res # V1 types
└── v2/ # V2 parser (syntax v2.3)
├── V2Parser.res # Entry point: parse / parseWireframe
├── V2Parser.d.ts # Hand-rolled TS declarations
├── types/ # Token, V2Types (AST), V2Errors
├── lexer/ # Scanner, Lexer, TokenStream, GridIndex
├── parser/ # BlockParser, ParseContext, Priority
├── elements/ # 12 element parsers + V2ElementParser + V2ParserRegistry
├── layout/ # LayoutInferrer, RadioGrouper
├── validator/ # Validator (cross-cutting checks)
├── registry/ # EmojiRegistry
├── utils/ # UnicodeUtils, Heuristics, Slugify, EscapeUtils, PositionUtils
└── __tests__/ # V2 test suite
V2Parser.parse runs the following stages:
source string
│
▼
Lexer.tokenize(~tabSize) // Scanner + Lexer: grid-aware tokens
│
▼
GridIndex.make(tokens) // random (row, col) lookups
TokenStream.make(tokens) // sequential cursor with save/restore
│
▼
V2Parser block loop // find next @scene:/@component: header
│ └─ BlockParser.parseHeaderAttrs // @title/@device/@transition/@props
│ └─ BlockParser.parseContent // body rows
│ └─ V2ParserRegistry.tryParse // priority dispatch → element parsers
│
▼
RadioGrouper.assignGroupsRecursive // radio group assignment
LayoutInferrer.inferLayout // Row/Column groups by start row
Validator.validate // cross-cutting checks
│
▼
parseResult { ast, blocks, errors, warnings, success }
Multiple top-level blocks are parsed in a loop; parseContent stops at the next block boundary. In strict mode the first erroring block halts the loop and all errors are promoted to non-recoverable.
Every token carries:
kind(e.g.Plus,Dash,Pipe,LBracket,Text,Whitespace,Newline,EOF)text— the raw lexemeposition—{ row, col, offset }, all 0-based
col is a visual column: East Asian Wide characters and emoji grapheme clusters count as 2, combining marks as 0, and tabs expand per tabSize. All column math routes through utils/UnicodeUtils.res — no other module counts columns directly. LF, CRLF, and standalone CR line endings are normalized by the Scanner.
GridIndex maps (row, col) to tokens, enabling the random lookups Container parsing needs (e.g. "is there a | at column 42 of every body row?", "does the bottom border width match the top?").
A mutable cursor over the token array with peek / next / save / restore / skipToEndOfRow / rewindToRow. Element parsers use save/restore for speculative look-ahead.
V2ParserRegistry owns trial order. tryParse walks parsers in descending priority and dispatches the first whose canParse returns true:
| Priority | Parser | Module |
|---|---|---|
| 115 | String | StringParser |
| 105 | PropPlaceholder | PropPlaceholderParser |
| 100 | Emoji | EmojiParser |
| 95 | Select | SelectParser |
| 90 | Input | V2InputParser |
| 85 | Radio | RadioParser |
| 80 | Checkbox | V2CheckboxParser |
| 70 | Button | V2ButtonParser |
| 60 | Link | V2LinkParser |
| 40–50 | Divider (all forms) | DividerParser |
| 10 | Container | ContainerParser |
| 1 | Text (fallback) | V2TextParser |
Two contracts keep this simple:
canParseis a read-only probe. It must not advance the stream (the registry defensively restores the cursor either way).- Disambiguation lives in each parser, not in the registry. The bracket family (
[ ]) resolves as Select > Input > Checkbox > Button purely through each parser's owncanParselogic; the numeric gaps exist for debuggability, not tie-breaking.
A parse returning None also restores the cursor, letting lower-priority parsers try.
The parser interface (elements/V2ElementParser.res):
type t = {
elementType: V2Types.nodeType,
priority: int,
canParse: TokenStream.t => bool,
parse: (ParseContext.t, TokenStream.t) => option<V2Types.astNode>,
}Example — a ~~~ "wave divider":
// WaveDividerParser.res
let canParse = (stream: TokenStream.t): bool => {
// Probe only — registry restores the cursor afterwards
let tok = TokenStream.peek(stream)
tok.kind == Tilde // simplified
}
let parse = (ctx: ParseContext.t, stream: TokenStream.t): option<V2Types.astNode> => {
let start = TokenStream.position(stream)
// ...consume tokens, or return None to let lower priorities try...
Some(V2Types.DividerNode({
location: {start, end_: TokenStream.position(stream)},
style: V2Types.Normal,
id: None,
label: None,
}))
}
let make = (): V2ElementParser.t =>
V2ElementParser.make(
~elementType=V2Types.Divider,
~priority=42, // between Divider (40) and Divider ID (45)
~canParse,
~parse,
)Register it (see V2Parser.makeRegistry for the built-in registration order):
let reg = V2ParserRegistry.make()
// ...register built-ins...
V2ParserRegistry.register(reg, WaveDividerParser.make()) // sorted by priority automatically
V2ParserRegistry.unregister(reg, V2Types.Divider) // remove by element typeGuidelines:
- Report problems through
ParseContext.addError/addWarning— don't throw. - Heuristic-driven warnings must carry a
ruleId(add the rule toHeuristics.Rule). - Keep recovery row-bounded where possible (see Error Recovery).
- Add boundary tests for any new threshold (just-inside / exact / just-outside).
All tolerances live in utils/Heuristics.res:
let default: t = {
containerColumnTolerance: 1,
containerWidthTolerance: 2,
radioHorizontalGap: 6,
radioVerticalColumnTolerance: 1,
radioMaxBlankRows: 0,
centerSymmetryThreshold: 0.15,
rightAlignThreshold: 0.10,
dividerMinRun: 3,
nearMissTokenDistance: 1,
}- User overrides arrive as
Heuristics.partial(all-optional);applyPartialmerges them overdefault. - Stable rule IDs live in
Heuristics.Rule(e.g.container.wallAlignment,radioGrouping.vertical,text.center,nearMissPatterns,errorRecovery.containerSync). Heuristic warnings reference these so users can trace why a tolerance fired. - Changing a default requires updating the golden test fixtures in the same change (REQ-23.5).
Default mode is recovering: errors are recorded, an ErrorNode (or recovery flag) marks the region, and parsing continues. Named synchronization rules:
| Error | Sync rule |
|---|---|
UnclosedInput |
Row-bounded — recovery never crosses the row |
UnclosedString |
Bounded at EOF/block boundary |
UnclosedContainer |
errorRecovery.containerSync — body rows are not leaked as normal siblings |
NestedBlockDeclaration |
Rewind to the nested header row; reparse as a new top-level block (pipes treated as wall noise, bounded by the outer container's bottom border) |
MaxDepthExceeded |
Offending container parses as empty, containsErrorRecovery: true, siblings continue |
strict: true flips the contract: first error halts parsing and all errors report recoverable: false.
layout/LayoutInferrer.res derives arrangement from element start rows:
- Same start row →
Rowgroup; consecutive different rows →Columngroup. - A container's start row is its top-border row.
- Groups are
{ direction, start, end_, startRow }— index ranges intoparent.children. Never duplicate child nodes. - Spacing affects only the optional
distributionfield, never direction.
layout/RadioGrouper.res runs before layout inference and assigns radio group names using the radio heuristics (vertical runs, same-row proximity, same container).
validator/Validator.res runs once per block after parsing and owns cross-cutting checks only (per-element checks belong in element parsers):
DuplicatePropName— duplicate@propsentries (last wins)DuplicateContainerId— same#idon two containersUnknownPropReference—${name}not declared in@propsMultipleRadiosSelected— more than one(*)in one group
Only two surfaces are mutable:
ParseContext— error/warning accumulators, recovery bookkeeping (e.g.pendingNestedBlockRow).TokenStreamcursor — advanced bynext, checkpointed withsave/restore.
Everything else — tokens, the GridIndex, and all AST records — is immutable after construction. JS-facing entry points defensively normalize partial options (V2Parser.normalizeOptions) so undefined fields from JavaScript callers can't crash ReScript code.
registry/EmojiRegistry.res maps shortcodes to emoji (14 built-ins).
EmojiRegistry.register("rocket", "🚀") // global registration
EmojiRegistry.reset() // restore pristine defaultsPer-parse overrides (parseOptions.emojiRegistry) are consulted first and never mutate global state — lookupWithOverride falls back to the module registry. Defaults are rebuilt per reset() call so a polluted dict can never become the new baseline.
npm test # full suite (V1 + V2)
npm run test:watch
npm run test:coverage- V2 tests live in
src/parser/v2/__tests__/(ReScript,rescript-vitest), includingRegressions*_test.ressuites accumulated from automated review rounds. - TypeScript-facing behavior (package exports,
.d.tscorrectness) is covered by*.test.tsfiles. - Heuristic thresholds require boundary fixtures: just-inside, exact, and just-outside the threshold (REQ-23.4).
- Build with
npm run build(ReScript → TypeScript → typecheck) before running tests on parser changes.
See testing.md for conventions.
Per REQ-19, on the reference platform (Apple Silicon or ≥2.5 GHz x86-64, ≥8 GB RAM, Node ≥20, single-threaded):
| Input | Target (best-of-5 wall time) |
|---|---|
| ≤ 100 lines | < 50ms |
| ≤ 1000 lines | < 500ms |
| Memory | peak heapUsed delta ≤ 10× input bytes |
If a CI machine can't meet the baseline, adjust the threshold in vitest.config with a comment linking to the requirement — never silently relax it.
The V1 parser (src/parser/, main wyreframe export) uses a 3-stage pipeline:
- Grid Scanner — ASCII text → 2D character grid
- Shape Detector — box tracing, nesting/hierarchy construction
- Semantic Parser — pluggable element parsers → V1 AST
V1 additionally owns everything V2 does not yet cover:
- Renderer (
src/renderer/) — AST → DOM with CSS scene transitions - Interaction DSL (
Interactions/) —@click -> goto(...)etc. - Auto-Fix (
Fixer/) — formatting auto-correction (fix,fixOnly)
V1 element parsers register through the V1 registry in Semantic/; the V1 extension workflow mirrors the V2 one but uses grid positions instead of token streams. V1 code is in maintenance mode — prefer building new features against V2.
| Phase | Description | Status |
|---|---|---|
| 1 | V2 parser developed in isolation (src/parser/v2/) |
✅ Done |
| 2 | Side-by-side testing — both parsers, same input, compare | In progress |
| 3 | Runtime feature flag selects V1 or V2 | Planned |
| 4 | V2 default, V1 fallback | Planned |
| 5 | V1 deprecation and removal | Planned |
AST conversion helpers (v2ToV1 / v1ToV2) may be provided for gradual migration; until then the two ASTs are independent types.
- Syntax v2.3 Reference
- API Reference
- Type Definitions
- Design documents:
.claude/specs/syntax-v2-parser/{requirements,design,tasks}.md - Issues: GitHub Issues
Version: 0.4.3 Last Updated: 2026-06-11 License: GPL-3.0