158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
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;
|