| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 2 | import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; |
| 3 | |
| 4 | /** One step forward or back, held to the tour's first and last stops. */ |
| 5 | export 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. */ |
| 10 | export 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. */ |
| 20 | export 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. */ |
| 27 | function 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. */ |
| 32 | export 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. */ |
| 47 | export 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 | */ |
| 103 | export 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 | |
| 133 | const 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 | */ |
| 139 | export 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 | */ |
| 175 | export 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. */ |
| 199 | export 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 | */ |
| 208 | export 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 | } |