gitbay/MRs/UnifiedDiff.swift
181 lines · 6947 bytes
1import Foundation
2
3/// `mr diff` returns one unified diff as text (a recorded gap, #40-family:
4/// the web parses it server-side). This parses it client-side into files,
5/// hunks and lines for rendering.
6nonisolated struct UnifiedDiff: Sendable, Hashable {
7
8 let files: [File]
9
10 nonisolated struct File: Sendable, Hashable, Identifiable {
11 let oldPath: String
12 let newPath: String
13 let hunks: [Hunk]
14 let isBinary: Bool
15
16 var id: String { oldPath + "→" + newPath }
17
18 /// The path to show: the new one, unless the file was deleted.
19 var displayPath: String {
20 newPath == "/dev/null" ? oldPath : newPath
21 }
22 var isNew: Bool { oldPath == "/dev/null" }
23 var isDeleted: Bool { newPath == "/dev/null" }
24
25 var additions: Int {
26 hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .addition } }
27 }
28 var deletions: Int {
29 hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .deletion } }
30 }
31 }
32
33 nonisolated struct Hunk: Sendable, Hashable, Identifiable {
34 let header: String // "@@ -l,c +l,c @@ context"
35 let lines: [Line]
36 var id: String { header }
37 }
38
39 nonisolated struct Line: Sendable, Hashable, Identifiable {
40 enum Kind: Sendable, Hashable {
41 case context, addition, deletion
42 }
43
44 let kind: Kind
45 /// Line numbers in the old and new file; nil on the side the line
46 /// does not exist on.
47 let oldNumber: Int?
48 let newNumber: Int?
49 let text: String
50
51 var id: String { "\(oldNumber ?? 0):\(newNumber ?? 0):\(text)" }
52
53 /// What VoiceOver reads for the line, e.g. "Added line 13: …".
54 var accessibilityLabel: String {
55 let spoken = text.trimmingCharacters(in: .whitespaces).isEmpty ? "blank" : text
56 return switch kind {
57 case .addition: "Added line \(newNumber ?? 0): \(spoken)"
58 case .deletion: "Removed line \(oldNumber ?? 0): \(spoken)"
59 case .context: "Line \(newNumber ?? 0): \(spoken)"
60 }
61 }
62
63 /// Whether a review thread hangs off this line. `side` picks
64 /// which numbering the thread was recorded against; a stale
65 /// thread anchors nowhere, because the head it referenced is
66 /// gone.
67 func anchors(_ thread: ReviewThread, in file: File) -> Bool {
68 guard !thread.stale, thread.path == file.displayPath else { return false }
69 return switch thread.side {
70 case "old": oldNumber == Int(thread.line)
71 default: newNumber == Int(thread.line)
72 }
73 }
74 }
75
76 /// Total across files, for the summary row.
77 var additions: Int { files.reduce(0) { $0 + $1.additions } }
78 var deletions: Int { files.reduce(0) { $0 + $1.deletions } }
79
80 /// Whether this diff still shows the line a thread was left on. A
81 /// thread the diff has moved past renders in its own section rather
82 /// than vanishing.
83 func anchors(_ thread: ReviewThread) -> Bool {
84 files.contains { file in
85 file.hunks.contains { hunk in
86 hunk.lines.contains { $0.anchors(thread, in: file) }
87 }
88 }
89 }
90
91 // MARK: - Parsing
92
93 static func parse(_ text: String) -> UnifiedDiff {
94 var files: [File] = []
95 var lines = text.components(separatedBy: "\n")[...]
96
97 while let line = lines.first {
98 guard line.hasPrefix("diff --git ") else {
99 lines = lines.dropFirst()
100 continue
101 }
102 lines = lines.dropFirst()
103
104 var oldPath = ""
105 var newPath = ""
106 var isBinary = false
107 var hunks: [Hunk] = []
108
109 // Header lines up to the first hunk or the next file.
110 while let header = lines.first, !header.hasPrefix("diff --git ") {
111 if header.hasPrefix("--- ") {
112 oldPath = Self.stripPrefix(String(header.dropFirst(4)))
113 } else if header.hasPrefix("+++ ") {
114 newPath = Self.stripPrefix(String(header.dropFirst(4)))
115 } else if header.hasPrefix("Binary files ") || header.hasPrefix("GIT binary patch") {
116 isBinary = true
117 } else if header.hasPrefix("@@") {
118 break
119 }
120 lines = lines.dropFirst()
121 }
122
123 // Hunks.
124 while let hunkHeader = lines.first, hunkHeader.hasPrefix("@@") {
125 lines = lines.dropFirst()
126 var (oldNumber, newNumber) = Self.startNumbers(hunkHeader)
127 var hunkLines: [Line] = []
128 loop: while let bodyLine = lines.first {
129 switch bodyLine.first {
130 case "+":
131 hunkLines.append(Line(kind: .addition, oldNumber: nil,
132 newNumber: newNumber, text: String(bodyLine.dropFirst())))
133 newNumber += 1
134 case "-":
135 hunkLines.append(Line(kind: .deletion, oldNumber: oldNumber,
136 newNumber: nil, text: String(bodyLine.dropFirst())))
137 oldNumber += 1
138 case " ":
139 hunkLines.append(Line(kind: .context, oldNumber: oldNumber,
140 newNumber: newNumber, text: String(bodyLine.dropFirst())))
141 oldNumber += 1
142 newNumber += 1
143 case "\\": // "\ No newline at end of file"
144 break
145 default:
146 break loop
147 }
148 lines = lines.dropFirst()
149 }
150 hunks.append(Hunk(header: hunkHeader, lines: hunkLines))
151 }
152
153 files.append(File(oldPath: oldPath, newPath: newPath, hunks: hunks, isBinary: isBinary))
154 }
155 return UnifiedDiff(files: files)
156 }
157
158 /// "a/path" → "path"; "/dev/null" stays.
159 private static func stripPrefix(_ path: String) -> String {
160 if path == "/dev/null" { return path }
161 if path.hasPrefix("a/") || path.hasPrefix("b/") {
162 return String(path.dropFirst(2))
163 }
164 return path
165 }
166
167 /// "@@ -12,5 +14,6 @@ …" → (12, 14)
168 private static func startNumbers(_ header: String) -> (Int, Int) {
169 var old = 1
170 var new = 1
171 let parts = header.split(separator: " ")
172 for part in parts {
173 if part.hasPrefix("-"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
174 old = n
175 } else if part.hasPrefix("+"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
176 new = n
177 }
178 }
179 return (old, new)
180 }
181}