gitbay/MRs/DiffCommentDraft.swift

v1.14.0
gitbay-ios/gitbay/MRs/DiffCommentDraft.swift history · blame · raw

73 lines · 3346 bytes

 1import Foundation
 2
 3/// A new review comment on lines of a merge request's diff: where it is
 4/// anchored, and the ```suggestion block that proposes replacement lines.
 5/// `mr diff-comment` decides whether the anchor is in the diff; this only
 6/// builds what the command is asked.
 7nonisolated struct DiffCommentDraft: Sendable, Hashable, Identifiable {
 8    let path: String
 9    /// The old side is a removed line; everything else anchors on the new file.
10    let old: Bool
11    /// The last line of the range, the one the comment was started from.
12    let line: Int
13    /// The lines on the same side of the hunk, in order, that a range may
14    /// start from: the tapped line and those above it, back to the first
15    /// line of the hunk on that side.
16    let candidates: [(number: Int, text: String)]
17
18    var id: String { "\(path):\(old ? "old" : "new"):\(line)" }
19
20    static func == (a: Self, b: Self) -> Bool { a.id == b.id }
21    func hash(into hasher: inout Hasher) { hasher.combine(id) }
22
23    /// A suggestion replaces new-file lines; a removed line has nothing
24    /// to replace.
25    var canSuggest: Bool { !old }
26
27    /// The earliest line a range may start on.
28    var firstLine: Int { candidates.first?.number ?? line }
29
30    /// The draft for a line of the diff, or nil for a line no side can
31    /// anchor (none in a well-formed diff).
32    init?(file: UnifiedDiff.File, hunk: UnifiedDiff.Hunk, line target: UnifiedDiff.Line) {
33        let old = target.kind == .deletion
34        guard let number = old ? target.oldNumber : target.newNumber else { return nil }
35        // The server checks --path against the diff's file names, which
36        // list a rename under its new name only, on either side.
37        self.path = file.displayPath
38        self.old = old
39        self.line = number
40        var lines: [(Int, String)] = []
41        for l in hunk.lines {
42            let n = old ? (l.kind == .addition ? nil : l.oldNumber) : (l.kind == .deletion ? nil : l.newNumber)
43            guard let n else { continue }
44            if n > number { break }
45            lines.append((n, l.text))
46        }
47        self.candidates = lines
48    }
49
50    /// `mr diff-comment <owner/name> <n> --path <p> --line <l> [--start-line <s>] [--old] --file -`.
51    func argv(repo: String, number: Int64, startLine: Int) -> [String] {
52        var argv = ["mr", "diff-comment", repo, String(number), "--path", path, "--line", String(line)]
53        if startLine < line { argv += ["--start-line", String(startLine)] }
54        if old { argv.append("--old") }
55        argv += ["--file", "-"]
56        return argv
57    }
58
59    /// The ```suggestion block for lines `startLine` through `line`, with
60    /// the current text in it to edit.
61    func suggestion(from startLine: Int) -> String {
62        let text = candidates.filter { $0.number >= startLine }.map(\.text)
63        return "```suggestion\n" + text.map { $0 + "\n" }.joined() + "```\n"
64    }
65
66    /// The body after the range's start moves from `old` to `new`: an
67    /// untouched suggestion is rebuilt to cover the new range, since
68    /// sending the old one would replace the whole range with fewer
69    /// lines. Text the user changed is theirs and stays.
70    func rebuilt(_ text: String, from old: Int, to new: Int) -> String {
71        text == suggestion(from: old) ? suggestion(from: new) : text
72    }
73}