a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/MRs/UnifiedDiff.swift

ui-smoke
gitbay-ios/gitbay/MRs/UnifiedDiff.swift history · blame · raw

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}