diff --git a/src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts b/src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts index 2bbb52f20d3..4faca771305 100644 --- a/src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts +++ b/src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts @@ -1,6 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { marked } from 'marked' import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization' +// Offsets marked itself treats as fenced code, so the sweep below compares the +// normalizer against the parser that decides what the file really means. +function markedCodeRanges(markdown: string): [number, number][] { + const ranges: [number, number][] = [] + let offset = 0 + for (const token of marked.lexer(markdown.replace(/\r\n|\r/g, '\n'))) { + if (token.type === 'code') { + ranges.push([offset, offset + token.raw.length]) + } + offset += token.raw.length + } + return ranges +} + afterEach(() => { vi.restoreAllMocks() }) @@ -26,6 +41,25 @@ describe('normalizeMarkdownReferenceLinks', () => { expect(normalizeMarkdownReferenceLinks(markdown)).toBe(markdown) }) + it('never removes a definition marked keeps inside a fenced code block', () => { + const definition = '[docs]: https://example.com/docs' + const survivors: string[] = [] + for (const outer of ['```', '````', '~~~', '~~~~']) { + // Fence-shaped lines that do and do not close `outer`. + for (const inner of ['```', '````', '~~~', '````js', '``` ', '```` trailing', '~~~a`b']) { + const markdown = `${outer}\ncode\n${inner}\n${definition}\n[Docs]\n${outer}\n` + const offset = markdown.indexOf(definition) + const insideCode = markedCodeRanges(markdown).some( + ([start, end]) => offset >= start && offset < end + ) + if (insideCode && !normalizeMarkdownReferenceLinks(markdown).includes(definition)) { + survivors.push(markdown) + } + } + } + expect(survivors).toEqual([]) + }) + it('scans newline-heavy documents without splitting into line arrays', () => { const split = vi.spyOn(String.prototype, 'split') const body = Array.from({ length: 5000 }, (_, index) => `line ${index + 1}`).join('\n') diff --git a/src/renderer/src/components/editor/markdown-reference-link-normalization.ts b/src/renderer/src/components/editor/markdown-reference-link-normalization.ts index 912618f16b3..1bcf3b4f379 100644 --- a/src/renderer/src/components/editor/markdown-reference-link-normalization.ts +++ b/src/renderer/src/components/editor/markdown-reference-link-normalization.ts @@ -1,3 +1,9 @@ +import { + createMarkdownFenceRangeCursor, + createMarkdownFenceTracker, + getMarkdownFenceRanges +} from './markdown-fence-scanner' + type ReferenceLinkDefinition = { label: string title: string | null @@ -65,25 +71,12 @@ function splitReferenceDefinitions(content: string): { markdown: string } { const definitions = new Map() - let activeFence: '`' | '~' | null = null - let activeFenceLength = 0 + const fence = createMarkdownFenceTracker() let markdown = '' forEachReferenceDefinitionLine(content, (line, newline) => { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/) - if (fenceMatch) { - const fenceChar = fenceMatch[1][0] as '`' | '~' - const fenceLength = fenceMatch[1].length - if (activeFence === null) { - activeFence = fenceChar - activeFenceLength = fenceLength - } else if (activeFence === fenceChar && fenceLength >= activeFenceLength) { - activeFence = null - activeFenceLength = 0 - } - } - - const definition = activeFence === null ? parseReferenceDefinition(line) : null + const isFenceLine = fence.consume(line) + const definition = isFenceLine || fence.insideFence ? null : parseReferenceDefinition(line) if (definition) { definitions.set(definition.label, definition) return @@ -148,40 +141,11 @@ function replaceReferenceLinks( ): string { let result = '' let index = 0 - let activeFence: '`' | '~' | null = null - let activeFenceLength = 0 - let isLineStart = true - const nonWhitespace = /\S/g - const fencePrefix = /(`{3,}|~{3,})/y - let fenceProbe = -1 - let fenceMatch: RegExpExecArray | null = null + const isInsideFence = createMarkdownFenceRangeCursor(getMarkdownFenceRanges(markdown)) while (index < markdown.length) { - if (isLineStart) { - // Reuse the lookahead across blank lines, preserving cross-line fence semantics. - if (index > fenceProbe) { - nonWhitespace.lastIndex = index - fenceProbe = nonWhitespace.exec(markdown)?.index ?? markdown.length - fencePrefix.lastIndex = fenceProbe - fenceMatch = fencePrefix.exec(markdown) - } - if (fenceMatch) { - const fenceChar = fenceMatch[1][0] as '`' | '~' - const fenceLength = fenceMatch[1].length - if (activeFence === null) { - activeFence = fenceChar - activeFenceLength = fenceLength - } else if (activeFence === fenceChar && fenceLength >= activeFenceLength) { - activeFence = null - activeFenceLength = 0 - } - } - } - - if (activeFence || markdown[index] !== '[' || isEscaped(markdown, index)) { - const nextChar = markdown[index] - result += nextChar - isLineStart = nextChar === '\n' + if (isInsideFence(index) || markdown[index] !== '[' || isEscaped(markdown, index)) { + result += markdown[index] index += 1 continue } @@ -189,7 +153,6 @@ function replaceReferenceLinks( const closingTextIndex = findClosingBracket(markdown, index + 1) if (closingTextIndex === -1) { result += markdown[index] - isLineStart = false index += 1 continue } @@ -198,7 +161,6 @@ function replaceReferenceLinks( const afterText = markdown[closingTextIndex + 1] if (afterText === '(') { result += markdown[index] - isLineStart = false index += 1 continue } @@ -211,7 +173,6 @@ function replaceReferenceLinks( const definition = definitions.get(label) if (definition) { result += formatInlineReferenceLink(text, definition) - isLineStart = false index = closingLabelIndex + 1 continue } @@ -220,14 +181,12 @@ function replaceReferenceLinks( const definition = definitions.get(normalizeReferenceLabel(text)) if (definition) { result += formatInlineReferenceLink(text, definition) - isLineStart = false index = closingTextIndex + 1 continue } } result += markdown[index] - isLineStart = false index += 1 } diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts index e333f7801a9..1892e18d1ca 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts @@ -1,10 +1,12 @@ import type { IRange } from 'monaco-editor' import { describe, expect, it } from 'vitest' import { getMarkdownDocLinkTarget } from './markdown-doc-links' +import { createMarkdownFenceTracker } from './markdown-fence-scanner' import { getMarkdownDocLinkDecorationRanges } from './monaco-markdown-doc-link-decorations' -// Why: the pre-offset implementation, kept verbatim as the equivalence oracle -// for the allocation-free scan that replaced it. +// Why: the pre-offset implementation, kept as the equivalence oracle for the +// allocation-free scan that replaced it. Fence detection is the shared +// scanner's contract, not this file's, so the oracle calls into it. function referenceDecorationRanges(content: string): IRange[] { const getInlineCodeSpans = (line: string): { start: number; end: number }[] => { const spans: { start: number; end: number }[] = [] @@ -26,7 +28,7 @@ function referenceDecorationRanges(content: string): IRange[] { spans.some((span) => index >= span.start && index < span.end) const ranges: IRange[] = [] - let insideFence = false + const fence = createMarkdownFenceTracker() let lineStart = 0 let lineNumber = 1 for (let index = 0; index <= content.length; index += 1) { @@ -39,11 +41,7 @@ function referenceDecorationRanges(content: string): IRange[] { const currentLineNumber = lineNumber lineNumber += 1 - if (/^\s*(```|~~~)/.test(line)) { - insideFence = !insideFence - continue - } - if (insideFence) { + if (fence.consume(line) || fence.insideFence) { continue } const inlineCodeSpans = getInlineCodeSpans(line) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts index 457213e0c12..04e932c3518 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts @@ -1,5 +1,6 @@ import type { editor, IDisposable, IRange } from 'monaco-editor' import { getMarkdownDocLinkTarget } from './markdown-doc-links' +import { createMarkdownFenceRangeCursor, getMarkdownFenceRanges } from './markdown-fence-scanner' import { forEachLine } from './text-line-offsets' const BACKTICK = 96 @@ -41,18 +42,10 @@ function isInsideSpan(index: number, spans: number[]): boolean { return false } -const FENCE_PREFIX_RE = /[^\S\n]*(?:```|~~~)/y - -function startsCodeFence(content: string, lineStart: number, lineEnd: number): boolean { - FENCE_PREFIX_RE.lastIndex = lineStart - // Bound whitespace to this line so blank runs cannot trigger repeated suffix scans. - return FENCE_PREFIX_RE.test(content) && FENCE_PREFIX_RE.lastIndex <= lineEnd -} - export function getMarkdownDocLinkDecorationRanges(content: string): IRange[] { const ranges: IRange[] = [] const inlineCodeSpans: number[] = [] - let insideFence = false + const isInsideFence = createMarkdownFenceRangeCursor(getMarkdownFenceRanges(content)) // Why: `indexOf` on the whole document would rescan the tail once per line. // Both cursors only ever move forward, and every probe position is // monotonic, so the delimiter search stays linear in document length. @@ -60,11 +53,7 @@ export function getMarkdownDocLinkDecorationRanges(content: string): IRange[] { let nextClose = content.indexOf(']]') forEachLine(content, (lineStart, lineEnd, lineNumber) => { - if (startsCodeFence(content, lineStart, lineEnd)) { - insideFence = !insideFence - return - } - if (insideFence) { + if (isInsideFence(lineStart)) { return } diff --git a/tests/e2e/markdown-fence-links.spec.ts b/tests/e2e/markdown-fence-links.spec.ts new file mode 100644 index 00000000000..449f90c5d97 --- /dev/null +++ b/tests/e2e/markdown-fence-links.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture +} from './helpers/markdown-editor-fixture' + +test('source links stay outside mixed code fences', async ({ orcaPage }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const file = await createMarkdownFixture( + context, + 'markdown-fences', + 'links', + testInfo.workerIndex, + '# Fence boundaries\n\n~~~text\n```\n[[inside-code]]\n~~~\n\n[[outside-code]]\n' + ) + try { + await openMarkdownFixture(orcaPage, context, file) + await orcaPage.evaluate(() => { + const state = window.__store!.getState() + if (!state.activeFileId) { + throw new Error('missing active file') + } + state.setMarkdownViewMode(state.activeFileId, 'source') + }) + const links = orcaPage.locator('.monaco-editor .view-lines .monaco-markdown-doc-link') + await expect(links).toHaveCount(1) + await expect(links).toHaveText('[[outside-code]]') + await testInfo.attach('fence-links', { + body: await orcaPage.screenshot({ path: testInfo.outputPath('fence-links.png') }), + contentType: 'image/png' + }) + } finally { + await cleanupMarkdownFixture(file) + } +})