gitbay/MRs/UnifiedDiff.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbay/MRs/UnifiedDiff.swift history · blame · raw

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}