Skip to content

Commit a0c2d9c

Browse files
wickedevclaude
andcommitted
fix: detect and warn about misaligned closing borders (#4)
Add MisalignedClosingBorder warning when closing '|' characters are not aligned with the box border. The parser now: - Traces boxes tolerantly even with misaligned interior rows - Generates warnings with expected/actual column information - Continues parsing successfully instead of failing Changes: - Add MisalignedClosingBorder error type and warning message - Add validateInteriorAlignment function to BoxTracer - Update ShapeDetector to collect and return warnings - Change parseResult type to include warnings array - Add 3 unit tests for the new warning detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8e70809 commit a0c2d9c

File tree

13 files changed

+549
-116
lines changed

13 files changed

+549
-116
lines changed

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,24 @@ switch Renderer.createUI(ui, None) {
105105
```javascript
106106
import { parse, render, createUI, createUIOrThrow } from 'wyreframe';
107107

108-
// Parse only
108+
// Parse only - returns { success, ast } or { success, errors }
109109
const result = parse(text);
110110

111-
// Render only
112-
const { root, sceneManager } = render(ast);
111+
// Render only - IMPORTANT: pass result.ast, not result directly!
112+
if (result.success) {
113+
const { root, sceneManager } = render(result.ast);
114+
}
113115

114-
// Parse + Render (recommended)
116+
// Parse + Render (recommended) - handles the wrapper automatically
115117
const result = createUI(text);
116118

117119
// Throw on error
118120
const { root, sceneManager } = createUIOrThrow(text);
119121
```
120122

123+
> **Note:** `parse()` returns a wrapper object `{ success: true, ast: AST }` or `{ success: false, errors: [] }`.
124+
> When using `render()` directly, make sure to pass `result.ast`, not the result object itself.
125+
121126
### ReScript
122127

123128
```rescript

src/index.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export interface RenderResult {
173173
export type ParseSuccessResult = {
174174
success: true;
175175
ast: AST;
176+
warnings: ParseError[];
176177
};
177178

178179
export type ParseErrorResult = {
@@ -199,6 +200,7 @@ export type CreateUISuccessResult = {
199200
root: HTMLElement;
200201
sceneManager: SceneManager;
201202
ast: AST;
203+
warnings: ParseError[];
202204
};
203205

204206
export type CreateUIErrorResult = {
@@ -246,10 +248,11 @@ type ReScriptResult<T, E> = ReScriptOk<T> | ReScriptError<E>;
246248
* ```
247249
*/
248250
export function parse(text: string): ParseResult {
249-
const result = Parser.parse(text) as ReScriptResult<AST, ParseError[]>;
251+
const result = Parser.parse(text) as ReScriptResult<[AST, ParseError[]], ParseError[]>;
250252

251253
if (result.TAG === 'Ok') {
252-
return { success: true, ast: result._0 };
254+
const [ast, warnings] = result._0;
255+
return { success: true, ast, warnings };
253256
} else {
254257
return { success: false, errors: result._0 };
255258
}
@@ -294,10 +297,11 @@ export function parseOrThrow(text: string): AST {
294297
* @returns Parse result with success flag
295298
*/
296299
export function parseWireframe(wireframe: string): ParseResult {
297-
const result = Parser.parseWireframe(wireframe) as ReScriptResult<AST, ParseError[]>;
300+
const result = Parser.parseWireframe(wireframe) as ReScriptResult<[AST, ParseError[]], ParseError[]>;
298301

299302
if (result.TAG === 'Ok') {
300-
return { success: true, ast: result._0 };
303+
const [ast, warnings] = result._0;
304+
return { success: true, ast, warnings };
301305
} else {
302306
return { success: false, errors: result._0 };
303307
}
@@ -336,6 +340,31 @@ export function parseInteractions(dsl: string): InteractionResult {
336340
* ```
337341
*/
338342
export function render(ast: AST, options?: RenderOptions): RenderResult {
343+
// Input validation: Check if user accidentally passed ParseResult instead of AST
344+
if (ast && typeof ast === 'object' && 'success' in ast) {
345+
const parseResult = ast as unknown as ParseResult;
346+
if (parseResult.success === true && 'ast' in parseResult) {
347+
throw new Error(
348+
'render() expects an AST object, but received a ParseResult. ' +
349+
'Did you forget to extract .ast? Use: render(result.ast) instead of render(result)'
350+
);
351+
} else if (parseResult.success === false) {
352+
throw new Error(
353+
'render() received a failed ParseResult. ' +
354+
'Check parse errors before calling render: if (result.success) { render(result.ast); }'
355+
);
356+
}
357+
}
358+
359+
// Validate AST structure
360+
if (!ast || typeof ast !== 'object' || !Array.isArray(ast.scenes)) {
361+
throw new Error(
362+
'render() expects an AST object with a scenes array. ' +
363+
'Did you pass ParseResult instead of ParseResult.ast? ' +
364+
'Correct usage: const result = parse(text); if (result.success) { render(result.ast); }'
365+
);
366+
}
367+
339368
// Pass undefined if no options, so ReScript uses its defaults
340369
const result = Renderer.render(ast, options);
341370

@@ -380,6 +409,7 @@ export function createUI(text: string, options?: RenderOptions): CreateUIResult
380409
root,
381410
sceneManager,
382411
ast: parseResult.ast,
412+
warnings: parseResult.warnings,
383413
};
384414
}
385415

src/parser/Detector/BoxTracer.res

Lines changed: 172 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,90 @@ let isDividerOnlyEdge = (edgeChars: array<cellChar>): bool => {
7070
!hasHLineOrChar
7171
}
7272

73+
/**
74+
* Find the last Corner character below a given position in the same column.
75+
* Simple version used for fallback width mismatch detection.
76+
*
77+
* @param grid - The 2D character grid
78+
* @param startPos - Starting position (searches below this row)
79+
* @returns Option of position where the last corner was found
80+
*/
81+
let findLastCornerInColumn = (grid: Grid.t, startPos: Position.t): option<Position.t> => {
82+
let lastCorner = ref(None)
83+
for row in startPos.row + 1 to grid.height - 1 {
84+
let pos = Position.make(row, startPos.col)
85+
switch Grid.get(grid, pos) {
86+
| Some(Corner) => lastCorner := Some(pos)
87+
| _ => ()
88+
}
89+
}
90+
lastCorner.contents
91+
}
92+
93+
/**
94+
* Check if a row has any VLine character between left and right columns (inclusive).
95+
* Used to determine if a row is part of a box (even with misaligned borders).
96+
*/
97+
let rowHasVLineInRange = (grid: Grid.t, row: int, leftCol: int, rightCol: int): bool => {
98+
let found = ref(false)
99+
let col = ref(leftCol)
100+
while col.contents <= rightCol && !found.contents {
101+
switch Grid.get(grid, Position.make(row, col.contents)) {
102+
| Some(VLine) => found := true
103+
| _ => ()
104+
}
105+
col := col.contents + 1
106+
}
107+
found.contents
108+
}
109+
110+
/**
111+
* Find the bottom-right corner of a box by scanning down from top-right.
112+
*
113+
* This is more tolerant than the strict scanDown approach:
114+
* - Continues through rows with misaligned VLines (records for warnings)
115+
* - Stops at rows with no VLine at all in the box's column range (box boundary)
116+
* - Handles internal dividers (+=====+) correctly by finding the last corner
117+
*
118+
* @param grid - The 2D character grid
119+
* @param topLeft - Position of top-left corner (for determining left boundary)
120+
* @param topRight - Position of top-right corner (starting point for scan)
121+
* @returns Option of position where the bottom-right corner was found
122+
*/
123+
let findBottomRightCorner = (grid: Grid.t, topLeft: Position.t, topRight: Position.t): option<Position.t> => {
124+
let lastCorner = ref(None)
125+
let row = ref(topRight.row + 1)
126+
let continue = ref(true)
127+
128+
while row.contents < grid.height && continue.contents {
129+
let pos = Position.make(row.contents, topRight.col)
130+
131+
switch Grid.get(grid, pos) {
132+
| Some(Corner) => {
133+
// Found a corner at the expected column - remember it
134+
lastCorner := Some(pos)
135+
row := row.contents + 1
136+
}
137+
| Some(VLine) => {
138+
// Found a VLine at the expected column - continue scanning
139+
row := row.contents + 1
140+
}
141+
| _ => {
142+
// No VLine/Corner at expected column - check if row is part of this box
143+
if rowHasVLineInRange(grid, row.contents, topLeft.col, topRight.col) {
144+
// Row has a VLine somewhere (misaligned) - continue scanning
145+
row := row.contents + 1
146+
} else {
147+
// No VLine in this row's box range - we've reached the end of the box
148+
continue := false
149+
}
150+
}
151+
}
152+
}
153+
154+
lastCorner.contents
155+
}
156+
73157
/**
74158
* Trace a box starting from the top-left corner position.
75159
*
@@ -134,38 +218,22 @@ let traceBox = (grid: Grid.t, topLeft: Position.t): traceResult => {
134218
// Calculate top width
135219
let topWidth = topRight.col - topLeft.col
136220

137-
// Step 4: Scan down from top-right to find bottom-right corner
138-
let rightEdgeScan = Grid.scanDown(grid, topRight, isValidVerticalChar)
221+
// Step 4: Find bottom-right corner by searching the column
222+
// Use tolerant search that ignores interior rows with misaligned VLines
223+
let bottomRightOpt = findBottomRightCorner(grid, topLeft, topRight)
139224

140-
let bottomRightOpt = {
141-
let lastCorner = ref(None)
142-
Array.forEach(rightEdgeScan, ((pos, cell)) => {
143-
switch cell {
144-
| Corner if !Position.equals(pos, topRight) => lastCorner := Some(pos)
145-
| _ => ()
146-
}
147-
})
148-
lastCorner.contents
149-
}
225+
// Store rightEdgeScan for later validation (after we know the box is valid)
226+
let rightEdgeScan = Grid.scanDown(grid, topRight, isValidVerticalChar)
150227

151228
switch bottomRightOpt {
152229
| None => {
153-
// Right edge scan failed - could be width mismatch
154-
// Try tracing from left side to detect width mismatch
155-
let leftEdgeScan = Grid.scanDown(grid, topLeft, isValidVerticalChar)
156-
let bottomLeftOpt = {
157-
let lastCorner = ref(None)
158-
Array.forEach(leftEdgeScan, ((pos, cell)) => {
159-
switch cell {
160-
| Corner if !Position.equals(pos, topLeft) => lastCorner := Some(pos)
161-
| _ => ()
162-
}
163-
})
164-
lastCorner.contents
165-
}
230+
// No corner found in the right column at topRight.col
231+
// Try to detect width mismatch by finding bottom-left via left edge
232+
let bottomLeftViaLeft = findLastCornerInColumn(grid, topLeft)
166233

167-
switch bottomLeftOpt {
234+
switch bottomLeftViaLeft {
168235
| None =>
236+
// No bottom-left corner found either - unclosed box
169237
Error(
170238
ErrorTypes.makeSimple(
171239
ErrorTypes.UncloseBox({
@@ -212,7 +280,7 @@ let traceBox = (grid: Grid.t, topLeft: Position.t): traceResult => {
212280
),
213281
)
214282
} else {
215-
// Widths match but right edge still failed - unclosed box
283+
// Widths match but right edge corners don't align - unclosed box
216284
Error(
217285
ErrorTypes.makeSimple(
218286
ErrorTypes.UncloseBox({
@@ -372,3 +440,80 @@ let traceBox = (grid: Grid.t, topLeft: Position.t): traceResult => {
372440
)
373441
}
374442
}
443+
444+
/**
445+
* Validate interior row alignment for a successfully traced box.
446+
*
447+
* Checks each row between the top and bottom borders to ensure:
448+
* - The closing '|' character is at the expected column (bounds.right)
449+
* - Generates MisalignedClosingBorder warnings for any misaligned rows
450+
*
451+
* This validation runs AFTER successful box tracing to detect visual
452+
* alignment issues that don't prevent parsing but should be warned about.
453+
*
454+
* @param grid - The 2D character grid
455+
* @param bounds - The bounds of the traced box
456+
* @returns Array of warnings for any misaligned closing borders
457+
*/
458+
let validateInteriorAlignment = (grid: Grid.t, bounds: Bounds.t): array<ErrorTypes.t> => {
459+
let warnings = []
460+
461+
// Check each interior row (excluding top and bottom border rows)
462+
for row in bounds.top + 1 to bounds.bottom - 1 {
463+
// Check if there's a VLine at the expected right border column
464+
let expectedRightCol = bounds.right
465+
let rightCell = Grid.get(grid, Position.make(row, expectedRightCol))
466+
467+
switch rightCell {
468+
| Some(VLine) => () // Properly aligned, no warning needed
469+
| Some(_) | None => {
470+
// The expected column doesn't have a VLine
471+
// Try to find where the actual closing '|' is on this row
472+
// Search in both directions from the expected position
473+
let actualCol = ref(None)
474+
475+
// First, search to the RIGHT of expectedRightCol (for pipes beyond the expected boundary)
476+
// Search up to 50 chars beyond to catch misaligned pipes
477+
let maxSearchRight = expectedRightCol + 50
478+
let colRight = ref(expectedRightCol + 1)
479+
while colRight.contents <= maxSearchRight && Option.isNone(actualCol.contents) {
480+
switch Grid.get(grid, Position.make(row, colRight.contents)) {
481+
| Some(VLine) => actualCol := Some(colRight.contents)
482+
| _ => ()
483+
}
484+
colRight := colRight.contents + 1
485+
}
486+
487+
// If not found to the right, search to the LEFT (for pipes before the expected boundary)
488+
// But only if it's not the opening pipe (bounds.left)
489+
if Option.isNone(actualCol.contents) {
490+
let col = ref(expectedRightCol - 1)
491+
while col.contents > bounds.left && Option.isNone(actualCol.contents) {
492+
switch Grid.get(grid, Position.make(row, col.contents)) {
493+
| Some(VLine) => actualCol := Some(col.contents)
494+
| _ => ()
495+
}
496+
col := col.contents - 1
497+
}
498+
}
499+
500+
// If we found a VLine at a different position, generate a warning
501+
switch actualCol.contents {
502+
| Some(foundCol) if foundCol !== expectedRightCol => {
503+
let warning = ErrorTypes.makeSimple(
504+
ErrorTypes.MisalignedClosingBorder({
505+
position: Position.make(row, foundCol),
506+
expectedCol: expectedRightCol,
507+
actualCol: foundCol,
508+
}),
509+
)
510+
warnings->Array.push(warning)->ignore
511+
}
512+
| _ => () // No VLine found or it's at the correct position (already handled above)
513+
}
514+
}
515+
}
516+
}
517+
518+
warnings
519+
}

0 commit comments

Comments
 (0)