diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts new file mode 100644 index 00000000..eb8b0e54 --- /dev/null +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseFileReference, type ParsedFileReference } from './fileReferenceParser'; + +const parse = (value: string): ParsedFileReference | null => parseFileReference(value); + +describe('parseFileReference', () => { + test('returns null for empty or whitespace input', () => { + expect(parse('')).toBeNull(); + expect(parse(' ')).toBeNull(); + }); + + test('parses bare path', () => { + expect(parse('src/foo.ts')).toEqual({ path: 'src/foo.ts' }); + }); + + test('parses path with single line', () => { + expect(parse('src/foo.ts:42')).toEqual({ path: 'src/foo.ts', line: 42 }); + }); + + test('parses path with line and column', () => { + expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 }); + }); + + test('parses path with line range', () => { + expect(parse('src/foo.ts:42-58')).toEqual({ + path: 'src/foo.ts', + line: 42, + endLine: 58, + }); + }); + + test('parses path with single-line range (start equals end)', () => { + expect(parse('src/foo.ts:10-10')).toEqual({ + path: 'src/foo.ts', + line: 10, + endLine: 10, + }); + }); + + test('rejects range with end before start', () => { + expect(parse('src/foo.ts:20-10')).toBeNull(); + }); + + test('falls back to path-only when range endpoint is non-numeric', () => { + // `src/foo.ts:10-abc` and `src/foo.ts:abc-20` are malformed; the + // line info is discarded and only the path is returned (the trailing + // `:`-suffix is stripped). + expect(parse('src/foo.ts:10-abc')).toEqual({ path: 'src/foo.ts' }); + expect(parse('src/foo.ts:abc-20')).toEqual({ path: 'src/foo.ts' }); + }); + + test('strips backtick and quote wrapping from range forms', () => { + expect(parse('`src/foo.ts:10-20`')).toEqual({ + path: 'src/foo.ts', + line: 10, + endLine: 20, + }); + expect(parse('"src/foo.ts:1-3"')).toEqual({ + path: 'src/foo.ts', + line: 1, + endLine: 3, + }); + }); + + test('parses absolute Windows path with line range', () => { + expect(parse('C:/repo/src/foo.ts:5-9')).toEqual({ + path: 'C:/repo/src/foo.ts', + line: 5, + endLine: 9, + }); + }); + + test('preserves line:col form (does not interpret as range)', () => { + expect(parse('src/foo.ts:42:8')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: 8, + }); + }); + + test('preserves hash form', () => { + expect(parse('src/foo.ts#L42C8')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: 8, + }); + expect(parse('src/foo.ts#L42')).toEqual({ + path: 'src/foo.ts', + line: 42, + }); + }); + + test('range form takes precedence over line-only when suffix matches digits-dash-digits', () => { + const result = parse('src/foo.ts:42-58'); + expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 }); + }); +}); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index db97312c..6e71125c 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -18,7 +18,7 @@ import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; -import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; +import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; import { @@ -28,6 +28,13 @@ import { type DecorateLabels, type MermaidRender, } from './markdown/decorate'; +import { + BLOCK_PATH_TOKEN_RE, + isAbsoluteReferencePath, + normalizeReferencePath, + parseFileReference, + type ParsedFileReference, +} from './fileReferenceParser'; const useCurrentMermaidTheme = () => { const themeSystem = useOptionalThemeSystem(); @@ -148,16 +155,9 @@ const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token'; const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`; const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned'; -// Matches `path[:line[:col]]` inside shell/grep-style output. Requires a file -// extension (1-8 alphanumerics) so plain words don't qualify; the path itself -// must contain at least one extension-bearing segment. -// -// Known limitation: backslash-separated Windows paths (e.g. -// `C:\Users\test\file.ts:12`) are not matched because the path character class -// does not include `\`. Compiler output inside fenced code blocks predominantly -// uses forward slashes, so this is a niche gap. The inline-code pipeline is not -// affected — it reads full text content rather than matching with a regex. -const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+){0,2}/g; +// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style +// output. The regex is defined in `./fileReferenceParser`; the inline-code +// pipeline reads full text content rather than using this regex. const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000; const FILE_REFERENCE_STAT_CONCURRENCY = 4; const FILE_REFERENCE_STAT_CACHE_MAX = 1000; @@ -177,12 +177,6 @@ const getFileReferenceLinkLimit = (): number => ( isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT ); -type ParsedFileReference = { - path: string; - line?: number; - column?: number; -}; - const KNOWN_FILE_BASENAMES = new Set([ 'dockerfile', 'makefile', @@ -192,126 +186,19 @@ const KNOWN_FILE_BASENAMES = new Set([ '.gitignore', '.npmrc', ]); -const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES) - .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|'); const normalizePath = (value: string): string => { - return normalizeFilePath(value); + return normalizeReferencePath(value); }; const isAbsolutePath = (value: string): boolean => { - return isAbsoluteFilePath(value); + return isAbsoluteReferencePath(value); }; const toAbsolutePath = (basePath: string, targetPath: string): string => { return toAbsoluteFilePath(basePath, targetPath); }; -const trimPathCandidate = (value: string): string => { - let next = (value || '').trim(); - if (!next) { - return ''; - } - - if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) { - next = next.slice(1, -1).trim(); - } - - next = next.replace(/[.,;!?]+$/g, ''); - - if (next.endsWith(')') && !next.includes('(')) { - next = next.slice(0, -1); - } - if (next.endsWith(']') && !next.includes('[')) { - next = next.slice(0, -1); - } - - return next; -}; - -const stripTrailingReference = (value: string): string => { - let next = trimPathCandidate(value); - if (!next) { - return ''; - } - - const semicolonIndex = next.indexOf(';'); - if (semicolonIndex >= 0) { - next = next.slice(0, semicolonIndex); - } - - next = next.replace(/#.*$/, ''); - - const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/); - if (extensionSuffixMatch) { - next = extensionSuffixMatch[1] ?? next; - } - - const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0 - ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i')) - : null; - if (basenameSuffixMatch) { - next = basenameSuffixMatch[1] ?? next; - } - - return trimPathCandidate(next); -}; - -const parseFileReference = (value: string): ParsedFileReference | null => { - const trimmed = trimPathCandidate(value); - if (!trimmed) { - return null; - } - - const semicolonIndex = trimmed.indexOf(';'); - const withoutSemicolonSuffix = semicolonIndex >= 0 - ? trimPathCandidate(trimmed.slice(0, semicolonIndex)) - : trimmed; - if (!withoutSemicolonSuffix) { - return null; - } - - const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i); - if (hashMatch) { - const path = stripTrailingReference(hashMatch[1] ?? ''); - const line = Number.parseInt(hashMatch[2] ?? '', 10); - const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined; - if (!path || !Number.isFinite(line)) { - return null; - } - - return { - path, - line, - column: Number.isFinite(column ?? Number.NaN) ? column : undefined, - }; - } - - const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/); - if (colonMatch) { - const path = stripTrailingReference(colonMatch[1] ?? ''); - const line = Number.parseInt(colonMatch[2] ?? '', 10); - const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined; - if (!path || !Number.isFinite(line)) { - return null; - } - - return { - path, - line, - column: Number.isFinite(column ?? Number.NaN) ? column : undefined, - }; - } - - const pathOnly = stripTrailingReference(withoutSemicolonSuffix); - if (!pathOnly) { - return null; - } - - return { path: pathOnly }; -}; - const hasFileExtension = (path: string): boolean => { const base = path.split('/').filter(Boolean).pop() ?? ''; if (!base || base.endsWith('.')) { diff --git a/packages/ui/src/components/chat/fileReferenceParser.ts b/packages/ui/src/components/chat/fileReferenceParser.ts new file mode 100644 index 00000000..b2c6b91e --- /dev/null +++ b/packages/ui/src/components/chat/fileReferenceParser.ts @@ -0,0 +1,157 @@ +import { isAbsoluteFilePath, normalizeFilePath } from '@/lib/path-utils'; + +export type ParsedFileReference = { + path: string; + line?: number; + column?: number; + endLine?: number; +}; + +const KNOWN_FILE_BASENAMES = new Set([ + 'dockerfile', + 'makefile', + 'readme', + 'license', + '.env', + '.gitignore', + '.npmrc', +]); +const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES) + .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + +export const normalizeReferencePath = (value: string): string => normalizeFilePath(value); + +export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value); + +const trimPathCandidate = (value: string): string => { + let next = (value || '').trim(); + if (!next) { + return ''; + } + + if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) { + next = next.slice(1, -1).trim(); + } + + next = next.replace(/[.,;!?]+$/g, ''); + + if (next.endsWith(')') && !next.includes('(')) { + next = next.slice(0, -1); + } + if (next.endsWith(']') && !next.includes('[')) { + next = next.slice(0, -1); + } + + return next; +}; + +const stripTrailingReference = (value: string): string => { + let next = trimPathCandidate(value); + if (!next) { + return ''; + } + + const semicolonIndex = next.indexOf(';'); + if (semicolonIndex >= 0) { + next = next.slice(0, semicolonIndex); + } + + next = next.replace(/#.*$/, ''); + + const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/); + if (extensionSuffixMatch) { + next = extensionSuffixMatch[1] ?? next; + } + + const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0 + ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i')) + : null; + if (basenameSuffixMatch) { + next = basenameSuffixMatch[1] ?? next; + } + + return trimPathCandidate(next); +}; + +export const parseFileReference = (value: string): ParsedFileReference | null => { + const trimmed = trimPathCandidate(value); + if (!trimmed) { + return null; + } + + const semicolonIndex = trimmed.indexOf(';'); + const withoutSemicolonSuffix = semicolonIndex >= 0 + ? trimPathCandidate(trimmed.slice(0, semicolonIndex)) + : trimmed; + if (!withoutSemicolonSuffix) { + return null; + } + + // Range form: `path:start-end`. Tried before the colon form so a suffix + // like `:10-20` is consumed as a range rather than truncated to a line + // number. Range and col (`:line:col`) are mutually exclusive. + const rangeMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)-(\d+)$/); + if (rangeMatch) { + const path = stripTrailingReference(rangeMatch[1] ?? ''); + const line = Number.parseInt(rangeMatch[2] ?? '', 10); + const endLine = Number.parseInt(rangeMatch[3] ?? '', 10); + if (!path || !Number.isFinite(line) || !Number.isFinite(endLine) || endLine < line) { + return null; + } + + return { path, line, endLine }; + } + + const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i); + if (hashMatch) { + const path = stripTrailingReference(hashMatch[1] ?? ''); + const line = Number.parseInt(hashMatch[2] ?? '', 10); + const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const colonMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)(?::(\d+))?$/); + if (colonMatch) { + const path = stripTrailingReference(colonMatch[1] ?? ''); + const line = Number.parseInt(colonMatch[2] ?? '', 10); + const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const pathOnly = stripTrailingReference(withoutSemicolonSuffix); + if (!pathOnly) { + return null; + } + + return { path: pathOnly }; +}; + +// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style +// output. Requires a file extension (1-8 alphanumerics) so plain words don't +// qualify; the path itself must contain at least one extension-bearing +// segment. The line suffix is either `:N`, `:N:M`, or `:N-M` (range); col and +// range are mutually exclusive. +// +// Known limitation: backslash-separated Windows paths (e.g. +// `C:\Users\test\file.ts:12`) are not matched because the path character class +// does not include `\`. Compiler output inside fenced code blocks predominantly +// uses forward slashes, so this is a niche gap. The inline-code pipeline is not +// affected — it reads full text content rather than matching with a regex. +export const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?(?::\d+)?)?/g;