gitbay/MRs/UnifiedDiff.swift

v1.7.0
gitbay-ios/gitbay/MRs/UnifiedDiff.swift history · blame · raw

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}