1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2
3export interface ComposerListEdit {
4 start: number;
5 end: number;
6 replacement: string;
7 /** Expanded caret offset after the edit is applied. */
8 cursorAfter: number;
9}
10
11type ListMarker =
12 | { kind: "ordered"; indent: string; numberText: string; delimiter: "." | ")"; space: string }
13 | { kind: "task"; indent: string; space: string }
14 | { kind: "bullet"; indent: string; bullet: string; space: string };
15
16interface ParsedListMarker {
17 marker: ListMarker;
18 markerEnd: number;
19 /** Column a nested item must reach to belong to this item. */
20 contentColumn: number;
21}
22
23function parseListMarker(line: string): ParsedListMarker | null {
24 const indent = line.match(/^[ \t]*/)?.[0] ?? "";
25 const rest = line.slice(indent.length);
26 const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/);
27 if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) {
28 const space = ordered[3] ?? "";
29 return {
30 marker: {
31 kind: "ordered",
32 indent,
33 numberText: ordered[1],
34 delimiter: ordered[2] === ")" ? ")" : ".",
35 space,
36 },
37 markerEnd: indent.length + ordered[0].length,
38 contentColumn: contentColumn(indent, ordered[1].length + 1, space),
39 };
40 }
41 // The checkbox is item content, so a task's content starts after "- ".
42 const task = rest.match(/^-(\s)\[[ xX]\]((?:[ \t]+)|\s*$)/);
43 if (task && task[1] !== undefined) {
44 return {
45 marker: { kind: "task", indent, space: task[2] ?? "" },
46 markerEnd: indent.length + task[0].length,
47 contentColumn: contentColumn(indent, 1, task[1]),
48 };
49 }
50 const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/);
51 if (bullet && bullet[1] !== undefined) {
52 const space = bullet[2] ?? "";
53 return {
54 marker: { kind: "bullet", indent, bullet: bullet[1], space },
55 markerEnd: indent.length + bullet[0].length,
56 contentColumn: contentColumn(indent, 1, space),
57 };
58 }
59 return null;
60}
61
62// CommonMark content follows 1-4 columns of padding; wider padding is code, so the content
63// column sits one column past the marker.
64function contentColumn(indent: string, markerWidth: number, padding: string): number {
65 const markerEnd = indentWidth(indent) + markerWidth;
66 const space = indentWidth(padding, markerEnd);
67 return markerEnd + (space >= 1 && space <= 4 ? space : 1);
68}
69
70export function nextOrderedMarkerText(marker: string): string {
71 const numberText = marker.slice(0, -1);
72 const delimiter = marker.slice(-1);
73 const number = Number.parseInt(numberText, 10);
74 const next = Number.isSafeInteger(number)
75 ? String(number + 1).padStart(numberText.length, "0")
76 : numberText;
77 return `${next}${delimiter}`;
78}
79
80function nextMarkerText(marker: ListMarker): string {
81 if (marker.kind === "ordered") {
82 return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `;
83 }
84 if (marker.kind === "task") {
85 return `${marker.indent}- [ ] `;
86 }
87 return `${marker.indent}${marker.bullet} `;
88}
89
90// Markdown tabs advance to the next four-column stop.
91function indentWidth(line: string, fromColumn = 0): number {
92 let column = fromColumn;
93 for (const char of line) {
94 if (char === " ") column += 1;
95 else if (char === "\t") column += 4 - (column % 4);
96 else break;
97 }
98 return column - fromColumn;
99}
100
101function renumberFollowingItems(
102 value: string,
103 afterLineEnd: number,
104 marker: Extract<ListMarker, { kind: "ordered" }>,
105 tokens: ReturnType<typeof inlineTokenRanges>,
106): { end: number; text: string } | null {
107 const baseWidth = indentWidth(marker.indent);
108 const parts: string[] = [];
109 let expected = Number.parseInt(marker.numberText, 10) + 2;
110 let position = afterLineEnd;
111 let copiedUntil = afterLineEnd;
112 let tokenIndex = 0;
113 while (position < value.length && Number.isSafeInteger(expected)) {
114 const lineStart = position + 1;
115 const nextBreak = value.indexOf("\n", lineStart);
116 const lineEnd = nextBreak === -1 ? value.length : nextBreak;
117 position = lineEnd;
118 let token = tokens[tokenIndex];
119 while (token && token.end <= lineStart) {
120 tokenIndex += 1;
121 token = tokens[tokenIndex];
122 }
123 // A newline inside a chip does not start a list item.
124 if (token && token.start < lineStart) continue;
125
126 const line = value.slice(lineStart, lineEnd);
127 const width = indentWidth(line);
128 if (line.trim() === "" || width < baseWidth) break;
129 if (width > baseWidth) continue;
130 const parsed = parseListMarker(line);
131 if (parsed?.marker.kind !== "ordered" || parsed.marker.delimiter !== marker.delimiter) break;
132
133 const current = parsed.marker;
134 const numberStart = lineStart + current.indent.length;
135 const numberEnd = numberStart + current.numberText.length;
136 const replacement = current.numberText.startsWith("0")
137 ? String(expected).padStart(current.numberText.length, "0")
138 : String(expected);
139 if (replacement !== current.numberText) {
140 parts.push(value.slice(copiedUntil, numberStart), replacement);
141 copiedUntil = numberEnd;
142 }
143 expected += 1;
144 }
145 return parts.length === 0 ? null : { end: copiedUntil, text: parts.join("") };
146}
147
148function inlineTokenRanges(value: string): Array<{ start: number; end: number }> {
149 const ranges: Array<{ start: number; end: number }> = [];
150 let offset = 0;
151 for (const segment of splitPromptIntoComposerSegments(value)) {
152 const end = offset + (segment.type === "text" ? segment.text : segment.source).length;
153 if (segment.type !== "text") ranges.push({ start: offset, end });
154 offset = end;
155 }
156 return ranges;
157}
158
159function isInsideInlineToken(
160 tokens: ReturnType<typeof inlineTokenRanges>,
161 cursor: number,
162): boolean {
163 return tokens.some((range) => cursor > range.start && cursor < range.end);
164}
165
166function currentLine(value: string, cursor: number): { start: number; end: number; text: string } {
167 const start = value.lastIndexOf("\n", cursor - 1) + 1;
168 const endIndex = value.indexOf("\n", cursor);
169 const end = endIndex === -1 ? value.length : endIndex;
170 return { start, end, text: value.slice(start, end) };
171}
172
173function* listMarkersAbove(
174 value: string,
175 lineStart: number,
176 tokens: ReturnType<typeof inlineTokenRanges>,
177): Generator<ParsedListMarker> {
178 let tokenIndex = tokens.length - 1;
179 while (lineStart > 0) {
180 const lineEnd = lineStart - 1;
181 lineStart = value.lastIndexOf("\n", lineEnd - 1) + 1;
182 // A newline inside a chip does not start a list item.
183 for (let token = tokens[tokenIndex]; token; token = tokens[tokenIndex]) {
184 if (token.start >= lineStart) tokenIndex -= 1;
185 else if (token.end > lineStart) lineStart = value.lastIndexOf("\n", token.start - 1) + 1;
186 else break;
187 }
188 const parsed = parseListMarker(value.slice(lineStart, lineEnd));
189 if (!parsed) return;
190 yield parsed;
191 }
192}
193
194function parentMarker(
195 value: string,
196 lineStart: number,
197 marker: ListMarker,
198 tokens: ReturnType<typeof inlineTokenRanges>,
199): ListMarker | null {
200 const width = indentWidth(marker.indent);
201 for (const above of listMarkersAbove(value, lineStart, tokens)) {
202 if (indentWidth(above.marker.indent) < width) return above.marker;
203 }
204 return null;
205}
206
207function nestedNumber(
208 marker: ListMarker,
209 previousChild: ListMarker | undefined,
210): { replacedLength: number; text: string } {
211 if (marker.kind !== "ordered") return { replacedLength: 0, text: "" };
212 const continues =
213 previousChild?.kind === "ordered" && previousChild.delimiter === marker.delimiter;
214 return {
215 replacedLength: marker.numberText.length,
216 text: continues
217 ? nextOrderedMarkerText(`${previousChild.numberText}${marker.delimiter}`).slice(0, -1)
218 : "1",
219 };
220}
221
222export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null {
223 if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null;
224 const line = currentLine(value, cursor);
225 const parsed = parseListMarker(line.text);
226 if (!parsed || parsed.marker.space === "") return null;
227 const markerEnd = line.start + parsed.markerEnd;
228 if (cursor < markerEnd) return null;
229 const tokens = inlineTokenRanges(value);
230 if (isInsideInlineToken(tokens, cursor)) return null;
231 if (value.slice(markerEnd, line.end).trim() === "") {
232 const parent = parentMarker(value, line.start, parsed.marker, tokens);
233 const replacement = parent ? nextMarkerText(parent) : "";
234 const cursorAfter = line.start + replacement.length;
235 const end = Math.max(cursor, markerEnd);
236 const renumbered =
237 parent?.kind === "ordered" ? renumberFollowingItems(value, line.end, parent, tokens) : null;
238 if (renumbered) {
239 return {
240 start: line.start,
241 end: renumbered.end,
242 replacement: replacement + value.slice(end, line.end) + renumbered.text,
243 cursorAfter,
244 };
245 }
246 return { start: line.start, end, replacement, cursorAfter };
247 }
248 const insertion = `\n${nextMarkerText(parsed.marker)}`;
249 const cursorAfter = cursor + insertion.length;
250 const { marker } = parsed;
251 if (marker.kind === "ordered") {
252 const renumbered = renumberFollowingItems(value, line.end, marker, tokens);
253 if (renumbered) {
254 return {
255 start: cursor,
256 end: renumbered.end,
257 replacement: insertion + value.slice(cursor, line.end) + renumbered.text,
258 cursorAfter,
259 };
260 }
261 }
262 return { start: cursor, end: cursor, replacement: insertion, cursorAfter };
263}
264
265export function listIndentForTab(
266 value: string,
267 start: number,
268 end: number,
269): ComposerListEdit | null {
270 if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null;
271 const line = currentLine(value, start);
272 const parsed = parseListMarker(line.text);
273 if (!parsed) return null;
274 const tokens = inlineTokenRanges(value);
275 if (isInsideInlineToken(tokens, start)) return null;
276 const { marker } = parsed;
277 const width = indentWidth(marker.indent);
278 let previousChild: ListMarker | undefined;
279 for (const above of listMarkersAbove(value, line.start, tokens)) {
280 const aboveWidth = indentWidth(above.marker.indent);
281 if (aboveWidth < width) break;
282 if (aboveWidth > width) {
283 if (!previousChild || aboveWidth < indentWidth(previousChild.indent)) {
284 previousChild = above.marker;
285 }
286 continue;
287 }
288 const indent =
289 previousChild?.indent ?? above.marker.indent + " ".repeat(above.contentColumn - aboveWidth);
290 const number = nestedNumber(marker, previousChild);
291 const replacedEnd = line.start + marker.indent.length + number.replacedLength;
292 const replacement = indent + number.text;
293 return {
294 start: line.start,
295 end: replacedEnd,
296 replacement,
297 cursorAfter:
298 start < replacedEnd
299 ? line.start + indent.length
300 : start - replacedEnd + line.start + replacement.length,
301 };
302 }
303 return { start: line.start, end: line.start, replacement: " ", cursorAfter: start + 2 };
304}
305
306export function listOutdentForTab(
307 value: string,
308 start: number,
309 end: number,
310): ComposerListEdit | null {
311 if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null;
312 const line = currentLine(value, start);
313 const parsed = parseListMarker(line.text);
314 if (!parsed?.marker.indent) return null;
315 const tokens = inlineTokenRanges(value);
316 if (isInsideInlineToken(tokens, start)) return null;
317 const { marker } = parsed;
318 const parent = parentMarker(value, line.start, marker, tokens);
319 // With no parent above, undo one step of Tab's two-space fallback.
320 const indent = parent?.indent ?? marker.indent.replace(/^(?:\t| {1,2})/, "");
321 const continues =
322 parent?.kind === "ordered" &&
323 marker.kind === "ordered" &&
324 parent.delimiter === marker.delimiter;
325 const number = continues ? nestedNumber(marker, parent) : { replacedLength: 0, text: "" };
326 const replacedEnd = line.start + marker.indent.length + number.replacedLength;
327 const replacement = indent + number.text;
328 const cursorAfter =
329 start < replacedEnd
330 ? line.start + indent.length
331 : start - replacedEnd + line.start + replacement.length;
332 const renumbered = continues ? renumberFollowingItems(value, line.end, parent, tokens) : null;
333 if (renumbered) {
334 return {
335 start: line.start,
336 end: renumbered.end,
337 replacement: replacement + value.slice(replacedEnd, line.end) + renumbered.text,
338 cursorAfter,
339 };
340 }
341 return { start: line.start, end: replacedEnd, replacement, cursorAfter };
342}