import Foundation /// `mr diff` returns one unified diff as text (a recorded gap, #40-family: /// the web parses it server-side). This parses it client-side into files, /// hunks and lines for rendering. nonisolated struct UnifiedDiff: Sendable, Hashable { let files: [File] nonisolated struct File: Sendable, Hashable, Identifiable { let oldPath: String let newPath: String let hunks: [Hunk] let isBinary: Bool var id: String { oldPath + "→" + newPath } /// The path to show: the new one, unless the file was deleted. var displayPath: String { newPath == "/dev/null" ? oldPath : newPath } var isNew: Bool { oldPath == "/dev/null" } var isDeleted: Bool { newPath == "/dev/null" } var additions: Int { hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .addition } } } var deletions: Int { hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .deletion } } } } nonisolated struct Hunk: Sendable, Hashable, Identifiable { let header: String // "@@ -l,c +l,c @@ context" let lines: [Line] var id: String { header } } nonisolated struct Line: Sendable, Hashable, Identifiable { enum Kind: Sendable, Hashable { case context, addition, deletion } let kind: Kind /// Line numbers in the old and new file; nil on the side the line /// does not exist on. let oldNumber: Int? let newNumber: Int? let text: String var id: String { "\(oldNumber ?? 0):\(newNumber ?? 0):\(text)" } /// Whether a review thread hangs off this line. `side` picks /// which numbering the thread was recorded against; a stale /// thread anchors nowhere, because the head it referenced is /// gone. func anchors(_ thread: ReviewThread, in file: File) -> Bool { guard !thread.stale, thread.path == file.displayPath else { return false } return switch thread.side { case "old": oldNumber == Int(thread.line) default: newNumber == Int(thread.line) } } } /// Total across files, for the summary row. var additions: Int { files.reduce(0) { $0 + $1.additions } } var deletions: Int { files.reduce(0) { $0 + $1.deletions } } /// Whether this diff still shows the line a thread was left on. A /// thread the diff has moved past renders in its own section rather /// than vanishing. func anchors(_ thread: ReviewThread) -> Bool { files.contains { file in file.hunks.contains { hunk in hunk.lines.contains { $0.anchors(thread, in: file) } } } } // MARK: - Parsing static func parse(_ text: String) -> UnifiedDiff { var files: [File] = [] var lines = text.components(separatedBy: "\n")[...] while let line = lines.first { guard line.hasPrefix("diff --git ") else { lines = lines.dropFirst() continue } lines = lines.dropFirst() var oldPath = "" var newPath = "" var isBinary = false var hunks: [Hunk] = [] // Header lines up to the first hunk or the next file. while let header = lines.first, !header.hasPrefix("diff --git ") { if header.hasPrefix("--- ") { oldPath = Self.stripPrefix(String(header.dropFirst(4))) } else if header.hasPrefix("+++ ") { newPath = Self.stripPrefix(String(header.dropFirst(4))) } else if header.hasPrefix("Binary files ") || header.hasPrefix("GIT binary patch") { isBinary = true } else if header.hasPrefix("@@") { break } lines = lines.dropFirst() } // Hunks. while let hunkHeader = lines.first, hunkHeader.hasPrefix("@@") { lines = lines.dropFirst() var (oldNumber, newNumber) = Self.startNumbers(hunkHeader) var hunkLines: [Line] = [] loop: while let bodyLine = lines.first { switch bodyLine.first { case "+": hunkLines.append(Line(kind: .addition, oldNumber: nil, newNumber: newNumber, text: String(bodyLine.dropFirst()))) newNumber += 1 case "-": hunkLines.append(Line(kind: .deletion, oldNumber: oldNumber, newNumber: nil, text: String(bodyLine.dropFirst()))) oldNumber += 1 case " ": hunkLines.append(Line(kind: .context, oldNumber: oldNumber, newNumber: newNumber, text: String(bodyLine.dropFirst()))) oldNumber += 1 newNumber += 1 case "\\": // "\ No newline at end of file" break default: break loop } lines = lines.dropFirst() } hunks.append(Hunk(header: hunkHeader, lines: hunkLines)) } files.append(File(oldPath: oldPath, newPath: newPath, hunks: hunks, isBinary: isBinary)) } return UnifiedDiff(files: files) } /// "a/path" → "path"; "/dev/null" stays. private static func stripPrefix(_ path: String) -> String { if path == "/dev/null" { return path } if path.hasPrefix("a/") || path.hasPrefix("b/") { return String(path.dropFirst(2)) } return path } /// "@@ -12,5 +14,6 @@ …" → (12, 14) private static func startNumbers(_ header: String) -> (Int, Int) { var old = 1 var new = 1 let parts = header.split(separator: " ") for part in parts { if part.hasPrefix("-"), let n = Int(part.dropFirst().split(separator: ",")[0]) { old = n } else if part.hasPrefix("+"), let n = Int(part.dropFirst().split(separator: ",")[0]) { new = n } } return (old, new) } }