Skip to content

Commit d2fd868

Browse files
kmelveclaude
andcommitted
feat: add recursive nested box handling
- Detect and fix inner boxes within outer boxes recursively - Process inner boxes right-to-left to avoid column invalidation - Validate inner box alignment before processing - Maintain outer box dimensions when fixing inner boxes - Support arbitrary nesting depth and multiple side-by-side boxes New functions: - findInnerBoxRegions: detect inner box boundaries - extractInnerBox: extract inner box content with validation - reinsertInnerBox: reinsert fixed content while preserving spacing - hasInnerBoundary, findInnerTopCornerColumn, etc.: detection helpers - sliceByDisplayColumn, replaceByDisplayColumn: column-based string ops Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5718ba3 commit d2fd868

File tree

5 files changed

+482
-14
lines changed

5 files changed

+482
-14
lines changed

src/boxfix.ts

Lines changed: 211 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,227 @@
1-
import { BoxfixResult, BoxfixStats, RIGHT_BORDER_CHARS } from "./types.js";
2-
import { getDisplayWidth, expandTabs, padBeforeLastChar } from "./width.js";
1+
import { BoxfixResult, BoxfixStats, RIGHT_BORDER_CHARS, BoxRegion } from "./types.js";
2+
import {
3+
getDisplayWidth,
4+
expandTabs,
5+
padBeforeLastChar,
6+
sliceByDisplayColumn,
7+
replaceByDisplayColumn,
8+
} from "./width.js";
39
import {
410
isDiagram,
511
isBoundaryLine,
612
isContentLine,
713
isTreeLine,
14+
hasInnerBoundary,
15+
findInnerTopCornerColumn,
16+
hasBottomCornerAtColumn,
17+
findBoundaryEndColumn,
818
} from "./diagram-detector.js";
919

20+
/**
21+
* Find all inner box regions within a diagram
22+
* An inner box is detected by finding a top-left corner (┌ or +) not at column 0,
23+
* then tracking until we find the matching bottom-left corner (└ or +) at the same column
24+
*/
25+
function findInnerBoxRegions(lines: string[]): BoxRegion[] {
26+
const regions: BoxRegion[] = [];
27+
28+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
29+
const line = lines[lineIdx];
30+
31+
// Look for inner top boundaries in this line
32+
if (!hasInnerBoundary(line)) {
33+
continue;
34+
}
35+
36+
// Find all inner top corners in this line
37+
let searchCol = 0;
38+
while (true) {
39+
const startCol = findInnerTopCornerColumn(line, searchCol);
40+
if (startCol === -1) {
41+
break;
42+
}
43+
44+
// Find the end column of this boundary
45+
const endCol = findBoundaryEndColumn(line, startCol);
46+
47+
// Look for the matching bottom boundary
48+
let endLine = -1;
49+
for (let bottomIdx = lineIdx + 1; bottomIdx < lines.length; bottomIdx++) {
50+
if (hasBottomCornerAtColumn(lines[bottomIdx], startCol)) {
51+
// Verify it's a complete bottom boundary by checking end column too
52+
const bottomEndCol = findBoundaryEndColumn(lines[bottomIdx], startCol);
53+
if (bottomEndCol === endCol) {
54+
endLine = bottomIdx;
55+
break;
56+
}
57+
}
58+
}
59+
60+
if (endLine !== -1) {
61+
regions.push({
62+
startLine: lineIdx,
63+
endLine,
64+
startCol,
65+
endCol,
66+
});
67+
}
68+
69+
// Continue searching for more inner boxes on this line
70+
searchCol = endCol;
71+
}
72+
}
73+
74+
return regions;
75+
}
76+
77+
/**
78+
* Result of extracting an inner box
79+
*/
80+
interface ExtractedBox {
81+
content: string;
82+
/** Width of each extracted line (may differ from boundary width for content lines) */
83+
lineWidths: number[];
84+
}
85+
86+
/**
87+
* Extract an inner box as a standalone diagram
88+
* For content lines, finds the actual right border position to avoid including outer box padding
89+
* Returns null if the extraction doesn't produce a valid box
90+
*/
91+
function extractInnerBox(lines: string[], region: BoxRegion): ExtractedBox | null {
92+
const extractedLines: string[] = [];
93+
const lineWidths: number[] = [];
94+
const verticalChars = ["│", "┃", "|"];
95+
96+
for (let i = region.startLine; i <= region.endLine; i++) {
97+
const line = lines[i];
98+
let extracted = sliceByDisplayColumn(line, region.startCol, region.endCol);
99+
let extractedWidth = getDisplayWidth(extracted);
100+
101+
// For content lines (not top/bottom boundary), trim to the actual right border
102+
if (i !== region.startLine && i !== region.endLine) {
103+
// Find the last vertical border character in the extracted content
104+
let lastBorderIdx = -1;
105+
for (let j = extracted.length - 1; j >= 0; j--) {
106+
if (verticalChars.includes(extracted[j])) {
107+
lastBorderIdx = j;
108+
break;
109+
}
110+
}
111+
if (lastBorderIdx !== -1 && lastBorderIdx < extracted.length - 1) {
112+
// Trim to end at the border character and track the actual width
113+
extracted = extracted.slice(0, lastBorderIdx + 1);
114+
extractedWidth = getDisplayWidth(extracted);
115+
}
116+
}
117+
118+
extractedLines.push(extracted);
119+
lineWidths.push(extractedWidth);
120+
}
121+
122+
const content = extractedLines.join("\n");
123+
124+
// Validate that the extraction produced a valid box
125+
// The first line should be a boundary line (start with corner character)
126+
const firstLine = extractedLines[0];
127+
const topCorners = ["┌", "+"];
128+
if (!firstLine || !topCorners.includes(firstLine[0])) {
129+
// Extraction didn't produce a valid box (misaligned inner box)
130+
return null;
131+
}
132+
133+
// Also validate that content lines start with vertical border
134+
// (they might be offset if the inner box is misaligned)
135+
for (let i = 1; i < extractedLines.length - 1; i++) {
136+
const contentLine = extractedLines[i];
137+
if (contentLine && !verticalChars.includes(contentLine[0])) {
138+
// Content line doesn't start with border - misaligned inner box
139+
return null;
140+
}
141+
}
142+
143+
return { content, lineWidths };
144+
}
145+
146+
/**
147+
* Reinsert a fixed inner box back at its original position
148+
* Uses the original extracted widths and adjusts for width changes to maintain outer spacing
149+
*/
150+
function reinsertInnerBox(
151+
lines: string[],
152+
region: BoxRegion,
153+
fixedInner: string,
154+
originalWidths: number[]
155+
): string[] {
156+
const fixedLines = fixedInner.split("\n");
157+
const result = [...lines];
158+
159+
for (let i = 0; i < fixedLines.length; i++) {
160+
const lineIdx = region.startLine + i;
161+
if (lineIdx < result.length) {
162+
const originalWidth = originalWidths[i] || 0;
163+
const fixedWidth = getDisplayWidth(fixedLines[i]);
164+
const widthDiff = fixedWidth - originalWidth;
165+
166+
// Extend the end column to consume trailing space if the fixed content is wider
167+
// This maintains the total line width by taking space from after the inner box
168+
const endCol = region.startCol + originalWidth + Math.max(0, widthDiff);
169+
170+
result[lineIdx] = replaceByDisplayColumn(
171+
result[lineIdx],
172+
region.startCol,
173+
endCol,
174+
fixedLines[i]
175+
);
176+
}
177+
}
178+
179+
return result;
180+
}
181+
10182
/**
11183
* Fix a single ASCII diagram by correcting right border alignment
12184
*
13185
* Algorithm:
14-
* 1. Process the diagram top to bottom, tracking current box context
15-
* 2. When we see a boundary line, update the target width for subsequent lines
16-
* 3. For content lines shorter than target (by small margin), pad them
17-
* 4. This handles nested/stacked boxes correctly by using local context
186+
* 1. Find and recursively fix any inner boxes first
187+
* 2. Process the diagram top to bottom, tracking current box context
188+
* 3. When we see a boundary line, update the target width for subsequent lines
189+
* 4. For content lines shorter than target (by small margin), pad them
190+
* 5. This handles nested/stacked boxes correctly by using local context
18191
*/
19192
export function boxfixDiagram(content: string): {
20193
result: string;
21194
linesFixed: number;
22195
} {
23196
// Expand tabs first
24197
const expanded = expandTabs(content);
25-
const lines = expanded.split("\n");
198+
let lines = expanded.split("\n");
199+
let totalLinesFixed = 0;
200+
201+
// Phase 1: Find and recursively fix inner boxes
202+
const regions = findInnerBoxRegions(lines);
203+
204+
// Sort regions: process right-to-left (higher startCol first) to avoid column invalidation
205+
// Also process deeper boxes first (those that start on later lines)
206+
regions.sort((a, b) => {
207+
if (a.startCol !== b.startCol) {
208+
return b.startCol - a.startCol; // Right to left
209+
}
210+
return b.startLine - a.startLine; // Later lines first
211+
});
212+
213+
for (const region of regions) {
214+
const extracted = extractInnerBox(lines, region);
215+
// Skip if extraction didn't produce a valid box (misaligned inner box)
216+
if (extracted === null) {
217+
continue;
218+
}
219+
const { result: fixedInner, linesFixed: innerFixed } = boxfixDiagram(extracted.content);
220+
totalLinesFixed += innerFixed;
221+
lines = reinsertInnerBox(lines, region, fixedInner, extracted.lineWidths);
222+
}
223+
224+
// Phase 2: Fix outer box (existing logic)
26225

27226
// Find all boundary line widths to use as reference
28227
const boundaryWidths = new Set<number>();
@@ -32,13 +231,13 @@ export function boxfixDiagram(content: string): {
32231
}
33232
}
34233

35-
// If no boundary lines found, return unchanged
234+
// If no boundary lines found, return with just inner fixes
36235
if (boundaryWidths.size === 0) {
37-
return { result: content, linesFixed: 0 };
236+
return { result: lines.join("\n"), linesFixed: totalLinesFixed };
38237
}
39238

40239
// Process each line, tracking current box context
41-
let linesFixed = 0;
240+
let outerLinesFixed = 0;
42241
let currentTargetWidth = 0;
43242

44243
const fixedLines = lines.map((line) => {
@@ -91,7 +290,7 @@ export function boxfixDiagram(content: string): {
91290
const { padded, spacesAdded } = padBeforeLastChar(trimmed, targetWidth);
92291

93292
if (spacesAdded > 0) {
94-
linesFixed++;
293+
outerLinesFixed++;
95294
return padded + trailingWhitespace;
96295
}
97296

@@ -100,7 +299,7 @@ export function boxfixDiagram(content: string): {
100299

101300
return {
102301
result: fixedLines.join("\n"),
103-
linesFixed,
302+
linesFixed: totalLinesFixed + outerLinesFixed,
104303
};
105304
}
106305

src/diagram-detector.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BOX_CHARS, CORNER_CHARS } from "./types.js";
2+
import { getDisplayWidth } from "./width.js";
23

34
/**
45
* Check if content appears to be an ASCII diagram (as opposed to regular code)
@@ -119,3 +120,92 @@ export function isContentLine(line: string): boolean {
119120

120121
return verticalChars.includes(firstChar) && verticalChars.includes(lastChar);
121122
}
123+
124+
/**
125+
* Check if a content line contains an inner box boundary
126+
* (a corner character that is not at the start of the line)
127+
*/
128+
export function hasInnerBoundary(line: string): boolean {
129+
const trimmed = line.trim();
130+
131+
// Must be a content line first
132+
if (!isContentLine(line)) {
133+
return false;
134+
}
135+
136+
// Check for corner chars not at position 0
137+
const topCorners = ["┌", "+"];
138+
139+
// Skip first character (the outer border) and look for inner boundaries
140+
for (let i = 1; i < trimmed.length - 1; i++) {
141+
if (topCorners.includes(trimmed[i])) {
142+
return true;
143+
}
144+
}
145+
146+
return false;
147+
}
148+
149+
/**
150+
* Find the display column of a top-left corner character in a line
151+
* Returns -1 if not found
152+
*/
153+
export function findInnerTopCornerColumn(line: string, startSearchCol = 0): number {
154+
const topCorners = ["┌", "+"];
155+
let currentCol = 0;
156+
157+
for (const char of line) {
158+
if (currentCol > startSearchCol && topCorners.includes(char)) {
159+
return currentCol;
160+
}
161+
currentCol += getDisplayWidth(char);
162+
}
163+
164+
return -1;
165+
}
166+
167+
/**
168+
* Find the display column of a bottom-left corner character at a specific column
169+
* Returns true if a bottom corner exists at the given column
170+
*/
171+
export function hasBottomCornerAtColumn(line: string, col: number): boolean {
172+
const bottomCorners = ["└", "+"];
173+
let currentCol = 0;
174+
175+
for (const char of line) {
176+
if (currentCol === col) {
177+
return bottomCorners.includes(char);
178+
}
179+
if (currentCol > col) {
180+
return false;
181+
}
182+
currentCol += getDisplayWidth(char);
183+
}
184+
185+
return false;
186+
}
187+
188+
/**
189+
* Find the right edge of a box boundary starting at a given column
190+
* Returns the column after the last character of the boundary
191+
*/
192+
export function findBoundaryEndColumn(line: string, startCol: number): number {
193+
const horizontalChars = ["─", "━", "-", "="];
194+
const endCorners = ["┐", "┘", "+"];
195+
let currentCol = 0;
196+
let lastBoundaryCol = startCol;
197+
198+
for (const char of line) {
199+
if (currentCol > startCol) {
200+
if (endCorners.includes(char)) {
201+
return currentCol + getDisplayWidth(char);
202+
}
203+
if (horizontalChars.includes(char) || char === " ") {
204+
lastBoundaryCol = currentCol + getDisplayWidth(char);
205+
}
206+
}
207+
currentCol += getDisplayWidth(char);
208+
}
209+
210+
return lastBoundaryCol;
211+
}

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,17 @@ export const RIGHT_BORDER_CHARS: string[] = [...BOX_CHARS.vertical, ...BOX_CHARS
6060
* Characters that indicate a boundary line (top/bottom of box)
6161
*/
6262
export const CORNER_CHARS: string[] = [...BOX_CHARS.corners, ...BOX_CHARS.asciiCorners];
63+
64+
/**
65+
* A detected inner box region within a diagram
66+
*/
67+
export interface BoxRegion {
68+
/** Line index where inner box starts (top boundary) */
69+
startLine: number;
70+
/** Line index where inner box ends (bottom boundary) */
71+
endLine: number;
72+
/** Display column where inner box starts */
73+
startCol: number;
74+
/** Display column where inner box ends */
75+
endCol: number;
76+
}

0 commit comments

Comments
 (0)