1import type { FileDiffMetadata } from "@pierre/diffs";
2import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts";
3
4/** One step forward or back, held to the tour's first and last stops. */
5export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number {
6 return Math.max(Math.min(current + delta, stopCount - 1), 0);
7}
8
9/** The stop's files in reading order: the excerpted ones first, then the rest it owns. */
10export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray<string> {
11 return [
12 ...new Set([
13 ...stop.excerpts.map((excerpt) => excerpt.path),
14 ...stop.files.map((file) => file.path),
15 ]),
16 ];
17}
18
19/** The rail's second line for a stop. */
20export function stopSubline(stop: PullRequestReviewGuideStop): string {
21 const count = stopFilePaths(stop).length;
22 if (count === 0) return "Context, no code";
23 return count === 1 ? "1 file" : `${count} files`;
24}
25
26/** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */
27function noteMarker(index: number): string {
28 return index < 26 ? String.fromCharCode(65 + index) : String(index + 1);
29}
30
31/** One rendered line of a file's diff, with where it sits on each side. */
32export interface DiffRow {
33 readonly kind: "context" | "addition" | "deletion";
34 /** Line number on the old side, null for an added line. */
35 readonly oldLine: number | null;
36 /** Line number on the new side, null for a removed line. */
37 readonly newLine: number | null;
38 /** The next line number on each side when this row is reached, for hunk headers. */
39 readonly oldAt: number;
40 readonly newAt: number;
41 readonly text: string;
42 /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */
43 readonly hunk: number;
44}
45
46/** Every line of a parsed file diff as the unified view lists it. */
47export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray<DiffRow> {
48 const rows: DiffRow[] = [];
49 file.hunks.forEach((hunk, hunkIndex) => {
50 let oldAt = hunk.deletionStart;
51 let newAt = hunk.additionStart;
52 for (const content of hunk.hunkContent) {
53 if (content.type === "context") {
54 for (let offset = 0; offset < content.lines; offset += 1) {
55 rows.push({
56 kind: "context",
57 oldLine: oldAt,
58 newLine: newAt,
59 oldAt,
60 newAt,
61 text: file.additionLines[content.additionLineIndex + offset] ?? "",
62 hunk: hunkIndex,
63 });
64 oldAt += 1;
65 newAt += 1;
66 }
67 continue;
68 }
69 for (let offset = 0; offset < content.deletions; offset += 1) {
70 rows.push({
71 kind: "deletion",
72 oldLine: oldAt,
73 newLine: null,
74 oldAt,
75 newAt,
76 text: file.deletionLines[content.deletionLineIndex + offset] ?? "",
77 hunk: hunkIndex,
78 });
79 oldAt += 1;
80 }
81 for (let offset = 0; offset < content.additions; offset += 1) {
82 rows.push({
83 kind: "addition",
84 oldLine: null,
85 newLine: newAt,
86 oldAt,
87 newAt,
88 text: file.additionLines[content.additionLineIndex + offset] ?? "",
89 hunk: hunkIndex,
90 });
91 newAt += 1;
92 }
93 }
94 });
95 return rows;
96}
97
98/**
99 * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half
100 * of a replacement at either edge comes along: an excerpt starting on an added line also shows
101 * the lines it replaced, which is what makes it read as a change rather than as new code.
102 */
103export function excerptRows(
104 rows: ReadonlyArray<DiffRow>,
105 excerpt: Pick<PullRequestReviewGuideExcerpt, "side" | "start" | "end">,
106): ReadonlyArray<DiffRow> {
107 const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine);
108 const inRange = (row: DiffRow) => {
109 const line = lineOf(row);
110 return line !== null && line >= excerpt.start && line <= excerpt.end;
111 };
112 let first = rows.findIndex(inRange);
113 if (first === -1) return [];
114 let last = rows.findLastIndex(inRange);
115 const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk;
116 if (excerpt.side === "new" && rows[first]?.kind === "addition") {
117 while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) {
118 first -= 1;
119 }
120 }
121 if (excerpt.side === "old" && rows[last]?.kind === "deletion") {
122 while (
123 last < rows.length - 1 &&
124 rows[last + 1]?.kind === "addition" &&
125 sameHunk(last + 1, last)
126 ) {
127 last += 1;
128 }
129 }
130 return rows.slice(first, last + 1);
131}
132
133const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const;
134
135/**
136 * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file
137 * with its real line numbers instead of a second diff view being written for excerpts.
138 */
139export function rowsToPatch(
140 path: string,
141 previousPath: string,
142 rows: ReadonlyArray<DiffRow>,
143): string {
144 if (rows.length === 0) return "";
145 const lines = [
146 `diff --git a/${previousPath} b/${path}`,
147 `--- a/${previousPath}`,
148 `+++ b/${path}`,
149 ];
150 let start = 0;
151 while (start < rows.length) {
152 let end = start;
153 while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1;
154 const group = rows.slice(start, end + 1);
155 const head = group[0]!;
156 const oldCount = group.filter((row) => row.oldLine !== null).length;
157 const newCount = group.filter((row) => row.newLine !== null).length;
158 // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`.
159 const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt;
160 const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt;
161 lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
162 for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`);
163 start = end + 1;
164 }
165 return `${lines.join("\n")}\n`;
166}
167
168/**
169 * The old side an excerpt expands against: the new file with only the excerpt's lines put back
170 * as they were. The renderer fills the code around a hunk from both files and needs them to
171 * agree outside it, which the real old file does not wherever another hunk changed lines. Only
172 * the lines the reader can expand into are read, and those are the new file's, so the code
173 * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks.
174 */
175export function excerptExpansionOldContents(
176 newContents: string,
177 rows: ReadonlyArray<DiffRow>,
178): string | null {
179 const head = rows[0];
180 if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null;
181 const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? [];
182 const before = head.newAt - 1;
183 const newCount = rows.filter((row) => row.newLine !== null).length;
184 // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed
185 // from the front, or padded there with lines no expansion reaches.
186 const oldBefore = head.oldAt - 1;
187 const prefix = newLines.slice(0, before);
188 const oldPrefix =
189 oldBefore >= prefix.length
190 ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix]
191 : prefix.slice(prefix.length - oldBefore);
192 const oldRows = rows
193 .filter((row) => row.oldLine !== null)
194 .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`));
195 return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join("");
196}
197
198/** A stop's notes lettered in guide order, with the excerpt lines each one marks. */
199export interface LetteredNote<Note> {
200 readonly marker: string;
201 readonly note: Note;
202}
203
204/**
205 * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every
206 * excerpt keeps its letter in the list; it just has no line to mark.
207 */
208export function letterStopNotes(stop: PullRequestReviewGuideStop) {
209 const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note }));
210 const byExcerpt = stop.excerpts.map((excerpt) => {
211 const lines = new Map<number, Array<{ marker: string; title: string }>>();
212 for (const { marker, note } of lettered) {
213 if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) {
214 continue;
215 }
216 const entry = { marker, title: note.title };
217 const list = lines.get(note.line);
218 if (list === undefined) lines.set(note.line, [entry]);
219 else list.push(entry);
220 }
221 return [...lines].map(([line, markers]) => ({ line, markers }));
222 });
223 return { lettered, byExcerpt };
224}