Version: 0.4.3 Date: 2025-12-27 Language: ReScript Target Audience: Developers extending or maintaining the parser
- Introduction
- 3-Stage Architecture Overview
- Understanding the Grid Scanner
- Understanding the Shape Detector
- Understanding the Semantic Parser
- Creating Custom Element Parsers
- Extension Points
- Error System Extension
- Testing Your Extensions
- Performance Considerations
- Common Patterns and Best Practices
The Wyreframe parser is designed with extensibility as a core principle. This guide will help you understand the architecture and show you how to extend the parser to support new element types, custom validation rules, and enhanced error messages.
- How the 3-stage pipeline processes ASCII wireframes
- How to create custom element parsers
- Where and how to extend the parser for your needs
- Best practices for testing and performance
- Basic ReScript knowledge
- Familiarity with functional programming concepts
- Understanding of AST (Abstract Syntax Tree) concepts
The Wyreframe parser uses a systematic 3-stage pipeline that separates concerns and enables clear, maintainable code.
graph TB
A[ASCII Input] --> B[Stage 1: Grid Scanner]
B --> C[Grid Data Structure]
C --> D[Stage 2: Shape Detector]
D --> E[Shape Hierarchy]
E --> F[Stage 3: Semantic Parser]
F --> G[AST Output]
style B fill:#e1f5ff
style D fill:#fff4e1
style F fill:#e8f5e8
Purpose: Convert linear ASCII text into a searchable 2D grid.
Input: String (ASCII wireframe) Output: Grid data structure with position indices
Key Responsibilities:
- Normalize line endings and widths
- Build 2D character array with (row, col) addressing
- Create indices for special characters (
+,-,|,=) - Enable position-based navigation (scanRight, scanDown, etc.)
Why This Matters: The Grid provides spatial awareness that regex-based parsing lacks. You can ask questions like "what's to the right of this position?" or "trace a line until you hit a corner."
Purpose: Recognize geometric shapes and their spatial relationships.
Input: Grid data structure Output: Hierarchy of boxes with bounds and nesting information
Key Responsibilities:
- Trace box boundaries starting from corner characters
- Extract box names from top borders
- Detect horizontal dividers (lines with
=) - Build parent-child hierarchy based on spatial containment
- Validate box structure (matching widths, aligned edges)
Why This Matters: The Shape Detector provides structural understanding. It knows which boxes are nested inside others, enabling the semantic parser to understand context.
Purpose: Interpret box contents and build the final AST.
Input: Grid + Shape hierarchy Output: AST with typed elements (buttons, inputs, links, etc.)
Key Responsibilities:
- Parse box contents line by line
- Recognize UI elements using pluggable parsers
- Calculate element alignment (left/center/right)
- Build scene structure from directives
- Generate complete AST
Why This Matters: The Semantic Parser is where extensibility shines. Adding new element types doesn't require changing the Grid or Shape detection logic.
type t = {
cells: array<array<cellChar>>,
width: int,
height: int,
cornerIndex: array<Position.t>, // All '+' positions
hLineIndex: array<Position.t>, // All '-' positions
vLineIndex: array<Position.t>, // All '|' positions
dividerIndex: array<Position.t>, // All '=' positions
}// Get character at position
let char = Grid.get(grid, Position.make(5, 10))
// Get entire line
let line = Grid.getLine(grid, 5)
// Get range of characters
let range = Grid.getRange(grid, row: 5, ~startCol=10, ~endCol=20)// Scan right until predicate fails
let results = Grid.scanRight(grid, startPos, cell => {
switch cell {
| HLine | Corner => true // Continue scanning
| _ => false // Stop scanning
}
})
// Result: array<(Position.t, cellChar)>// Find all corners in O(1) time (using prebuilt index)
let corners = grid.cornerIndex
// Find all corners within a specific box
let cornersInBox = Grid.findInRange(grid, Corner, boxBounds)- Character lookup: Use
get()when you need a specific position - Line processing: Use
getLine()for entire rows - Directional tracing: Use
scan*()functions for boundary tracing - Bulk searches: Use indices for finding all instances of a character
The core algorithm traces boxes by following their boundaries:
let traceBox = (grid: Grid.t, topLeft: Position.t): Result.t<box, traceError> => {
// 1. Verify starting position is a corner '+'
// 2. Scan right along top edge
let topEdge = Grid.scanRight(grid, topLeft, isTopEdgeChar)
// 3. Extract box name if present
let name = extractBoxName(topEdge)
// 4. Scan down along right edge
let rightEdge = Grid.scanDown(grid, topRight, isRightEdgeChar)
// 5. Scan left along bottom edge
let bottomEdge = Grid.scanLeft(grid, bottomRight, isBottomEdgeChar)
// 6. Validate width match
if topWidth != bottomWidth {
return Error(MismatchedWidth({...}))
}
// 7. Scan up along left edge
let leftEdge = Grid.scanUp(grid, bottomLeft, isLeftEdgeChar)
// 8. Verify we returned to start position
// 9. Create and return box
Ok({
name,
bounds: Bounds.make(~top, ~left, ~bottom, ~right),
children: []
})
}let buildHierarchy = (boxes: array<box>): Result.t<array<box>, hierarchyError> => {
// Sort boxes by area (largest first)
let sorted = boxes->Belt.SortArray.stableSortBy((a, b) =>
compare(Bounds.area(b.bounds), Bounds.area(a.bounds))
)
// For each box, find its smallest containing parent
sorted->Belt.Array.forEach(box => {
let parent = sorted->Belt.Array.getBy(candidate =>
candidate !== box &&
Bounds.contains(candidate.bounds, box.bounds)
)
switch parent {
| Some(p) => p.children->Js.Array2.push(box)->ignore
| None => () // Root box
}
})
// Return only root boxes (no parent)
Ok(sorted->Belt.Array.keep(box => box.parent == None))
}- Spatial containment determines hierarchy, not parsing order
- Smallest containing box is the immediate parent
- Overlapping boxes (neither contains the other) are errors
- All errors are collected before returning (no early stopping)
The Semantic Parser uses a priority-based registry to recognize different element types:
type ElementParser.t = {
priority: int, // Higher = checked first
canParse: string => bool, // Quick pattern check
parse: (string, Position.t, Bounds.t) => parseResult // Full parsing
}let parse = (registry: ParserRegistry.t, content: string, position: Position.t, bounds: Bounds.t) => {
// Iterate parsers in priority order
let rec tryParsers = (parsers: array<ElementParser.t>) => {
switch parsers {
| [] => Text({content, position, align: Left, emphasis: false}) // Fallback
| [parser, ...rest] =>
if parser.canParse(content) {
switch parser.parse(content, position, bounds) {
| Some(element) => element
| None => tryParsers(rest) // Parser declined, try next
}
} else {
tryParsers(rest)
}
}
}
tryParsers(registry.parsers)
}let calculate = (content: string, position: Position.t, boxBounds: Bounds.t): alignment => {
let trimmed = content->Js.String2.trim
let contentStart = position.col
let contentEnd = contentStart + String.length(trimmed)
// Calculate space on left and right
let boxLeft = boxBounds.left + 1 // +1 to skip border
let boxRight = boxBounds.right - 1 // -1 to skip border
let boxWidth = boxRight - boxLeft
let leftSpace = contentStart - boxLeft
let rightSpace = boxRight - contentEnd
// Calculate ratios
let leftRatio = float_of_int(leftSpace) /. float_of_int(boxWidth)
let rightRatio = float_of_int(rightSpace) /. float_of_int(boxWidth)
// Determine alignment using thresholds
if leftRatio < 0.2 && rightRatio > 0.3 {
Left
} else if rightRatio < 0.2 && leftRatio > 0.3 {
Right
} else if Js.Math.abs_float(leftRatio -. rightRatio) < 0.15 {
Center
} else {
Left // Default
}
}Let's create a custom parser for a "Badge" element with syntax {badge: text}.
First, add your element to the element variant in Types.res:
type element =
| Box({...})
| Button({...})
| Input({...})
// ... existing elements ...
| Badge({
text: string,
position: Position.t,
align: alignment,
})Create src/parser/Semantic/Elements/BadgeParser.res:
module BadgeParser = {
open Types
// Pattern: {badge: some text}
let pattern = %re("/{badge:\s*([^}]+)}/")
let make = (): ElementParser.t => {
priority: 75, // Between links (80) and emphasis (70)
canParse: content => {
Js.Re.test_(pattern, content)
},
parse: (content, position, boxBounds) => {
switch Js.Re.exec_(pattern, content) {
| Some(result) => {
let captures = Js.Re.captures(result)
switch captures[1] {
| Some(textCapture) => {
let text = textCapture
->Js.Nullable.toOption
->Belt.Option.getExn
->Js.String2.trim
let align = AlignmentCalc.calculate(content, position, boxBounds)
Some(Badge({
text,
position,
align,
}))
}
| None => None
}
}
| None => None
}
}
}
}In ParserRegistry.res, add a factory function:
module ParserRegistry = {
// ... existing code ...
let makeBadgeParser = (): ElementParser.t => {
BadgeParser.make()
}
let makeDefaultRegistry = (): t => {
let registry = make()
register(registry, makeButtonParser())
register(registry, makeInputParser())
register(registry, makeLinkParser())
register(registry, makeCheckboxParser())
register(registry, makeBadgeParser()) // Add your parser
register(registry, makeEmphasisParser())
register(registry, makeTextParser())
registry
}
}Create src/parser/Semantic/Elements/__tests__/BadgeParser.test.res:
open Jest
open Expect
describe("BadgeParser", () => {
let parser = BadgeParser.make()
let dummyPosition = Position.make(0, 0)
let dummyBounds = Bounds.make(~top=0, ~left=0, ~bottom=5, ~right=30)
test("recognizes badge syntax", () => {
let content = "{badge: Admin}"
expect(parser.canParse(content))->toBe(true)
})
test("parses badge text correctly", () => {
let content = "{badge: Premium User}"
switch parser.parse(content, dummyPosition, dummyBounds) {
| Some(Badge({text, position, align})) => {
expect(text)->toBe("Premium User")
expect(position)->toEqual(dummyPosition)
}
| _ => fail("Expected Badge element")
}
})
test("handles whitespace in badge text", () => {
let content = "{badge: Whitespace }"
switch parser.parse(content, dummyPosition, dummyBounds) {
| Some(Badge({text})) => expect(text)->toBe("Whitespace")
| _ => fail("Expected Badge element")
}
})
test("rejects invalid syntax", () => {
let content = "{badge no colon}"
expect(parser.canParse(content))->toBe(false)
})
})Add documentation to your module:
/**
* BadgeParser recognizes badge elements with syntax: {badge: text}
*
* Examples:
* - {badge: Admin}
* - {badge: Premium User}
* - {badge: VIP}
*
* Priority: 75 (between links and emphasis)
*
* Returns: Badge({text, position, align})
*/
module BadgeParser = {
// ... implementation
}Sometimes you need context beyond the current line:
let parse = (content, position, boxBounds, ~context: parseContext) => {
// Access grid for multi-line patterns
let grid = context.grid
// Check next line for continuation
let nextLine = Grid.getLine(grid, position.row + 1)
// ... use context for advanced parsing
}For elements spanning multiple lines:
let parseMultiLine = (grid, startPos, bounds) => {
let lines = []
let currentRow = ref(startPos.row)
while currentRow.contents <= bounds.bottom - 1 {
switch Grid.getLine(grid, currentRow.contents) {
| Some(line) => {
lines->Js.Array2.push(line)->ignore
currentRow := currentRow.contents + 1
}
| None => break
}
}
// Process accumulated lines
processLines(lines)
}Location: src/parser/Semantic/ParserRegistry.res
What You Can Extend:
- Add new element types
- Change parser priority order
- Create specialized registries for different contexts
Example: Creating a minimal registry for testing:
let makeMinimalRegistry = (): t => {
let registry = make()
register(registry, makeButtonParser())
register(registry, makeTextParser())
registry
}Location: src/parser/Semantic/AlignmentCalc.res
What You Can Extend:
- Custom alignment strategies
- Element-specific alignment rules
Example: Custom alignment for code blocks (always left):
type alignmentStrategy =
| RespectPosition // Buttons, links
| AlwaysLeft // Text, code blocks
| AlwaysCenter // Titles, headers
let calculateWithStrategy = (content, position, bounds, strategy) => {
switch strategy {
| AlwaysLeft => Left
| AlwaysCenter => Center
| RespectPosition => calculate(content, position, bounds)
}
}Location: src/parser/Errors/ErrorMessages.res
What You Can Extend:
- Custom error messages
- Localization (different languages)
- Enhanced suggestions
Example: Adding a custom error type:
type errorCode =
| UncloseBox({...})
| MismatchedWidth({...})
// ... existing errors ...
| InvalidBadgeSyntax({
position: Position.t,
found: string,
})
let getTemplate = (code: errorCode): template => {
switch code {
| InvalidBadgeSyntax({position, found}) => {
title: "Invalid badge syntax",
message: `Found "${found}" at row ${Int.toString(position.row + 1)}, but badge syntax requires {badge: text}`,
solution: "Use the correct syntax: {badge: your text here}",
}
// ... other error templates
}
}Location: src/parser/Detector/BoxTracer.res
What You Can Extend:
- Custom box border characters
- Alternative box styles
- Additional validation rules
Example: Supporting rounded corners with parentheses:
let isCornerChar = (char: cellChar): bool => {
switch char {
| Corner => true // Standard '+'
| Char("(") | Char(")") => true // Rounded corners
| _ => false
}
}Location: src/parser/Semantic/ASTBuilder.res
What You Can Extend:
- AST transformation passes
- Validation rules
- Optimization passes
Example: Adding auto-ID generation:
let generateMissingIds = (ast: ast): ast => {
let idCounter = ref(0)
let rec processElement = (element: element): element => {
switch element {
| Button({id: "", text, position, align}) => {
idCounter := idCounter.contents + 1
Button({
id: `btn_${Int.toString(idCounter.contents)}`,
text,
position,
align,
})
}
| Box({name, bounds, children}) =>
Box({name, bounds, children: children->Belt.Array.map(processElement)})
| other => other
}
}
{
scenes: ast.scenes->Belt.Array.map(scene => {
{
...scene,
elements: scene.elements->Belt.Array.map(processElement)
}
})
}
}In ErrorTypes.res:
type errorCode =
| UncloseBox({...})
// ... existing errors ...
| DuplicateElementId({
id: string,
firstPosition: Position.t,
secondPosition: Position.t,
})let getSeverity = (code: errorCode): severity => {
switch code {
| DuplicateElementId(_) => Warning // Warning, not fatal
| UncloseBox(_) => Error // Fatal error
// ... other cases
}
}In ErrorMessages.res:
let getTemplate = (code: errorCode): template => {
switch code {
| DuplicateElementId({id, firstPosition, secondPosition}) => {
title: "Duplicate element ID",
message: `The ID "${id}" is used in multiple places:\n` ++
`• First: row ${Int.toString(firstPosition.row + 1)}, col ${Int.toString(firstPosition.col + 1)}\n` ++
`• Second: row ${Int.toString(secondPosition.row + 1)}, col ${Int.toString(secondPosition.col + 1)}`,
solution: "Each element must have a unique ID. Rename one of them.",
}
// ... other cases
}
}let validateUniqueIds = (elements: array<element>): Result.t<unit, ParseError.t> => {
let idMap = Belt.Map.String.empty
let rec check = (elements) => {
elements->Belt.Array.forEach(element => {
switch element {
| Button({id, position}) | Input({id, position}) => {
switch Belt.Map.String.get(idMap, id) {
| Some(firstPosition) =>
return Error(ParseError.make(
DuplicateElementId({id, firstPosition, secondPosition: position}),
grid
))
| None =>
idMap = Belt.Map.String.set(idMap, id, position)
}
}
| Box({children}) => check(children)
| _ => ()
}
})
}
check(elements)
Ok()
}// Test the parser in isolation
describe("BadgeParser", () => {
test("canParse returns true for valid syntax", () => {
let parser = BadgeParser.make()
expect(parser.canParse("{badge: Text}"))->toBe(true)
})
test("parse extracts text correctly", () => {
let parser = BadgeParser.make()
let result = parser.parse(
"{badge: Admin}",
Position.make(0, 0),
Bounds.make(~top=0, ~left=0, ~bottom=5, ~right=30)
)
switch result {
| Some(Badge({text})) => expect(text)->toBe("Admin")
| _ => fail("Expected Badge")
}
})
})// Test end-to-end parsing
describe("Badge element integration", () => {
test("parses badge in wireframe", () => {
let wireframe = `
+------------------+
| {badge: Admin} |
+------------------+
`
let result = WyreframeParser.parse(wireframe, None)
switch result {
| Ok(ast) => {
let scene = ast.scenes[0]
let hasBadge = scene.elements->Belt.Array.some(el => {
switch el {
| Badge(_) => true
| _ => false
}
})
expect(hasBadge)->toBe(true)
}
| Error(errors) => fail("Expected successful parse")
}
})
})open FastCheck
describe("Badge parser properties", () => {
test("parsing is deterministic", () => {
let arbBadge = fc.tuple(
fc.string({minLength: 1, maxLength: 20})
)->fc.map(text => `{badge: ${text}}`)
fc.assert_(
fc.property(arbBadge, content => {
let parser = BadgeParser.make()
let result1 = parser.parse(content, dummyPos, dummyBounds)
let result2 = parser.parse(content, dummyPos, dummyBounds)
deepEqual(result1, result2)
})
)
})
})// ❌ Inefficient: Scanning entire grid
let findAllCorners = (grid) => {
let corners = []
for row in 0 to grid.height - 1 {
for col in 0 to grid.width - 1 {
switch Grid.get(grid, Position.make(row, col)) {
| Some(Corner) => corners->Js.Array2.push(Position.make(row, col))->ignore
| _ => ()
}
}
}
corners
}
// ✅ Efficient: Use prebuilt index (O(1))
let findAllCorners = (grid) => {
grid.cornerIndex
}// ❌ Compiles regex on every call
let canParse = (content) => {
Js.Re.test_(%re("/pattern/"), content)
}
// ✅ Compile once at module level
let pattern = %re("/pattern/")
let canParse = (content) => {
Js.Re.test_(pattern, content)
}// ❌ Using JavaScript arrays
let filtered = array->Js.Array2.filter(predicate)
// ✅ Using Belt for better performance
let filtered = array->Belt.Array.keep(predicate)// Only compute alignment if element actually uses it
let parse = (content, position, bounds) => {
let align = lazy(AlignmentCalc.calculate(content, position, bounds))
Some(Badge({
text,
position,
align: Lazy.force(align), // Only computed if Badge is created
}))
}let benchmarkParser = (parser: ElementParser.t, samples: int) => {
let testContent = "{badge: Test}"
let position = Position.make(0, 0)
let bounds = Bounds.make(~top=0, ~left=0, ~bottom=5, ~right=30)
let start = Js.Date.now()
for _ in 0 to samples - 1 {
let _ = parser.parse(testContent, position, bounds)
}
let end = Js.Date.now()
let duration = end -. start
Js.Console.log(`Parsed ${Int.toString(samples)} times in ${Float.toString(duration)}ms`)
Js.Console.log(`Average: ${Float.toString(duration /. float_of_int(samples))}ms per parse`)
}Start with basic parsing, add features incrementally:
// Version 1: Simple badge
| Badge({text, position, align})
// Version 2: Add color
| Badge({text, color: option<string>, position, align})
// Version 3: Add icon
| Badge({text, color: option<string>, icon: option<string>, position, align})Don't modify core types; compose them:
// ❌ Modifying core Button type
type element =
| Button({id, text, position, align, customProp: option<string>})
// ✅ Wrapping with custom data
type customElement =
| CoreElement(element)
| EnhancedButton({
button: element, // Original button
customProp: string,
})Always collect multiple errors:
let validateElements = (elements: array<element>): Result.t<unit, array<ParseError.t>> => {
let errors = []
elements->Belt.Array.forEach(element => {
// Validation 1
switch validateId(element) {
| Error(e) => errors->Js.Array2.push(e)->ignore
| Ok() => ()
}
// Validation 2
switch validateAlignment(element) {
| Error(e) => errors->Js.Array2.push(e)->ignore
| Ok() => ()
}
})
if Array.length(errors) > 0 {
Error(errors)
} else {
Ok()
}
}Use exhaustive pattern matching:
let processElement = (element: element): processedElement => {
switch element {
| Box({name, bounds, children}) => processBox(name, bounds, children)
| Button({id, text, position, align}) => processButton(id, text, position, align)
| Input({id, placeholder, position}) => processInput(id, placeholder, position)
| Link({id, text, position, align}) => processLink(id, text, position, align)
| Checkbox({checked, label, position}) => processCheckbox(checked, label, position)
| Text({content, emphasis, position, align}) => processText(content, emphasis, position, align)
| Divider({position}) => processDivider(position)
| Row({children, align}) => processRow(children, align)
| Section({name, children}) => processSection(name, children)
// Compiler ensures all cases are handled
}
}Document before implementing:
/**
* SpecialParser recognizes special syntax: <<<content>>>
*
* Syntax:
* - <<<text>>> - Creates a special element
* - <<<text|variant>>> - With variant
* - <<<text|variant|icon>>> - With variant and icon
*
* Priority: 65
*
* Examples:
* ```
* <<<Important>>>
* <<<Warning|alert>>>
* <<<Info|info|🛈>>>
* ```
*
* Returns:
* - Some(Special({...})) on success
* - None if pattern doesn't match
*/
module SpecialParser = {
// Implementation follows documentation
}- Add element variant to
Types.res - Create parser module in
Semantic/Elements/ - Implement
priority,canParse,parse - Define regex pattern at module level
- Use
AlignmentCalcfor alignment - Register parser in
ParserRegistry.makeDefaultRegistry() - Write unit tests with ≥90% coverage
- Write integration test with real wireframe
- Add documentation with examples
- Benchmark if performance-critical
| Extension Point | Location | Difficulty | Common Use Case |
|---|---|---|---|
| Element Parser | Semantic/Elements/ |
Easy | New UI elements |
| Alignment Strategy | AlignmentCalc.res |
Easy | Custom positioning |
| Error Messages | ErrorMessages.res |
Easy | Localization |
| Box Validation | BoxTracer.res |
Medium | Alternative box styles |
| AST Transformation | ASTBuilder.res |
Medium | Post-processing |
| Grid Operations | Grid.res |
Hard | New scanning patterns |
| Operation | Target | Measurement |
|---|---|---|
| Grid construction | <5ms per 1000 lines | benchmarkGrid() |
| Box tracing | <1ms per box | benchmarkBoxTracer() |
| Element parsing | <0.1ms per element | benchmarkParser() |
| Full parse | <50ms for 100 lines | benchmarkEndToEnd() |
- Design Document:
.claude/specs/parser-refactor/design.md- Full architecture - Requirements:
.claude/specs/parser-refactor/requirements.md- All requirements - Type Definitions:
src/parser/Core/Types.res- Core types - Examples:
__tests__/fixtures/- Real wireframe examples
Q: My parser isn't being called
A: Check the priority. Higher numbers are checked first. Ensure canParse() returns true.
Q: How do I access the grid from a parser?
A: Pass grid through parseContext parameter. Modify ElementParser.t signature if needed.
Q: How do I handle multi-line elements?
A: Use Grid.getLine() and Grid.getRange() to read multiple rows. See pattern examples above.
Q: My tests are failing with type errors
A: Run npm run res:clean && npm run res:build to rebuild ReScript output.
This section outlines the phased approach to implementing and extending the parser.
Grid,Position,BoundsclassesGridScannerimplementation- Basic unit tests
BoxTracer- Box boundary tracingDividerDetector- Divider detectionHierarchyBuilder- Nesting relationships- Integration tests for various box patterns
ParserRegistryimplementation- Element parsers (Button, Input, Link, Checkbox, etc.)
AlignmentCalcimplementationASTBuilderimplementation
ParseErrorclass- Error message templates
ErrorContextBuilderimplementation- Error message tests
WireframeParserunified class- Backward compatibility with existing API
- Performance benchmarks
- Documentation
- Parallel execution testing with legacy parser
- Result comparison validation
- Gradual transition
- Legacy code removal
The Wyreframe parser is designed to be extended. The 3-stage architecture provides clear separation of concerns, and the plugin-based element parser system makes adding new features straightforward.
Key takeaways:
- Understand the stages: Grid → Shapes → Semantics
- Use the registry: Don't modify core parser logic
- Test thoroughly: Unit, integration, and property tests
- Optimize wisely: Use indices, lazy evaluation, Belt collections
- Document generously: Help the next developer (or future you)
Happy parsing!
Document Version: 0.4.3 Last Updated: 2025-12-27 License: GPL-3.0 Feedback: Please submit issues or suggestions to GitHub