141 lines
5.8 KiB
TypeScript
141 lines
5.8 KiB
TypeScript
// Mirror of the Go `readselector` package
|
|
// (apps/backend/internal/common/readselector). OMP's read tool embeds a
|
|
// line/range/mode selector in the path ("foo.go:43-94") and can reference
|
|
// several comma-joined files in a single read ("a.yaml:1-80,b.yaml:1-80"). The
|
|
// backend keeps a multi-file read's path raw; this splits it into one openable
|
|
// entry per file (selector stripped, line range parsed) so the chat can render a
|
|
// separate link per file. Keep in sync with the Go implementation and its tests.
|
|
|
|
export type ReadFileRef = {
|
|
/** Bare, openable file path with any read selector stripped. */
|
|
path: string;
|
|
/** 1-based first line referenced by the selector, or 0 when none. */
|
|
startLine: number;
|
|
/** Contiguous line span for a closed range ("N-M" / "N+K"), else 0. */
|
|
lineCount: number;
|
|
};
|
|
|
|
type LineSpec = { startLine: number; lineCount: number };
|
|
|
|
// atoi mirrors Go strconv.Atoi for our needs: an optionally-signed decimal
|
|
// integer only (no hex, exponent, or surrounding whitespace).
|
|
function atoi(s: string): number | null {
|
|
if (!/^-?\d+$/.test(s)) return null;
|
|
return Number(s);
|
|
}
|
|
|
|
// parseLineSpec parses a single line spec: "N", "N-", "N-M", or "N+K".
|
|
function parseLineSpec(seg: string): LineSpec | null {
|
|
const plus = seg.indexOf("+");
|
|
if (plus >= 0) {
|
|
const start = atoi(seg.slice(0, plus));
|
|
const count = atoi(seg.slice(plus + 1));
|
|
if (start === null || count === null || start <= 0 || count < 0) return null;
|
|
return { startLine: start, lineCount: count };
|
|
}
|
|
const dash = seg.indexOf("-");
|
|
if (dash >= 0) {
|
|
const start = atoi(seg.slice(0, dash));
|
|
if (start === null || start <= 0) return null;
|
|
const rest = seg.slice(dash + 1);
|
|
if (rest === "") return { startLine: start, lineCount: 0 }; // "N-" open-ended
|
|
const end = atoi(rest);
|
|
if (end === null || end < start) return null;
|
|
return { startLine: start, lineCount: end - start + 1 };
|
|
}
|
|
const start = atoi(seg);
|
|
if (start === null || start <= 0) return null;
|
|
return { startLine: start, lineCount: 0 };
|
|
}
|
|
|
|
// parseLineSpecList parses a comma-separated list ("5-16,960-973"); the first
|
|
// spec drives the result.
|
|
function parseLineSpecList(part: string): LineSpec | null {
|
|
const segs = part.split(",");
|
|
let first: LineSpec | null = null;
|
|
for (let i = 0; i < segs.length; i++) {
|
|
const spec = parseLineSpec(segs[i] ?? "");
|
|
if (!spec) return null;
|
|
if (i === 0) first = spec;
|
|
}
|
|
return first;
|
|
}
|
|
|
|
// parseReadSelector validates the selector tail (everything after the file's
|
|
// first ':'). Parts joined by ':' accept combos such as "2-4:raw"; the first
|
|
// line-spec encountered drives the range. Returns a zero range (still valid) for
|
|
// mode-only selectors like "raw".
|
|
function parseReadSelector(suffix: string): LineSpec | null {
|
|
let result: LineSpec = { startLine: 0, lineCount: 0 };
|
|
let gotLine = false;
|
|
for (const part of suffix.split(":")) {
|
|
if (part === "raw" || part === "conflicts") continue;
|
|
const spec = parseLineSpecList(part);
|
|
if (!spec) return null;
|
|
if (!gotLine) {
|
|
result = spec;
|
|
gotLine = true;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// splitOne mirrors Go readselector.Split for a single file reference. A colon in
|
|
// the file's final segment that isn't a Windows drive letter ("C:\...") begins
|
|
// the selector.
|
|
function splitOne(raw: string): ReadFileRef {
|
|
const unchanged: ReadFileRef = { path: raw, startLine: 0, lineCount: 0 };
|
|
let lastSep = raw.lastIndexOf("/");
|
|
const back = raw.lastIndexOf("\\");
|
|
if (back > lastSep) lastSep = back;
|
|
const rel = raw.slice(lastSep + 1).indexOf(":");
|
|
if (rel < 0) return unchanged;
|
|
const colon = lastSep + 1 + rel;
|
|
const isWindowsDrivePrefix = colon === 1 && raw.length >= 2 && /^[A-Za-z]$/.test(raw[0] ?? "");
|
|
if (lastSep < 0 && isWindowsDrivePrefix) return unchanged;
|
|
const base = raw.slice(0, colon);
|
|
const suffix = raw.slice(colon + 1);
|
|
if (base === "" || suffix === "") return unchanged;
|
|
const parsed = parseReadSelector(suffix);
|
|
if (!parsed) return unchanged;
|
|
return { path: base, startLine: parsed.startLine, lineCount: parsed.lineCount };
|
|
}
|
|
|
|
// isStrongFile reports whether a comma segment is a self-contained file
|
|
// reference: it carries a read selector (splitOne strips it) or its basename has
|
|
// an extension. A segment that looks fileish only via a path separator ("src/foo"
|
|
// in the single filename "src/foo,bar.go") is NOT a file boundary, so a comma
|
|
// inside a filename is not mistaken for a multi-file bundle.
|
|
function isStrongFile(part: string): boolean {
|
|
if (splitOne(part).path !== part) return true;
|
|
const sep = Math.max(part.lastIndexOf("/"), part.lastIndexOf("\\"));
|
|
const base = sep >= 0 ? part.slice(sep + 1) : part;
|
|
return base.includes(".");
|
|
}
|
|
|
|
/**
|
|
* Split an omp read path that may reference several comma-joined files into one
|
|
* {@link ReadFileRef} per file. A single file (with or without a multi-range
|
|
* selector) returns exactly one entry identical to the Go `Split`; multiple
|
|
* files are only reported when every segment is a self-contained file reference
|
|
* (it carries a read selector or its basename has an extension), so a comma
|
|
* inside a directory name ("a,b/foo.go") or a filename ("src/foo,bar.go:1-20")
|
|
* stays a single entry rather than splitting into phantom files.
|
|
*/
|
|
export function splitReadFiles(raw: string): ReadFileRef[] {
|
|
const parts = raw.split(",");
|
|
const files: ReadFileRef[] = [];
|
|
let multi = true;
|
|
parts.forEach((part, i) => {
|
|
// A bare line-spec list or mode keyword is an extra range of the preceding
|
|
// file, not a new file.
|
|
const isContinuationRange =
|
|
part === "raw" || part === "conflicts" || parseLineSpecList(part) !== null;
|
|
if (i > 0 && isContinuationRange) return;
|
|
if (!isStrongFile(part)) multi = false;
|
|
files.push(splitOne(part));
|
|
});
|
|
if (multi && files.length > 1) return files;
|
|
return [splitOne(raw)];
|
|
}
|