Skip to content

Commit a45f036

Browse files
PR Batch Testerclaude
andcommitted
fix(mobile): track list nesting and fences like the renderer does
Walk each line's quote, bullet, and number markers instead of matching a regex, so shifted sibling items, several markers on one line, fences after a list marker, quoted fences, and fence closers with trailing text all follow CommonMark. Open lists live on a stack keyed by content column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 88d080d commit a45f036

2 files changed

Lines changed: 151 additions & 31 deletions

File tree

‎apps/mobile/src/lib/orderedListMarkers.test.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ describe("wideOrderedListMarkerDigits", () => {
1515
expect(wideOrderedListMarkerDigits(`${numberedList(60)}\n\ntext\n\n${numberedList(50)}`)).toBe(
1616
0,
1717
);
18-
const withSubLists = Array.from(
19-
{ length: 60 },
20-
(_, index) => `${index + 1}. item\n 1. sub\n 2. sub`,
21-
).join("\n");
18+
const withSubLists = Array.from({ length: 60 }, (_, index) => {
19+
const indent = " ".repeat(String(index + 1).length + 2);
20+
return `${index + 1}. item\n${indent}1. sub\n${indent}2. sub`;
21+
}).join("\n");
2222
expect(wideOrderedListMarkerDigits(withSubLists)).toBe(0);
2323
});
2424

@@ -33,6 +33,10 @@ describe("wideOrderedListMarkerDigits", () => {
3333
expect(wideOrderedListMarkerDigits(numberedList(101, () => "1."))).toBe(3);
3434
expect(wideOrderedListMarkerDigits(`${numberedList(60)}\n\n${numberedList(50)}`)).toBe(3);
3535
expect(wideOrderedListMarkerDigits("99. first\n[^note]: text\n1. second")).toBe(3);
36+
expect(wideOrderedListMarkerDigits("99. first\n 1. second")).toBe(3);
37+
expect(wideOrderedListMarkerDigits("> 99. first\n> 1. second")).toBe(3);
38+
// Under a two-digit marker a three-space indent is not nested, so it counts on.
39+
expect(wideOrderedListMarkerDigits("98. a\n 1. b\n 1. c")).toBe(3);
3640
expect(wideOrderedListMarkerDigits("007. agent")).toBe(0);
3741
});
3842

@@ -41,6 +45,8 @@ describe("wideOrderedListMarkerDigits", () => {
4145
expect(wideOrderedListMarkerDigits("> > 250. quoted")).toBe(3);
4246
expect(wideOrderedListMarkerDigits("1. a\n\n 100. nested")).toBe(3);
4347
expect(wideOrderedListMarkerDigits(" code\n\n100. item")).toBe(3);
48+
expect(wideOrderedListMarkerDigits("100. 1. nested")).toBe(3);
49+
expect(wideOrderedListMarkerDigits("99. 1. nested\n1. next")).toBe(3);
4450
});
4551

4652
it("stays linear on long runs of bullets and spaces", () => {
@@ -51,5 +57,10 @@ describe("wideOrderedListMarkerDigits", () => {
5157
expect(wideOrderedListMarkerDigits("- a\n\n```\n123456789. example\n```")).toBe(0);
5258
expect(wideOrderedListMarkerDigits("~~~~\n250. a\n~~~\n300. b\n~~~~\n1. c")).toBe(0);
5359
expect(wideOrderedListMarkerDigits("```\n250. a\n```\n\n150. b")).toBe(3);
60+
expect(wideOrderedListMarkerDigits("1. ```sh\n 250. a\n ```\n\n150. b")).toBe(3);
61+
expect(wideOrderedListMarkerDigits("- ```text\n code\n ```\n\n100. item")).toBe(3);
62+
expect(wideOrderedListMarkerDigits("```text\n```js\n```\n\n100. item")).toBe(3);
63+
expect(wideOrderedListMarkerDigits("> ```text\n> 123456789. example\n> ```\n\n- a")).toBe(0);
64+
expect(wideOrderedListMarkerDigits("```\n ```\n250. still code\n```\n1. a")).toBe(0);
5465
});
5566
});

‎apps/mobile/src/lib/orderedListMarkers.ts‎

Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,92 @@
1-
// A line the renderer could read as an ordered-list item, including one nested
2-
// after quote or list markers. Group 1 is everything before the number, so its
3-
// length tells nested lists apart. Indented code also matches; that only widens.
4-
// Each repeat starts with a marker or `>`, which keeps matching linear.
5-
const ORDERED_MARKER_LINE =
6-
/^([ \t>]*(?:(?:[-+*]|\d{1,9}[.)])[ \t]+(?:>[ \t>]*)?)*)(\d{1,9})[.)](?:[ \t\r]|$)/;
7-
const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})/;
1+
interface ListMarker {
2+
readonly column: number;
3+
readonly contentColumn: number;
4+
readonly number: number;
5+
}
6+
7+
interface LinePrefix {
8+
readonly quoteDepth: number;
9+
/** Whether any bullet or ordered-list marker opens the line. */
10+
readonly hasListMarker: boolean;
11+
readonly orderedMarkers: ReadonlyArray<ListMarker>;
12+
/** Column where the innermost container's content starts. */
13+
readonly contentColumn: number;
14+
readonly restColumn: number;
15+
readonly rest: string;
16+
}
17+
18+
const isBlank = (char: string | undefined) => char === " " || char === "\t";
19+
const isDigit = (char: string | undefined) => char !== undefined && char >= "0" && char <= "9";
20+
21+
// Walks the quote, bullet, and ordered-list markers at the start of a line,
22+
// with tab stops of 4. Content starts 1-4 columns after a marker, as in CommonMark.
23+
function readLinePrefix(line: string): LinePrefix {
24+
const orderedMarkers: ListMarker[] = [];
25+
let quoteDepth = 0;
26+
let hasListMarker = false;
27+
let index = 0;
28+
let column = 0;
29+
let contentColumn = 0;
30+
// Set after a list marker until the following spaces fix its content column.
31+
let pending: { markerEnd: number; number: number | null; column: number } | null = null;
32+
for (;;) {
33+
while (isBlank(line[index])) {
34+
column = line[index] === "\t" ? column + 4 - (column % 4) : column + 1;
35+
index += 1;
36+
}
37+
if (pending) {
38+
const gap = column - pending.markerEnd;
39+
contentColumn = gap >= 1 && gap <= 4 && index < line.length ? column : pending.markerEnd + 1;
40+
if (pending.number !== null) {
41+
orderedMarkers.push({ column: pending.column, contentColumn, number: pending.number });
42+
}
43+
pending = null;
44+
}
45+
const markerColumn = column;
46+
const char = line[index];
47+
if (char === ">") {
48+
quoteDepth += 1;
49+
index += 1;
50+
column += 1;
51+
contentColumn = column + (isBlank(line[index]) ? 1 : 0);
52+
continue;
53+
}
54+
if ((char === "-" || char === "+" || char === "*") && isBlank(line[index + 1])) {
55+
hasListMarker = true;
56+
index += 1;
57+
column += 1;
58+
pending = { markerEnd: column, number: null, column: markerColumn };
59+
continue;
60+
}
61+
let digitsEnd = index;
62+
while (digitsEnd - index < 9 && isDigit(line[digitsEnd])) digitsEnd += 1;
63+
const delimiter = line[digitsEnd];
64+
const after = line[digitsEnd + 1];
65+
if (
66+
digitsEnd > index &&
67+
(delimiter === "." || delimiter === ")") &&
68+
(isBlank(after) || after === undefined || after === "\r")
69+
) {
70+
hasListMarker = true;
71+
column += digitsEnd + 1 - index;
72+
const number = Number(line.slice(index, digitsEnd));
73+
index = digitsEnd + 1;
74+
pending = { markerEnd: column, number, column: markerColumn };
75+
continue;
76+
}
77+
return {
78+
quoteDepth,
79+
hasListMarker,
80+
orderedMarkers,
81+
contentColumn,
82+
restColumn: column,
83+
rest: line.slice(index),
84+
};
85+
}
86+
}
87+
88+
const FENCE_OPENER = /^(`{3,}|~{3,})(.*)$/;
89+
const FENCE_CLOSER = /^(`{3,}|~{3,})[ \t\r]*$/;
890

991
/**
1092
* Digit count of the widest number the renderer could draw for an ordered list
@@ -13,34 +95,61 @@ const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})/;
1395
* Lists number their items up from the first marker, so each item counts as at
1496
* least one more than the previous item of its list. This is one cheap pass
1597
* while a reply streams. When a line is ambiguous it keeps counting, so it can
16-
* overestimate (a slightly wider gutter) but never clip a number.
98+
* overestimate (a slightly wider gutter) but should not clip a number.
1799
*/
18100
export function wideOrderedListMarkerDigits(markdown: string): number {
19-
// Last number per open list, keyed by the marker's column.
20-
const lists = new Map<number, number>();
21-
let openFence: string | null = null;
101+
// Open ordered lists, outermost first.
102+
let lists: Array<{ contentColumn: number; number: number }> = [];
103+
let fence: { char: string; length: number; quoteDepth: number; column: number } | null = null;
22104
let afterBlankLine = false;
23105
let widest = 0;
24106
for (const line of markdown.split("\n")) {
25-
const fence = CODE_FENCE.exec(line)?.[1];
26-
if (openFence !== null) {
27-
if (fence && fence[0] === openFence[0] && fence.length >= openFence.length) openFence = null;
28-
continue;
107+
const prefix = readLinePrefix(line);
108+
if (fence !== null) {
109+
// A fence ends at its closer, or when the quote holding it ends.
110+
if (prefix.quoteDepth >= fence.quoteDepth) {
111+
const closer = FENCE_CLOSER.exec(prefix.rest)?.[1];
112+
if (
113+
!prefix.hasListMarker &&
114+
prefix.restColumn - fence.column <= 3 &&
115+
closer?.[0] === fence.char &&
116+
closer.length >= fence.length
117+
) {
118+
fence = null;
119+
}
120+
continue;
121+
}
122+
fence = null;
29123
}
30-
if (fence) {
31-
openFence = fence;
32-
continue;
124+
for (const marker of prefix.orderedMarkers) {
125+
// Leave lists whose items this marker sits outside of.
126+
while (lists.length >= 2 && marker.column < lists[lists.length - 2]!.contentColumn) {
127+
lists.pop();
128+
}
129+
const list = lists.at(-1);
130+
if (list && marker.column < list.contentColumn) {
131+
list.number = Math.max(marker.number, list.number + 1);
132+
list.contentColumn = marker.contentColumn;
133+
} else {
134+
lists.push({ contentColumn: marker.contentColumn, number: marker.number });
135+
}
136+
widest = Math.max(widest, lists.at(-1)!.number);
33137
}
34-
const marker = ORDERED_MARKER_LINE.exec(line);
35-
if (marker) {
36-
const column = marker[1]!.length;
37-
for (const key of lists.keys()) if (key > column) lists.delete(key);
38-
const number = Math.max(Number(marker[2]), (lists.get(column) ?? -1) + 1);
39-
lists.set(column, number);
40-
widest = Math.max(widest, number);
41-
} else if (afterBlankLine && /^[^ \t\r]/.test(line)) {
138+
const opener = FENCE_OPENER.exec(prefix.rest);
139+
if (
140+
opener &&
141+
prefix.restColumn - prefix.contentColumn <= 3 &&
142+
!(opener[1]!.startsWith("`") && opener[2]!.includes("`"))
143+
) {
144+
fence = {
145+
char: opener[1]![0]!,
146+
length: opener[1]!.length,
147+
quoteDepth: prefix.quoteDepth,
148+
column: prefix.restColumn,
149+
};
150+
} else if (afterBlankLine && !prefix.hasListMarker && /^[^ \t\r]/.test(line)) {
42151
// Unindented text after a blank line cannot belong to any list item.
43-
lists.clear();
152+
lists = [];
44153
}
45154
afterBlankLine = line.trim() === "";
46155
}

0 commit comments

Comments
 (0)