gitbay/Builds/BuildLog.swift
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}