fix(app): preserve parser-owned links and streaming tilde tails

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:02 +02:00
parent aeaee471a6
commit 9b7a6ba782
3 changed files with 89 additions and 29 deletions
@@ -9,6 +9,24 @@ import {
const noFiles = () => false;
describe("source-preserving prose", () => {
it.each([
[
"https://example.com/?tags[]=a",
"[https://example.com/?tags[]=a](<https://example.com/?tags%5B%5D=a>)",
],
["`src/[...]/file.ts`", "`src/[...]/file.ts`"],
["[text []](https://example.com)", "[text []](https://example.com)"],
["https://example.com/a*b*c", "[https://example.com/a](<https://example.com/a>)*b*c"],
])("uses parser-owned link ranges: %s", (source, expected) => {
expect(prepareProse([source], noFiles)).toBe(expected);
});
it.each([
["Use ~", "Use "],
["Use ~/", "Use \\~/"],
["Use ~/.config", "Use \\~/.config"],
])("keeps provisional tilde escapes attached: %s", (source, expected) => {
expect(prepareProse([source], noFiles, true)).toBe(expected);
});
it.each([
["Paths ~/.config and ~/.local are used.", "Paths \\~/.config and \\~/.local are used."],
["~/a ~/b", "\\~/a \\~/b"],
@@ -276,7 +276,11 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
preserveLinkScope(context, span, raw, link);
} else if (code && raw.startsWith("`")) {
prepareCode(context, span, raw, code);
} else if (context.streaming && (raw.startsWith("[") || raw.startsWith("!["))) {
} else if (
context.streaming &&
!contains(context.referenceRanges, span.start) &&
(raw.startsWith("[") || raw.startsWith("!["))
) {
prepareUnfinishedLink(context, span, raw);
} else if (
context.streaming &&
@@ -284,6 +288,7 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
/^(?:\*{1,3}|_{1,3}|~{1,2}|`+)$/.test(raw)
) {
context.edits.push({ start: span.start, end: span.end, text: "" });
protect(context.protectedRanges, span);
}
}
function preserveUnresolvedReferences(context: InlinePreparation) {
@@ -352,26 +357,51 @@ function prepareLinkifiedLinks(context: InlinePreparation) {
if (!links.length) return;
const destinations = new Map(links.map((link) => [link.attrGet("href"), link]));
const { decoded, positions } = linkifySource(context);
for (const match of context.parser.linkify.match(decoded) ?? []) {
const start = positions[match.index]!;
const end = positions[match.lastIndex]!;
if (contains(context.referenceRanges, start)) continue;
const link = destinations.get(context.parser.normalizeLink(match.url));
if (!link) continue;
const destination = link.attrGet("href")!;
const payload: AssistantFileLinkSource = {
href: destination,
text: match.text,
markup: "linkify",
sourceInfo: "auto",
};
const href = context.canResolveCode(match.text)
? FILE_LINK_SCHEME + encodeURIComponent(JSON.stringify(payload))
: destination;
insert(context, start, "[");
insert(context, end, `](<${href}>)`);
let cursor = 0;
for (const text of context.trace.linkifyTexts) {
if (!text) continue;
let offset = decoded.indexOf(text, cursor);
while (
offset >= 0 &&
contains(context.referenceRanges, positions[offset]!, positions[offset + text.length]!)
)
offset = decoded.indexOf(text, offset + 1);
if (offset < 0) continue;
cursor = offset + text.length;
for (const match of context.parser.linkify.match(text) ?? []) {
const link = destinations.get(context.parser.normalizeLink(match.url));
if (!link) continue;
wrapLinkifiedMatch(
context,
positions[offset + match.index]!,
positions[offset + match.lastIndex]!,
link.attrGet("href")!,
match.text,
);
}
}
}
function wrapLinkifiedMatch(
context: InlinePreparation,
start: number,
end: number,
destination: string,
text: string,
) {
const payload: AssistantFileLinkSource = {
href: destination,
text,
markup: "linkify",
sourceInfo: "auto",
};
const href = context.canResolveCode(text)
? FILE_LINK_SCHEME + encodeURIComponent(JSON.stringify(payload))
: destination;
protect(context.referenceRanges, { start, end });
insert(context, start, "[");
insert(context, end, `](<${href}>)`);
}
function sourcePositions(
token: Token,
heading: Token | undefined,
@@ -448,16 +478,20 @@ function compileBlock(
task: tokens[index - 2]?.type === "list_item_open",
edits: [],
};
// Link/code ownership is established before either references or provisional
// brackets can be rewritten. Linkify uses the parser's emphasis-split text tokens.
for (const span of trace.spans)
if (span.tokens.some((child) => ["link_open", "code_inline", "image"].includes(child.type)))
protect(context.referenceRanges, span);
prepareLinkifiedLinks(context);
for (const span of trace.spans) prepareSpan(context, span);
if (trace.trim) {
const tail = { start: token.content.length - trace.trim, end: token.content.length };
context.edits.push({ ...tail, text: "" });
protect(context.protectedRanges, tail);
}
preserveUnresolvedReferences(context);
prepareText(context);
prepareLinkifiedLinks(context);
if (trace.trim)
context.edits.push({
start: token.content.length - trace.trim,
end: token.content.length,
text: "",
});
if (trace.suffix) insert(context, token.content.length, trace.suffix);
const positions = sourcePositions(token, heading, lines, lineOffsets);
for (const edit of context.edits)
@@ -9,6 +9,7 @@ export interface InlineSpan {
}
export interface InlineTrace {
spans: InlineSpan[];
linkifyTexts: string[];
suffix: string;
trim: number;
}
@@ -21,7 +22,7 @@ export function createSourceParser(streaming: boolean) {
const inlineTraces = new WeakMap<Token[], InlineTrace>();
const parseInline = parser.inline.parse.bind(parser.inline);
parser.inline.parse = (source, md, env, output) => {
const trace: InlineTrace = { spans: [], suffix: "", trim: 0 };
const trace: InlineTrace = { spans: [], linkifyTexts: [], suffix: "", trim: 0 };
parseInline(source, md, { ...env, [TRACE]: trace }, output);
inlineTraces.set(output, trace);
};
@@ -70,8 +71,15 @@ export function createSourceParser(streaming: boolean) {
}
parser.core.ruler.after("inline", "prose_inline_traces", (state) => {
for (const token of state.tokens) {
if (token.type === "inline" && token.children)
traces.set(token, inlineTraces.get(token.children)!);
if (token.type !== "inline" || !token.children) continue;
const trace = inlineTraces.get(token.children)!;
let linkDepth = 0;
for (const child of token.children) {
if (child.type === "link_open") linkDepth++;
if (child.type === "link_close") linkDepth--;
if (child.type === "text" && linkDepth === 0) trace.linkifyTexts.push(child.content);
}
traces.set(token, trace);
}
});
return { parser, traces };