Files
kandev/apps/web/lib/read-selector.ts

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)];
}