gitbay/MRs/UnifiedDiff.swift
171 lines · 6477 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 /// Whether a review thread hangs off this line. `side` picks
54 /// which numbering the thread was recorded against; a stale
55 /// thread anchors nowhere, because the head it referenced is
56 /// gone.
57 func anchors(_ thread: ReviewThread, in file: File) -> Bool {
58 guard !thread.stale, thread.path == file.displayPath else { return false }
59 return switch thread.side {
60 case "old": oldNumber == Int(thread.line)
61 default: newNumber == Int(thread.line)
62 }
63 }
64 }
65
66 /// Total across files, for the summary row.
67 var additions: Int { files.reduce(0) { $0 + $1.additions } }
68 var deletions: Int { files.reduce(0) { $0 + $1.deletions } }
69
70 /// Whether this diff still shows the line a thread was left on. A
71 /// thread the diff has moved past renders in its own section rather
72 /// than vanishing.
73 func anchors(_ thread: ReviewThread) -> Bool {
74 files.contains { file in
75 file.hunks.contains { hunk in
76 hunk.lines.contains { $0.anchors(thread, in: file) }
77 }
78 }
79 }
80
81 // MARK: - Parsing
82
83 static func parse(_ text: String) -> UnifiedDiff {
84 var files: [File] = []
85 var lines = text.components(separatedBy: "\n")[...]
86
87 while let line = lines.first {
88 guard line.hasPrefix("diff --git ") else {
89 lines = lines.dropFirst()
90 continue
91 }
92 lines = lines.dropFirst()
93
94 var oldPath = ""
95 var newPath = ""
96 var isBinary = false
97 var hunks: [Hunk] = []
98
99 // Header lines up to the first hunk or the next file.
100 while let header = lines.first, !header.hasPrefix("diff --git ") {
101 if header.hasPrefix("--- ") {
102 oldPath = Self.stripPrefix(String(header.dropFirst(4)))
103 } else if header.hasPrefix("+++ ") {
104 newPath = Self.stripPrefix(String(header.dropFirst(4)))
105 } else if header.hasPrefix("Binary files ") || header.hasPrefix("GIT binary patch") {
106 isBinary = true
107 } else if header.hasPrefix("@@") {
108 break
109 }
110 lines = lines.dropFirst()
111 }
112
113 // Hunks.
114 while let hunkHeader = lines.first, hunkHeader.hasPrefix("@@") {
115 lines = lines.dropFirst()
116 var (oldNumber, newNumber) = Self.startNumbers(hunkHeader)
117 var hunkLines: [Line] = []
118 loop: while let bodyLine = lines.first {
119 switch bodyLine.first {
120 case "+":
121 hunkLines.append(Line(kind: .addition, oldNumber: nil,
122 newNumber: newNumber, text: String(bodyLine.dropFirst())))
123 newNumber += 1
124 case "-":
125 hunkLines.append(Line(kind: .deletion, oldNumber: oldNumber,
126 newNumber: nil, text: String(bodyLine.dropFirst())))
127 oldNumber += 1
128 case " ":
129 hunkLines.append(Line(kind: .context, oldNumber: oldNumber,
130 newNumber: newNumber, text: String(bodyLine.dropFirst())))
131 oldNumber += 1
132 newNumber += 1
133 case "\\": // "\ No newline at end of file"
134 break
135 default:
136 break loop
137 }
138 lines = lines.dropFirst()
139 }
140 hunks.append(Hunk(header: hunkHeader, lines: hunkLines))
141 }
142
143 files.append(File(oldPath: oldPath, newPath: newPath, hunks: hunks, isBinary: isBinary))
144 }
145 return UnifiedDiff(files: files)
146 }
147
148 /// "a/path" → "path"; "/dev/null" stays.
149 private static func stripPrefix(_ path: String) -> String {
150 if path == "/dev/null" { return path }
151 if path.hasPrefix("a/") || path.hasPrefix("b/") {
152 return String(path.dropFirst(2))
153 }
154 return path
155 }
156
157 /// "@@ -12,5 +14,6 @@ …" → (12, 14)
158 private static func startNumbers(_ header: String) -> (Int, Int) {
159 var old = 1
160 var new = 1
161 let parts = header.split(separator: " ")
162 for part in parts {
163 if part.hasPrefix("-"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
164 old = n
165 } else if part.hasPrefix("+"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
166 new = n
167 }
168 }
169 return (old, new)
170 }
171}