gitbay/MRs/UnifiedDiff.swift
148 lines · 5529 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
54 /// Total across files, for the summary row.
55 var additions: Int { files.reduce(0) { $0 + $1.additions } }
56 var deletions: Int { files.reduce(0) { $0 + $1.deletions } }
57
58 // MARK: - Parsing
59
60 static func parse(_ text: String) -> UnifiedDiff {
61 var files: [File] = []
62 var lines = text.components(separatedBy: "\n")[...]
63
64 while let line = lines.first {
65 guard line.hasPrefix("diff --git ") else {
66 lines = lines.dropFirst()
67 continue
68 }
69 lines = lines.dropFirst()
70
71 var oldPath = ""
72 var newPath = ""
73 var isBinary = false
74 var hunks: [Hunk] = []
75
76 // Header lines up to the first hunk or the next file.
77 while let header = lines.first, !header.hasPrefix("diff --git ") {
78 if header.hasPrefix("--- ") {
79 oldPath = Self.stripPrefix(String(header.dropFirst(4)))
80 } else if header.hasPrefix("+++ ") {
81 newPath = Self.stripPrefix(String(header.dropFirst(4)))
82 } else if header.hasPrefix("Binary files ") || header.hasPrefix("GIT binary patch") {
83 isBinary = true
84 } else if header.hasPrefix("@@") {
85 break
86 }
87 lines = lines.dropFirst()
88 }
89
90 // Hunks.
91 while let hunkHeader = lines.first, hunkHeader.hasPrefix("@@") {
92 lines = lines.dropFirst()
93 var (oldNumber, newNumber) = Self.startNumbers(hunkHeader)
94 var hunkLines: [Line] = []
95 loop: while let bodyLine = lines.first {
96 switch bodyLine.first {
97 case "+":
98 hunkLines.append(Line(kind: .addition, oldNumber: nil,
99 newNumber: newNumber, text: String(bodyLine.dropFirst())))
100 newNumber += 1
101 case "-":
102 hunkLines.append(Line(kind: .deletion, oldNumber: oldNumber,
103 newNumber: nil, text: String(bodyLine.dropFirst())))
104 oldNumber += 1
105 case " ":
106 hunkLines.append(Line(kind: .context, oldNumber: oldNumber,
107 newNumber: newNumber, text: String(bodyLine.dropFirst())))
108 oldNumber += 1
109 newNumber += 1
110 case "\\": // "\ No newline at end of file"
111 break
112 default:
113 break loop
114 }
115 lines = lines.dropFirst()
116 }
117 hunks.append(Hunk(header: hunkHeader, lines: hunkLines))
118 }
119
120 files.append(File(oldPath: oldPath, newPath: newPath, hunks: hunks, isBinary: isBinary))
121 }
122 return UnifiedDiff(files: files)
123 }
124
125 /// "a/path" → "path"; "/dev/null" stays.
126 private static func stripPrefix(_ path: String) -> String {
127 if path == "/dev/null" { return path }
128 if path.hasPrefix("a/") || path.hasPrefix("b/") {
129 return String(path.dropFirst(2))
130 }
131 return path
132 }
133
134 /// "@@ -12,5 +14,6 @@ …" → (12, 14)
135 private static func startNumbers(_ header: String) -> (Int, Int) {
136 var old = 1
137 var new = 1
138 let parts = header.split(separator: " ")
139 for part in parts {
140 if part.hasPrefix("-"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
141 old = n
142 } else if part.hasPrefix("+"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
143 new = n
144 }
145 }
146 return (old, new)
147 }
148}