Commit ace3f95b39

ace3f95b39f73bd10c2df46181b67332256c1149

parent: 1924d6476d

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 02:12 UTC

feat: add build log search and jump-to-error navigation

Implements: https://todo.sr.ht/~ccleberg/hutch/29
Implements: https://todo.sr.ht/~ccleberg/hutch/30

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 40; 518 CURRENT_PROJECT_VERSION = 41;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.17.1; 535 MARKETING_VERSION = 2.18.0;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 40; 555 CURRENT_PROJECT_VERSION = 41;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.17.1; 572 MARKETING_VERSION = 2.18.0;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 40; 635 CURRENT_PROJECT_VERSION = 41;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.17.1; 645 MARKETING_VERSION = 2.18.0;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 40; 664 CURRENT_PROJECT_VERSION = 41;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.17.1; 674 MARKETING_VERSION = 2.18.0;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/Views/Builds/BuildTaskLogView.swift +391 −26
@@ -14,31 +14,31 @@ struct BuildTaskLogView: View {
14 Group { 14 Group {
15 if let task { 15 if let task {
16 if let logText = viewModel.displayedLogText(for: task) { 16 if let logText = viewModel.displayedLogText(for: task) {
17 BuildLogTextView(text: logText) 17 BuildTaskLogContentView(text: logText)
18 .safeAreaInset(edge: .bottom) { 18 .safeAreaInset(edge: .bottom) {
19 if viewModel.isShowingBuildLogFallback(for: task) { 19 if viewModel.isShowingBuildLogFallback(for: task) {
20 Text("Showing the live build log until a task-specific log is available.") 20 Text("Showing the live build log until a task-specific log is available.")
21 .font(.footnote) 21 .font(.footnote)
22 .foregroundStyle(.secondary) 22 .foregroundStyle(.secondary)
23 .frame(maxWidth: .infinity, alignment: .leading) 23 .frame(maxWidth: .infinity, alignment: .leading)
24 .padding(.horizontal) 24 .padding(.horizontal)
25 .padding(.vertical, 8) 25 .padding(.vertical, 8)
26 .background(.thinMaterial) 26 .background(.thinMaterial)
27 } 27 }
28 }
29 .toolbar {
30 ToolbarItem(placement: .topBarTrailing) {
31 ShareLink(item: logText)
32 } 28 }
33 ToolbarItem(placement: .topBarTrailing) { 29 .toolbar {
34 Button { 30 ToolbarItem(placement: .topBarTrailing) {
35 UIPasteboard.general.string = logText 31 ShareLink(item: logText)
36 } label: { 32 }
37 Image(systemName: "doc.on.doc") 33 ToolbarItem(placement: .topBarTrailing) {
34 Button {
35 UIPasteboard.general.string = logText
36 } label: {
37 Image(systemName: "doc.on.doc")
38 }
39 .accessibilityLabel("Copy log to clipboard")
38 } 40 }
39 .accessibilityLabel("Copy log to clipboard")
40 } 41 }
41 }
42 } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) { 42 } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
43 SRHTLoadingStateView(message: "Loading log…") 43 SRHTLoadingStateView(message: "Loading log…")
44 } else if task.log == nil { 44 } else if task.log == nil {
@@ -89,8 +89,209 @@ struct BuildTaskLogView: View {
89 } 89 }
90} 90}
91 91
92private struct BuildTaskLogContentView: View {
93 let text: String
94
95 @State private var searchQuery = ""
96 @State private var matches: [LogTextRange] = []
97 @State private var anchors: [LogAnchor] = []
98 @State private var selectedMatchIndex: Int?
99 @State private var scrollTarget: LogScrollTarget?
100
101 private var selectedMatch: LogTextRange? {
102 guard let selectedMatchIndex, matches.indices.contains(selectedMatchIndex) else { return nil }
103 return matches[selectedMatchIndex]
104 }
105
106 var body: some View {
107 VStack(spacing: 0) {
108 searchControls
109
110 if !anchors.isEmpty {
111 anchorBar
112 }
113
114 BuildLogTextView(
115 text: text,
116 highlights: matches,
117 selectedHighlight: selectedMatch,
118 scrollTarget: scrollTarget
119 )
120 }
121 .task(id: text) {
122 await refreshAnchors()
123 await refreshSearch()
124 }
125 .task(id: searchQuery) {
126 await refreshSearch()
127 }
128 }
129
130 private var searchControls: some View {
131 VStack(spacing: 10) {
132 HStack(spacing: 10) {
133 HStack(spacing: 8) {
134 Image(systemName: "magnifyingglass")
135 .foregroundStyle(.secondary)
136
137 TextField("Search log", text: $searchQuery)
138 .textInputAutocapitalization(.never)
139 .autocorrectionDisabled()
140
141 if !searchQuery.isEmpty {
142 Button {
143 searchQuery = ""
144 } label: {
145 Image(systemName: "xmark.circle.fill")
146 .foregroundStyle(.secondary)
147 }
148 .buttonStyle(.plain)
149 .accessibilityLabel("Clear search")
150 }
151 }
152 .padding(.horizontal, 10)
153 .padding(.vertical, 8)
154 .background {
155 RoundedRectangle(cornerRadius: 12, style: .continuous)
156 .fill(.thinMaterial)
157 }
158
159 HStack(spacing: 4) {
160 Button {
161 moveSelection(step: -1)
162 } label: {
163 Image(systemName: "chevron.up")
164 }
165 .disabled(matches.isEmpty)
166
167 Button {
168 moveSelection(step: 1)
169 } label: {
170 Image(systemName: "chevron.down")
171 }
172 .disabled(matches.isEmpty)
173 }
174 .buttonStyle(.borderless)
175 .labelStyle(.iconOnly)
176 }
177
178 HStack {
179 if searchQuery.isEmpty {
180 Text("\(anchors.count) error anchors")
181 } else if matches.isEmpty {
182 Text("No matches")
183 } else if let selectedMatchIndex {
184 Text("\(selectedMatchIndex + 1) of \(matches.count) matches")
185 }
186
187 Spacer()
188 }
189 .font(.footnote)
190 .foregroundStyle(.secondary)
191 }
192 .padding(.horizontal)
193 .padding(.top, 12)
194 .padding(.bottom, 10)
195 .background(.bar)
196 }
197
198 private var anchorBar: some View {
199 ScrollView(.horizontal, showsIndicators: false) {
200 HStack(spacing: 8) {
201 ForEach(anchors) { anchor in
202 Button {
203 requestScroll(to: anchor.range)
204 } label: {
205 VStack(alignment: .leading, spacing: 2) {
206 Text("Line \(anchor.lineNumber)")
207 .font(.caption.weight(.semibold))
208 Text(anchor.label)
209 .font(.caption2)
210 .lineLimit(1)
211 }
212 .padding(.horizontal, 10)
213 .padding(.vertical, 8)
214 .frame(maxWidth: 220, alignment: .leading)
215 .background {
216 RoundedRectangle(cornerRadius: 10, style: .continuous)
217 .fill(Color.red.opacity(0.12))
218 }
219 }
220 .buttonStyle(.plain)
221 }
222 }
223 .padding(.horizontal)
224 .padding(.bottom, 10)
225 }
226 .background(.bar)
227 }
228
229 private func refreshAnchors() async {
230 let text = text
231 let newAnchors = await Task.detached(priority: .userInitiated) {
232 detectLogAnchors(in: text)
233 }.value
234
235 guard !Task.isCancelled else { return }
236 anchors = newAnchors
237 }
238
239 private func refreshSearch() async {
240 let query = searchQuery
241 let text = text
242
243 if !query.isEmpty {
244 try? await Task.sleep(for: .milliseconds(150))
245 }
246 guard !Task.isCancelled else { return }
247
248 let newMatches = await Task.detached(priority: .userInitiated) {
249 logMatchRanges(in: text, query: query)
250 }.value
251
252 guard !Task.isCancelled else { return }
253
254 let previousSelectedRange = selectedMatch
255 matches = newMatches
256
257 if newMatches.isEmpty {
258 selectedMatchIndex = nil
259 return
260 }
261
262 if let previousSelectedRange,
263 let newIndex = newMatches.firstIndex(of: previousSelectedRange) {
264 selectedMatchIndex = newIndex
265 return
266 }
267
268 selectedMatchIndex = 0
269 requestScroll(to: newMatches[0])
270 }
271
272 private func moveSelection(step: Int) {
273 guard !matches.isEmpty else { return }
274 let currentIndex = selectedMatchIndex ?? 0
275 let nextIndex = (currentIndex + step + matches.count) % matches.count
276 selectedMatchIndex = nextIndex
277 requestScroll(to: matches[nextIndex])
278 }
279
280 private func requestScroll(to range: LogTextRange) {
281 let nextID = (scrollTarget?.id ?? 0) + 1
282 scrollTarget = LogScrollTarget(id: nextID, range: range)
283 }
284}
285
92private struct BuildLogTextView: UIViewRepresentable { 286private struct BuildLogTextView: UIViewRepresentable {
93 let text: String 287 let text: String
288 let highlights: [LogTextRange]
289 let selectedHighlight: LogTextRange?
290 let scrollTarget: LogScrollTarget?
291
292 func makeCoordinator() -> Coordinator {
293 Coordinator()
294 }
94 295
95 func makeUIView(context _: Context) -> UITextView { 296 func makeUIView(context _: Context) -> UITextView {
96 let textView = UITextView() 297 let textView = UITextView()
@@ -105,13 +306,177 @@ private struct BuildLogTextView: UIViewRepresentable {
105 textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) 306 textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
106 textView.textContainer.lineFragmentPadding = 0 307 textView.textContainer.lineFragmentPadding = 0
107 textView.textContainer.widthTracksTextView = false 308 textView.textContainer.widthTracksTextView = false
108 textView.font = UIFont.monospacedSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .regular) 309 textView.font = UIFont.monospacedSystemFont(
310 ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize,
311 weight: .regular
312 )
109 textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 313 textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
110 return textView 314 return textView
111 } 315 }
112 316
113 func updateUIView(_ uiView: UITextView, context _: Context) { 317 func updateUIView(_ uiView: UITextView, context: Context) {
114 guard uiView.text != text else { return } 318 let coordinator = context.coordinator
115 uiView.text = text 319
320 if coordinator.lastText != text {
321 uiView.attributedText = NSAttributedString(string: text, attributes: baseAttributes(for: uiView))
322 coordinator.lastText = text
323 coordinator.lastHighlights = []
324 coordinator.lastSelectedHighlight = nil
325 }
326
327 if coordinator.lastHighlights != highlights || coordinator.lastSelectedHighlight != selectedHighlight {
328 applyHighlights(to: uiView)
329 coordinator.lastHighlights = highlights
330 coordinator.lastSelectedHighlight = selectedHighlight
331 }
332
333 if coordinator.lastScrollTarget != scrollTarget, let scrollTarget {
334 scroll(to: scrollTarget.range.nsRange, in: uiView)
335 coordinator.lastScrollTarget = scrollTarget
336 }
337 }
338
339 private func applyHighlights(to textView: UITextView) {
340 let textStorage = textView.textStorage
341 let fullRange = NSRange(location: 0, length: textStorage.length)
342
343 textStorage.beginEditing()
344 textStorage.removeAttribute(.backgroundColor, range: fullRange)
345 textStorage.removeAttribute(.foregroundColor, range: fullRange)
346 textStorage.addAttributes(baseAttributes(for: textView), range: fullRange)
347
348 for range in highlights {
349 textStorage.addAttribute(
350 .backgroundColor,
351 value: UIColor.systemYellow.withAlphaComponent(0.28),
352 range: range.nsRange
353 )
354 }
355
356 if let selectedHighlight {
357 textStorage.addAttributes([
358 .backgroundColor: UIColor.systemOrange.withAlphaComponent(0.5),
359 .foregroundColor: UIColor.label
360 ], range: selectedHighlight.nsRange)
361 }
362 textStorage.endEditing()
363 }
364
365 private func scroll(to range: NSRange, in textView: UITextView) {
366 guard range.location != NSNotFound else { return }
367 textView.selectedRange = range
368 textView.scrollRangeToVisible(range)
369 }
370
371 private func baseAttributes(for textView: UITextView) -> [NSAttributedString.Key: Any] {
372 [
373 .font: textView.font as Any,
374 .foregroundColor: UIColor.label
375 ]
376 }
377
378 final class Coordinator {
379 var lastText = ""
380 var lastHighlights: [LogTextRange] = []
381 var lastSelectedHighlight: LogTextRange?
382 var lastScrollTarget: LogScrollTarget?
383 }
384}
385
386struct LogTextRange: Hashable, Equatable, Sendable {
387 let location: Int
388 let length: Int
389
390 var nsRange: NSRange {
391 NSRange(location: location, length: length)
116 } 392 }
117} 393}
394
395struct LogAnchor: Identifiable, Equatable, Sendable {
396 let lineNumber: Int
397 let label: String
398 let range: LogTextRange
399
400 var id: String {
401 "\(lineNumber):\(range.location)"
402 }
403}
404
405private struct LogScrollTarget: Equatable {
406 let id: Int
407 let range: LogTextRange
408}
409
410func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
411 let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
412 guard !trimmedQuery.isEmpty else { return [] }
413
414 let nsText = text as NSString
415 var searchRange = NSRange(location: 0, length: nsText.length)
416 var matches: [LogTextRange] = []
417
418 while searchRange.length > 0, matches.count < limit {
419 let foundRange = nsText.range(
420 of: trimmedQuery,
421 options: [.caseInsensitive, .diacriticInsensitive],
422 range: searchRange
423 )
424
425 guard foundRange.location != NSNotFound else { break }
426 matches.append(LogTextRange(location: foundRange.location, length: foundRange.length))
427
428 let nextLocation = foundRange.location + max(foundRange.length, 1)
429 guard nextLocation <= nsText.length else { break }
430 searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
431 }
432
433 return matches
434}
435
436func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
437 let nsText = text as NSString
438 let strongMarkers = [
439 "fatal error",
440 "fatal:",
441 "error:",
442 "exception:",
443 "uncaught exception",
444 "traceback",
445 "panic:",
446 "undefined reference",
447 "segmentation fault",
448 "assertion failed",
449 "failed:"
450 ]
451
452 var anchors: [LogAnchor] = []
453 var lineNumber = 1
454 var cursor = 0
455 var lastAnchorLine: Int?
456
457 while cursor < nsText.length, anchors.count < limit {
458 let lineRange = nsText.lineRange(for: NSRange(location: cursor, length: 0))
459 let rawLine = nsText.substring(with: lineRange)
460 let trimmedLine = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
461 let foldedLine = trimmedLine.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
462
463 let isMatch = !trimmedLine.isEmpty && strongMarkers.contains { foldedLine.contains($0) }
464 let isDistinctFromPrevious = lastAnchorLine.map { lineNumber - $0 > 1 } ?? true
465
466 if isMatch, isDistinctFromPrevious {
467 anchors.append(
468 LogAnchor(
469 lineNumber: lineNumber,
470 label: String(trimmedLine.prefix(100)),
471 range: LogTextRange(location: lineRange.location, length: lineRange.length)
472 )
473 )
474 lastAnchorLine = lineNumber
475 }
476
477 cursor = lineRange.upperBound
478 lineNumber += 1
479 }
480
481 return anchors
482}
Hutch/Views/Home/HomeView.swift +8
@@ -795,7 +795,15 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
795 .padding(.horizontal, 8) 795 .padding(.horizontal, 8)
796 .padding(.vertical, 4) 796 .padding(.vertical, 4)
797 .background(Color(.secondarySystemFill), in: Capsule()) 797 .background(Color(.secondarySystemFill), in: Capsule())
798
799 if action != nil {
800 Image(systemName: "chevron.right")
801 .font(.caption.weight(.semibold))
802 .foregroundStyle(.tertiary)
803 }
798 } 804 }
805 .frame(maxWidth: .infinity, alignment: .leading)
806 .contentShape(Rectangle())
799 .padding(.vertical, 2) 807 .padding(.vertical, 2)
800 } 808 }
801} 809}
HutchTests/BuildTaskLogSearchTests.swift added +30
@@ -0,0 +1,30 @@
1import Testing
2@testable import Hutch
3
4struct BuildTaskLogSearchTests {
5
6 @Test
7 func logMatchRangesFindsCaseInsensitiveOccurrences() {
8 let matches = logMatchRanges(in: "Error: one\nerror: two\nwarning", query: "ERROR")
9
10 #expect(matches.count == 2)
11 #expect(matches[0] == LogTextRange(location: 0, length: 5))
12 #expect(matches[1].location > matches[0].location)
13 }
14
15 @Test
16 func detectLogAnchorsFindsImportantFailureLines() {
17 let anchors = detectLogAnchors(in: """
18 compile step
19 warning: this is fine
20 error: missing header
21 traceback (most recent call last):
22 panic: build failed
23 """)
24
25 #expect(anchors.count == 2)
26 #expect(anchors[0].lineNumber == 3)
27 #expect(anchors[0].label.contains("missing header"))
28 #expect(anchors[1].lineNumber == 5)
29 }
30}