gitbay/Builds/BuildLog.swift

v1.14.0
gitbay-ios/gitbay/Builds/BuildLog.swift history · blame · raw

99 lines · 3901 bytes

 1import Foundation
 2
 3/// A build log cut into lines and grouped by step, the way the web folds
 4/// it (`SplitBuildLog` and `FailedSection` in gitbay's
 5/// internal/control/buildlog.go).
 6nonisolated struct BuildLog: Sendable {
 7
 8    struct Line: Sendable, Identifiable, Equatable {
 9        /// Position in the whole log, so any line can be scrolled to.
10        let id: Int
11        let text: String
12    }
13
14    /// The setup before the first step (`n` 0), or one step and its
15    /// output.
16    struct Section: Sendable, Identifiable {
17        let n: Int
18        /// The step's command; "" for the setup.
19        let step: String
20        let lines: [Line]
21
22        var id: Int { n }
23        var head: String { String(step.prefix { $0 != "\n" }) }
24    }
25
26    let sections: [Section]
27    /// Whether any step's line is in the log. A build that stopped in the
28    /// clone has none, and its log is one block.
29    let stepped: Bool
30    /// Index into `sections` of the step a failed build stopped in.
31    let failed: Int?
32
33    init(_ text: String, build: Build) {
34        var cuts: [(n: Int, step: String, text: Substring)] = []
35        var cur = (n: 0, step: "")
36        var start = text.startIndex
37        for (i, step) in (build.steps ?? []).enumerated() {
38            let marker = "$ " + step + "\n"
39            guard let at = Self.findLine(text, marker, from: start) else { break }
40            if cur.n > 0 || at.lowerBound > start {
41                cuts.append((cur.n, cur.step, text[start..<at.lowerBound]))
42            }
43            cur = (i + 1, step)
44            start = at.upperBound
45        }
46        if cur.n > 0 || start < text.endIndex {
47            cuts.append((cur.n, cur.step, text[start...]))
48        }
49
50        var next = 0
51        sections = cuts.map { cut in
52            var body = cut.text
53            if body.last?.isNewline == true { body = body.dropLast() }
54            // isNewline also splits "\r\n", which Swift keeps as one
55            // Character, and a progress line's bare "\r".
56            let lines: [Line] = cut.text.isEmpty ? [] : body
57                .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
58                .map { text in
59                    defer { next += 1 }
60                    return Line(id: next, text: String(text))
61                }
62            return Section(n: cut.n, step: cut.step, lines: lines)
63        }
64        stepped = sections.contains { $0.n > 0 }
65
66        if build.status == "failure", !sections.isEmpty {
67            let step = build.failedStep ?? 0
68            failed = sections.firstIndex { step > 0 && $0.n == step } ?? sections.count - 1
69        } else {
70            failed = nil
71        }
72    }
73
74    var lastLineID: Int? { sections.last { !$0.lines.isEmpty }?.lines.last?.id }
75
76    /// The sections holding a line that contains `query`, with only those
77    /// lines. All of them when `query` is empty.
78    func filtered(_ query: String) -> [Section] {
79        guard !query.isEmpty else { return sections }
80        return sections.compactMap { section in
81            let hits = section.lines.filter { $0.text.localizedCaseInsensitiveContains(query) }
82            return hits.isEmpty ? nil : Section(n: section.n, step: section.step, lines: hits)
83        }
84    }
85
86    /// Where `line` occurs at the start of a line of `text`, at or after
87    /// `from`. Literal, so "\n" matches inside "\r\n" as the server's does.
88    private static func findLine(_ text: String, _ line: String, from: String.Index) -> Range<String.Index>? {
89        var from = from
90        while let range = text.range(of: line, options: .literal, range: from..<text.endIndex) {
91            let at = range.lowerBound
92            if at == text.startIndex || text.utf8[text.utf8.index(before: at)] == UInt8(ascii: "\n") {
93                return range
94            }
95            from = text.utf8.index(after: at)
96        }
97        return nil
98    }
99}