1import type { FileDiffMetadata } from "@pierre/diffs";
2import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts";
3
4export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number {
5 return Math.max(Math.min(current + delta, stopCount - 1), 0);
6}
7
8export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray<string> {
9 return [
10 ...new Set([
11 ...stop.excerpts.map((excerpt) => excerpt.path),
12 ...stop.files.map((file) => file.path),
13 ]),
14 ];
15}
16
17export function stopSubline(stop: PullRequestReviewGuideStop): string {
18 const count = stopFilePaths(stop).length;
19 if (count === 0) return "Context, no code";
20 return count === 1 ? "1 file" : `${count} files`;
21}
22
23function noteMarker(index: number): string {
24 return index < 26 ? String.fromCharCode(65 + index) : String(index + 1);
25}
26
27export interface DiffRow {
28 readonly kind: "context" | "addition" | "deletion";
29 readonly oldLine: number | null;
30 readonly newLine: number | null;
31 readonly oldAt: number;
32 readonly newAt: number;
33 readonly text: string;
34 readonly hunk: number;
35}
36
37export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray<DiffRow> {
38 const rows: DiffRow[] = [];
39 file.hunks.forEach((hunk, hunkIndex) => {
40 let oldAt = hunk.deletionStart;
41 let newAt = hunk.additionStart;
42 for (const content of hunk.hunkContent) {
43 if (content.type === "context") {
44 for (let offset = 0; offset < content.lines; offset += 1) {
45 rows.push({
46 kind: "context",
47 oldLine: oldAt,
48 newLine: newAt,
49 oldAt,
50 newAt,
51 text: file.additionLines[content.additionLineIndex + offset] ?? "",
52 hunk: hunkIndex,
53 });
54 oldAt += 1;
55 newAt += 1;
56 }
57 continue;
58 }
59 for (let offset = 0; offset < content.deletions; offset += 1) {
60 rows.push({
61 kind: "deletion",
62 oldLine: oldAt,
63 newLine: null,
64 oldAt,
65 newAt,
66 text: file.deletionLines[content.deletionLineIndex + offset] ?? "",
67 hunk: hunkIndex,
68 });
69 oldAt += 1;
70 }
71 for (let offset = 0; offset < content.additions; offset += 1) {
72 rows.push({
73 kind: "addition",
74 oldLine: null,
75 newLine: newAt,
76 oldAt,
77 newAt,
78 text: file.additionLines[content.additionLineIndex + offset] ?? "",
79 hunk: hunkIndex,
80 });
81 newAt += 1;
82 }
83 }
84 });
85 return rows;
86}
87
88export function excerptRows(
89 rows: ReadonlyArray<DiffRow>,
90 excerpt: Pick<PullRequestReviewGuideExcerpt, "side" | "start" | "end">,
91): ReadonlyArray<DiffRow> {
92 const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine);
93 const inRange = (row: DiffRow) => {
94 const line = lineOf(row);
95 return line !== null && line >= excerpt.start && line <= excerpt.end;
96 };
97 let first = rows.findIndex(inRange);
98 if (first === -1) return [];
99 let last = rows.findLastIndex(inRange);
100 const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk;
101 if (excerpt.side === "new" && rows[first]?.kind === "addition") {
102 while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) {
103 first -= 1;
104 }
105 }
106 if (excerpt.side === "old" && rows[last]?.kind === "deletion") {
107 while (
108 last < rows.length - 1 &&
109 rows[last + 1]?.kind === "addition" &&
110 sameHunk(last + 1, last)
111 ) {
112 last += 1;
113 }
114 }
115 return rows.slice(first, last + 1);
116}
117
118const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const;
119
120export function rowsToPatch(
121 path: string,
122 previousPath: string,
123 rows: ReadonlyArray<DiffRow>,
124): string {
125 if (rows.length === 0) return "";
126 const lines = [
127 `diff --git a/${previousPath} b/${path}`,
128 `--- a/${previousPath}`,
129 `+++ b/${path}`,
130 ];
131 let start = 0;
132 while (start < rows.length) {
133 let end = start;
134 while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1;
135 const group = rows.slice(start, end + 1);
136 const head = group[0]!;
137 const oldCount = group.filter((row) => row.oldLine !== null).length;
138 const newCount = group.filter((row) => row.newLine !== null).length;
139 // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`.
140 const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt;
141 const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt;
142 lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
143 for (const row of group) {
144 lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\n$/, "")}`);
145 if (!row.text.endsWith("\n")) lines.push("\\ No newline at end of file");
146 }
147 start = end + 1;
148 }
149 return `${lines.join("\n")}\n`;
150}
151
152/**
153 * The renderer fills the code around a hunk from both files and needs them to agree
154 * outside it, which the real old file does not wherever another hunk changed lines.
155 */
156export function excerptExpansionOldContents(
157 newContents: string,
158 rows: ReadonlyArray<DiffRow>,
159): string | null {
160 const head = rows[0];
161 if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null;
162 const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? [];
163 const before = head.newAt - 1;
164 const newCount = rows.filter((row) => row.newLine !== null).length;
165 const oldBefore = head.oldAt - 1;
166 const prefix = newLines.slice(0, before);
167 const oldPrefix =
168 oldBefore >= prefix.length
169 ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix]
170 : prefix.slice(prefix.length - oldBefore);
171 const oldRows = rows.filter((row) => row.oldLine !== null).map((row) => row.text);
172 const suffix = newLines.slice(before + newCount);
173 // Synthetic context still needs its own line after an old EOF row.
174 if (suffix.length > 0 && oldRows.at(-1)?.endsWith("\n") === false) oldRows.push("\n");
175 return [...oldPrefix, ...oldRows, ...suffix].join("");
176}
177
178export function letterStopNotes(stop: PullRequestReviewGuideStop) {
179 const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note }));
180 const byExcerpt = stop.excerpts.map((excerpt) => {
181 const lines = new Map<number, Array<{ marker: string; title: string }>>();
182 for (const { marker, note } of lettered) {
183 if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) {
184 continue;
185 }
186 const entry = { marker, title: note.title };
187 const list = lines.get(note.line);
188 if (list === undefined) lines.set(note.line, [entry]);
189 else list.push(entry);
190 }
191 return [...lines].map(([line, markers]) => ({ line, markers }));
192 });
193 return { lettered, byExcerpt };
194}