mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-11-16 00:20:37 +08:00
159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
/// Cutting a single-file unified diff down to the few lines a code comment is
|
|
/// about, as text.
|
|
///
|
|
/// Text rather than the structured `Diff` because that is what `@pierre/diffs`
|
|
/// renders from: a snippet is just a smaller patch, and a patch with one hunk in
|
|
/// it parses exactly like any other. The alternative — slicing the structured
|
|
/// diff and asking the renderer to draw a hunk out of context — is what this
|
|
/// replaced.
|
|
|
|
/// `@@ -oldStart,oldCount +newStart,newCount @@`, with the counts optional (git
|
|
/// omits them for a single line) and anything after the closing `@@` — the
|
|
/// enclosing function git sometimes names — ignored.
|
|
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
|
|
interface Hunk {
|
|
/// Kept verbatim so an unclamped hunk goes back out exactly as git wrote it,
|
|
/// including the enclosing function name after the closing `@@`.
|
|
header: string;
|
|
oldStart: number;
|
|
newStart: number;
|
|
/// Body lines, each still carrying its ` `, `+` or `-` prefix. A
|
|
/// `\ No newline at end of file` marker counts as part of the line above it.
|
|
lines: string[];
|
|
}
|
|
|
|
/// The lines of a patch before its first hunk (the `diff --git`, `index`, `---`
|
|
/// and `+++` headers), and then each hunk.
|
|
function parse(patch: string): { preamble: string[]; hunks: Hunk[] } {
|
|
const preamble: string[] = [];
|
|
const hunks: Hunk[] = [];
|
|
let current: Hunk | undefined;
|
|
for (const line of patch.split("\n")) {
|
|
const header = HUNK_HEADER.exec(line);
|
|
if (header) {
|
|
current = {
|
|
header: line,
|
|
oldStart: Number(header[1]),
|
|
newStart: Number(header[3]),
|
|
lines: [],
|
|
};
|
|
hunks.push(current);
|
|
continue;
|
|
}
|
|
if (!current) {
|
|
preamble.push(line);
|
|
continue;
|
|
}
|
|
// A trailing empty line from the split, and anything after the last hunk
|
|
// that is not a body line, is not part of it.
|
|
if (line === "") continue;
|
|
current.lines.push(line);
|
|
}
|
|
return { preamble, hunks };
|
|
}
|
|
|
|
/// Which line of which side each body line is, so a slice can be renumbered.
|
|
/// `undefined` for the side a line does not appear on.
|
|
interface Numbered {
|
|
line: string;
|
|
old?: number;
|
|
new?: number;
|
|
}
|
|
|
|
function number(hunk: Hunk): Numbered[] {
|
|
const numbered: Numbered[] = [];
|
|
let oldLine = hunk.oldStart;
|
|
let newLine = hunk.newStart;
|
|
for (const line of hunk.lines) {
|
|
if (line.startsWith("\\")) {
|
|
// "\ No newline at end of file" — belongs to whichever line came before.
|
|
numbered.push({ line });
|
|
continue;
|
|
}
|
|
if (line.startsWith("+")) {
|
|
numbered.push({ line, new: newLine });
|
|
newLine += 1;
|
|
} else if (line.startsWith("-")) {
|
|
numbered.push({ line, old: oldLine });
|
|
oldLine += 1;
|
|
} else {
|
|
numbered.push({ line, old: oldLine, new: newLine });
|
|
oldLine += 1;
|
|
newLine += 1;
|
|
}
|
|
}
|
|
return numbered;
|
|
}
|
|
|
|
function join(preamble: string[], header: string, lines: string[]): string {
|
|
return [...preamble, header, ...lines, ""].join("\n");
|
|
}
|
|
|
|
/// Rebuild a hunk header for a slice of a hunk's lines, since the positions and
|
|
/// counts of both sides change with it.
|
|
function render(preamble: string[], lines: Numbered[]): string {
|
|
let oldStart: number | undefined;
|
|
let newStart: number | undefined;
|
|
let oldCount = 0;
|
|
let newCount = 0;
|
|
for (const { old, new: next } of lines) {
|
|
if (old !== undefined) {
|
|
oldStart ??= old;
|
|
oldCount += 1;
|
|
}
|
|
if (next !== undefined) {
|
|
newStart ??= next;
|
|
newCount += 1;
|
|
}
|
|
}
|
|
// A hunk with nothing on a side still has to name a position on it, and git
|
|
// writes 0 there.
|
|
const header = `@@ -${oldCount === 0 ? 0 : (oldStart ?? 1)},${oldCount} +${newCount === 0 ? 0 : (newStart ?? 1)},${newCount} @@`;
|
|
return join(
|
|
preamble,
|
|
header,
|
|
lines.map(({ line }) => line),
|
|
);
|
|
}
|
|
|
|
/// Cut `patch` down to `context` lines either side of `[start, end)` on one
|
|
/// side, within the hunk that covers it.
|
|
///
|
|
/// `undefined` when no hunk covers the range, which means the comment is
|
|
/// anchored outside the diff — a stale comment on a line a later revision
|
|
/// changed, say.
|
|
export function sliceForRange(
|
|
patch: string,
|
|
side: "old" | "new",
|
|
start: number,
|
|
end: number,
|
|
context = 3,
|
|
): string | undefined {
|
|
const { preamble, hunks } = parse(patch);
|
|
for (const hunk of hunks) {
|
|
const lines = number(hunk);
|
|
const covered: number[] = [];
|
|
lines.forEach(({ old, new: next }, index) => {
|
|
const lineNumber = side === "new" ? next : old;
|
|
if (lineNumber !== undefined && lineNumber >= start && lineNumber < end) {
|
|
covered.push(index);
|
|
}
|
|
});
|
|
if (covered.length === 0) continue;
|
|
|
|
const first = Math.max(0, covered[0] - context);
|
|
const last = Math.min(
|
|
lines.length - 1,
|
|
covered[covered.length - 1] + context,
|
|
);
|
|
// The whole hunk, verbatim, when it is already no bigger than the window —
|
|
// which keeps git's own header, including the enclosing function name.
|
|
if (first === 0 && last === lines.length - 1) {
|
|
return join(preamble, hunk.header, hunk.lines);
|
|
}
|
|
return render(preamble, lines.slice(first, last + 1));
|
|
}
|
|
return undefined;
|
|
}
|