Files
openchamber/packages/ui/src/components/chat/fileReferenceParser.ts
T

158 lines
5.1 KiB
TypeScript
Raw Normal View History

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;