feat(chat): recognize file:start-end as a clickable reference (#2000)

File references in chat messages can now use the 'path:start-end'
form (e.g. 'src/foo.ts:120-145'). The reference becomes clickable in
the renderer and, on click, the file opens at the start line. Range
selection is intentionally not done at this layer — the
'path:start-end' form is parsed only so the link resolves to the
correct path; navigation jumps to the start line, matching the
behavior of the existing 'path:line' form.

- Extract the file-reference parser to a dedicated module so it can
  be unit-tested without pulling in the markdown renderer's worker
  dependencies.
- Add a range branch to 'parseFileReference' and update the
  block-code path regex to recognize the new form.
- Switch the colon-form regex to a non-greedy path match so
  'path:line:col' is no longer mis-parsed as 'path:line' with the
  first numeric suffix dropped into the path.
- Add a unit test covering the new and existing parser forms.
This commit is contained in:
Catan
2026-07-06 00:58:44 +03:00
committed by GitHub
parent 708cd723f7
commit 0f3b7f96c2
3 changed files with 268 additions and 126 deletions
@@ -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 });
});
});
@@ -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('.')) {
@@ -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;