authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-10-12 11:38:17+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 00:38:17+00:00
log8569ce962a072cfd3943519f81645d6e51d8a03f
treef0b303872b14f5397147ea2c1b130e0f25371761
parent7ab28a3b2e8837bb919c9b83b4b463b6ebf85c4a
signature Signed by PGP key B5690EEEBB952194

perf(web): avoid quadratic markdown link scans (#18387)

Co-authored-by: SAPHID <saphid@users.noreply.github.com> Co-authored-by: GPT-6.1 Sol <noreply@openai.com>

2 files changed, 86 insertions(+), 4 deletions(-)

packages/shared/src/markdownLinks.test.ts+19
...@@ -222,6 +222,25 @@ describe("isWindowsDrivePathHref", () => {...@@ -222,6 +222,25 @@ describe("isWindowsDrivePathHref", () => {
222});222});
223223
224describe("extractMarkdownLinkHrefs", () => {224describe("extractMarkdownLinkHrefs", () => {
225 it.each([
226 ["[".repeat(32_000), []],
227 ["[](a".repeat(16_000), []],
228 ["[](<a".repeat(16_000), []],
229 ["[x](https://e.com/".repeat(8_000), []],
230 ["[a](<foo)bar> invalid [b](ok)", ["<foo", "ok"]],
231 ["[a](foo \"mixed title') [b](< >)", ["foo"]],
232 ["[a](<one [b](two)> invalid [c](ok)", ["two", "ok"]],
233 ["```text\n" + "[unfinished item\n".repeat(8_000) + "```", []],
234 ["[[[nested](src/main.ts) [unfinished", ["src/main.ts"]],
235 ["[outer [inner] invalid [valid](src/main.ts)", ["src/main.ts"]],
236 ["[bad](missing title [valid](src/main.ts)", ["src/main.ts"]],
237 ["```text\n[link](src/main.ts)\n```", ["src/main.ts"]],
238 ["[empty]() [valid](src/main.ts 'title')", ["src/main.ts"]],
239 ['[angle](<src/My File.ts> "title")', ["src/My File.ts"]],
240 ])("extracts links from incomplete and nested text", (markdown, hrefs) => {
241 expect(extractMarkdownLinkHrefs(markdown)).toEqual(hrefs);
242 });
243
225 it("extracts angle-bracketed paths containing spaces", () => {244 it("extracts angle-bracketed paths containing spaces", () => {
226 expect(245 expect(
227 extractMarkdownLinkHrefs(246 extractMarkdownLinkHrefs(
packages/shared/src/markdownLinks.ts+67-4
...@@ -322,13 +322,76 @@ export function extractInlineCodeSpans(text: string): string[] {...@@ -322,13 +322,76 @@ export function extractInlineCodeSpans(text: string): string[] {
322 return spans;322 return spans;
323}323}
324324
325const MARKDOWN_LINK_HREF_PATTERN =325/** Reuses delimiter searches when successive candidates fall in the same run. */
326 /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;326function markdownRunEndFinder(markdown: string, delimiter: RegExp) {
327 let runStart = -1;
328 let runEnd = -1;
329 return (start: number) => {
330 if (start >= runStart && start <= runEnd) return runEnd;
331 delimiter.lastIndex = start;
332 runStart = start;
333 runEnd = delimiter.exec(markdown)?.index ?? markdown.length;
334 return runEnd;
335 };
336}
337
338function markdownLinkTailScanner(markdown: string) {
339 const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
340 const titleEnd = markdownRunEndFinder(markdown, /["']/g);
341 const titleWhitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
342 return (start: number) => {
343 const end = whitespaceEnd(start);
344 if (markdown[end] === ")") return end + 1;
345 if (end === start || (markdown[end] !== '"' && markdown[end] !== "'")) return null;
346 const quoteEnd = titleEnd(end + 1);
347 if (quoteEnd === markdown.length) return null;
348 const close = titleWhitespaceEnd(quoteEnd + 1);
349 return markdown[close] === ")" ? close + 1 : null;
350 };
351}
352
353function markdownLinkDestinationScanner(markdown: string) {
354 const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
355 const angleEnd = markdownRunEndFinder(markdown, /[>\n]/g);
356 const bareEnd = markdownRunEndFinder(markdown, /[\s)]/g);
357 // Separate caches keep each branch's searches moving forward even when an
358 // angle destination fails and falls back to an ordinary destination.
359 const angleTail = markdownLinkTailScanner(markdown);
360 const bareTail = markdownLinkTailScanner(markdown);
361 return (start: number) => {
362 if (markdown[start] !== "(") return null;
363 const hrefStart = whitespaceEnd(start + 1);
364 if (markdown[hrefStart] === "<") {
365 const hrefEnd = angleEnd(hrefStart + 1);
366 if (hrefEnd > hrefStart + 1 && markdown[hrefEnd] === ">") {
367 const end = angleTail(hrefEnd + 1);
368 if (end !== null) return { hrefStart: hrefStart + 1, hrefEnd, end };
369 }
370 }
371 const hrefEnd = bareEnd(hrefStart);
372 if (hrefEnd === hrefStart) return null;
373 const end = bareTail(hrefEnd);
374 return end === null ? null : { hrefStart, hrefEnd, end };
375 };
376}
327377
328export function extractMarkdownLinkHrefs(markdown: string): string[] {378export function extractMarkdownLinkHrefs(markdown: string): string[] {
329 const hrefs: string[] = [];379 const hrefs: string[] = [];
330 for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {380 const scanDestination = markdownLinkDestinationScanner(markdown);
331 const href = (match[1] ?? match[2])?.trim();381 let cursor = 0;
382 while (cursor < markdown.length) {
383 const labelStart = markdown.indexOf("[", cursor);
384 if (labelStart === -1) break;
385 const labelEnd = markdown.indexOf("]", labelStart + 1);
386 if (labelEnd === -1) break;
387
388 // Nested opening brackets share this closing bracket. On failure, skip the
389 // whole label instead of rescanning its suffix from each opening bracket.
390 cursor = labelEnd + 1;
391 const destination = scanDestination(cursor);
392 if (!destination) continue;
393 cursor = destination.end;
394 const href = markdown.slice(destination.hrefStart, destination.hrefEnd).trim();
332 if (href) hrefs.push(href);395 if (href) hrefs.push(href);
333 }396 }
334 return hrefs;397 return hrefs;