From fb954a3413a2167e39e019e4ac8bb04dc3c9d6d9 Mon Sep 17 00:00:00 2001 From: Jacob Hearst Date: Thu, 25 Jun 2026 12:10:01 -0500 Subject: [PATCH 1/2] feat: add TrackingLineParser for source-line-accurate event attribution LineParser's look-ahead buffering and internal event queue decouple events from the input lines that produced them. TrackingLineParser wraps LineParser and maintains a FIFO queue of pending line numbers, using a pendingEventCount hook on LineParser to compute exact attribution for all edge cases (buffered look-ahead, comment-continuation merges, fatalError double-event path). Co-Authored-By: Claude Sonnet 4.6 --- Sources/XCSiftCore/LineParser.swift | 14 ++ Sources/XCSiftCore/TrackingLineParser.swift | 131 ++++++++++++++ .../TrackingLineParserTests.swift | 166 ++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 Sources/XCSiftCore/TrackingLineParser.swift create mode 100644 Tests/XCSiftCoreTests/TrackingLineParserTests.swift diff --git a/Sources/XCSiftCore/LineParser.swift b/Sources/XCSiftCore/LineParser.swift index 84cf63c..0964f09 100644 --- a/Sources/XCSiftCore/LineParser.swift +++ b/Sources/XCSiftCore/LineParser.swift @@ -118,6 +118,20 @@ public struct LineParser: Sendable { // MARK: - Event queue (events waiting to be delivered one per feed() call) private var eventQueue: [ParseEvent] = [] + /// The number of events currently held in internal buffers, waiting to be delivered + /// by future ``feed(_:)`` or ``flush()`` calls. + /// + /// Used by ``TrackingLineParser`` to compute exact source-line attribution without + /// observing private state directly. + var pendingEventCount: Int { + // Events produced by earlier lines but not yet returned, because feed() can only + // deliver one result per call. They will be returned on future feed() calls (Path A). + let queued = eventQueue.count + // A recorded-issue line held for look-ahead will produce exactly one future event. + let bufferedLookAhead = pendingRecordedIssueLine != nil ? 1 : 0 + return queued + bufferedLookAhead + } + /// Creates a new `LineParser`. /// /// - Parameter xcbeautify: Pass `true` when the input was pre-processed by xcbeautify or Tuist. diff --git a/Sources/XCSiftCore/TrackingLineParser.swift b/Sources/XCSiftCore/TrackingLineParser.swift new file mode 100644 index 0000000..0a1e096 --- /dev/null +++ b/Sources/XCSiftCore/TrackingLineParser.swift @@ -0,0 +1,131 @@ +// MARK: - TrackingLineParser + +/// A wrapper around ``LineParser`` that stamps each emitted event with the 1-based number +/// of the input line that **caused** the event to be enqueued internally. +/// +/// ``LineParser`` uses look-ahead buffering and an internal event queue, so a call to +/// ``feed(_:)`` may return an event that was produced by an earlier line. `TrackingLineParser` +/// maintains a FIFO queue of pending line numbers that mirrors the inner queue, allowing +/// each event to be paired with its true source line. +/// +/// ```swift +/// var parser = TrackingLineParser() +/// for line in lines { +/// let (lineNumber, result) = parser.feed(line) +/// if case .consumed(let event) = result { +/// print("line \(lineNumber): \(event)") +/// } +/// } +/// for (lineNumber, event) in parser.flush() { +/// print("line \(lineNumber): \(event)") +/// } +/// ``` +public struct TrackingLineParser: Sendable { + + // MARK: - State + + private var inner: LineParser + private var lineCounter: Int = 0 + + /// FIFO queue of source line numbers awaiting delivery. + /// + /// An entry is pushed for each net-new event the current `feed` call contributes to the + /// inner buffer. An entry is popped for every `.consumed` result from `feed`, and for each + /// event returned by `flush`. + private var pendingLineNumbers: [Int] = [] + + // MARK: - Init + + /// Creates a new `TrackingLineParser`. + /// + /// - Parameter xcbeautify: Forwarded directly to the underlying ``LineParser``. + public init(xcbeautify: Bool = false) { + self.inner = LineParser(xcbeautify: xcbeautify) + } + + // MARK: - Forwarded properties + + /// Forwarded from ``LineParser/didEmitXcbeautifyHint``. + public var didEmitXcbeautifyHint: Bool { inner.didEmitXcbeautifyHint } + + /// Forwarded from ``LineParser/sawSuccessMarker``. + public var sawSuccessMarker: Bool { inner.sawSuccessMarker } + + /// Forwarded from ``LineParser/sawFailureMarker``. + public var sawFailureMarker: Bool { inner.sawFailureMarker } + + // MARK: - Parsing + + /// Feeds one line to the underlying ``LineParser`` and returns the result paired with the + /// 1-based number of the input line that **produced** the event. + /// + /// The returned `lineNumber` reflects the line that caused the event to be enqueued + /// internally — which may be earlier than the line currently being fed, due to look-ahead + /// buffering. When the result is `.ignored`, `lineNumber` is `0`. + /// + /// - Parameter line: A single raw line of build output. + /// - Returns: A tuple of `(lineNumber, LineResult)`. + @discardableResult + public mutating func feed(_ line: String) -> (lineNumber: Int, LineResult) { + lineCounter += 1 + let currentLine = lineCounter + + // Snapshot inner queue depth before and after to detect how many net-new future + // events the current line contributes to the inner buffer. + let pendingBefore = inner.pendingEventCount + let result = inner.feed(line) + let pendingAfter = inner.pendingEventCount + + switch result { + case .ignored: + // Line matched nothing; no event will ever be attributed to it. + return (0, result) + + case .buffering: + // Line entered pendingRecordedIssueLine — exactly 1 future event guaranteed. + // pendingAfter == pendingBefore + 1 by definition; push one slot. + pendingLineNumbers.append(currentLine) + return (currentLine, result) + + case .consumed: + // One queued event was just delivered. The current line may also have contributed + // net-new entries to the inner buffer. `extraSlots` captures that delta: + // > 0 — overflow (e.g. fatalError double-event, Path A side-effect enqueue) + // = 0 — current line produced exactly one new queued event (normal path) + // < 0 — comment-continuation: current line was absorbed into the buffered + // event and produced no future event of its own + let extraSlots = pendingAfter - pendingBefore + // Push currentLine once per net-new future event it contributed. + // When extraSlots == 0: push once (normal case). + // When extraSlots == -1: push zero times (comment-continuation, no orphaned slot). + // When extraSlots == 1: push twice (double-event path). + let pushCount = max(0, 1 + extraSlots) + for _ in 0 ..< pushCount { + pendingLineNumbers.append(currentLine) + } + let sourceLineNumber = pendingLineNumbers.removeFirst() + return (sourceLineNumber, result) + } + } + + /// Flushes all buffered state and returns remaining events paired with their source line numbers. + /// + /// Mirrors ``LineParser/flush()``; call once after all lines have been fed. + /// + /// - Returns: An array of `(lineNumber: Int, ParseEvent)` pairs. The `lineNumber` is + /// `0` for events that have no attributable source line (e.g. synthetic crash events + /// emitted by the underlying parser when no buffered line caused them directly). + public mutating func flush() -> [(lineNumber: Int, ParseEvent)] { + let events = inner.flush() + var out: [(lineNumber: Int, ParseEvent)] = [] + out.reserveCapacity(events.count) + for event in events { + let lineNumber = pendingLineNumbers.isEmpty ? 0 : pendingLineNumbers.removeFirst() + out.append((lineNumber, event)) + } + // Clear any orphaned entries that did not produce an event (e.g. unemitted state + // left over after a run that ended without all buffers draining normally). + pendingLineNumbers.removeAll() + return out + } +} diff --git a/Tests/XCSiftCoreTests/TrackingLineParserTests.swift b/Tests/XCSiftCoreTests/TrackingLineParserTests.swift new file mode 100644 index 0000000..5b70ceb --- /dev/null +++ b/Tests/XCSiftCoreTests/TrackingLineParserTests.swift @@ -0,0 +1,166 @@ +import XCTest + +@testable import XCSiftCore + +final class TrackingLineParserTests: XCTestCase { + + // MARK: - Immediate consume + + func testImmediateConsume() { + var parser = TrackingLineParser() + let (lineNumber, result) = parser.feed("main.swift:10:5: error: use of undeclared identifier 'foo'") + guard case .consumed(let event) = result, case .error = event else { + return XCTFail("Expected .consumed(.error), got \(result)") + } + XCTAssertEqual(lineNumber, 1) + } + + // MARK: - Ignored line does not consume a slot + + func testIgnoredLineDoesNotAffectNumbering() { + var parser = TrackingLineParser() + let (n1, r1) = parser.feed("note: some note message") + XCTAssertEqual(r1, .ignored) + XCTAssertEqual(n1, 0) + + let (n2, r2) = parser.feed("main.swift:10:5: error: use of undeclared identifier 'foo'") + guard case .consumed(let event) = r2, case .error = event else { + return XCTFail("Expected .consumed(.error), got \(r2)") + } + XCTAssertEqual(n2, 2) + } + + // MARK: - Buffering then consumed: event attributed to the buffered line + + func testBufferingThenConsumedAttributedToBufferedLine() { + var parser = TrackingLineParser() + + // Line 1: recorded-issue → buffering + let (n1, r1) = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") + XCTAssertEqual(r1, .buffering) + XCTAssertEqual(n1, 1) + + // Line 2: unrelated error → emits testFailed from the line-1 buffer + let (n2, r2) = parser.feed("other.swift:5:1: error: something broke") + guard case .consumed(let e2) = r2, case .testFailed = e2 else { + return XCTFail("Expected .consumed(.testFailed) from flushed buffer, got \(r2)") + } + XCTAssertEqual(n2, 1, "testFailed should be attributed to line 1, not line 2") + + // Line 3: anything that drains the overflow error from line 2 + let (n3, r3) = parser.feed("note: irrelevant") + guard case .consumed(let e3) = r3, case .error = e3 else { + return XCTFail("Expected .consumed(.error) from overflow, got \(r3)") + } + XCTAssertEqual(n3, 2, "overflow error should be attributed to line 2") + } + + // MARK: - Comment-continuation: no orphaned slot + + func testCommentContinuationDoesNotShiftSubsequentLines() { + var parser = TrackingLineParser() + + // Line 1: recorded-issue → buffering + _ = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") + + // Line 2: comment continuation → merges into line 1's event, no overflow + let (n2, r2) = parser.feed("↳ Custom failure reason") + guard case .consumed(let e2) = r2, case .testFailed = e2 else { + return XCTFail("Expected .consumed(.testFailed), got \(r2)") + } + XCTAssertEqual(n2, 1, "merged testFailed should be attributed to line 1") + + // Line 3: next event should be line 3, not 2 (no orphaned slot) + let (n3, r3) = parser.feed("main.swift:7:1: error: bad") + guard case .consumed(let e3) = r3, case .error = e3 else { + return XCTFail("Expected .consumed(.error), got \(r3)") + } + XCTAssertEqual(n3, 3, "error on line 3 should not be bumped to line 2 by an orphaned slot") + } + + // MARK: - flush attributes events to their buffered lines + + func testFlushAttributedToBufferedLine() { + var parser = TrackingLineParser() + _ = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") + let events = parser.flush() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].lineNumber, 1) + guard case .testFailed = events[0].1 else { + return XCTFail("Expected .testFailed, got \(events[0].1)") + } + } + + // MARK: - flush on empty state + + func testFlushOnEmptyStateReturnsEmpty() { + var parser = TrackingLineParser() + XCTAssertTrue(parser.flush().isEmpty) + } + + // MARK: - Multi-line linker: intermediate lines are ignored + + func testMultiLineLinkerAttributedToFinalLine() { + var parser = TrackingLineParser() + let (n1, r1) = parser.feed("Undefined symbols for architecture arm64:") + XCTAssertEqual(r1, .ignored) + XCTAssertEqual(n1, 0) + + let (n2, r2) = parser.feed(" \"_MissingSymbol\", referenced from:") + XCTAssertEqual(r2, .ignored) + XCTAssertEqual(n2, 0) + + let (n3, r3) = parser.feed(" objc-class-ref in SomeFile.o") + guard case .consumed(let e3) = r3, case .linkerError = e3 else { + return XCTFail("Expected .consumed(.linkerError), got \(r3)") + } + XCTAssertEqual(n3, 3) + } + + // MARK: - Multiple consecutive consumed events + + func testConsecutiveConsumedEventsGetSequentialLineNumbers() { + var parser = TrackingLineParser() + let lines = [ + "main.swift:1:1: error: error one", + "main.swift:2:1: error: error two", + "main.swift:3:1: warning: warning three", + ] + for (index, line) in lines.enumerated() { + let (lineNumber, result) = parser.feed(line) + guard case .consumed = result else { + return XCTFail("Expected .consumed for line \(index + 1), got \(result)") + } + XCTAssertEqual(lineNumber, index + 1) + } + } + + // MARK: - Forwarded properties + + func testForwardedSuccessMarker() { + var parser = TrackingLineParser() + _ = parser.feed("** BUILD SUCCEEDED **") + XCTAssertTrue(parser.sawSuccessMarker) + XCTAssertFalse(parser.sawFailureMarker) + } + + func testForwardedFailureMarker() { + var parser = TrackingLineParser() + _ = parser.feed("** BUILD FAILED **") + XCTAssertTrue(parser.sawFailureMarker) + XCTAssertFalse(parser.sawSuccessMarker) + } + + // MARK: - Line counter increments on every feed regardless of result + + func testLineCounterIncrementsOnEveryFeed() { + var parser = TrackingLineParser() + // Feed 3 ignored lines then an error; error should be line 4 + _ = parser.feed("note: a") + _ = parser.feed("note: b") + _ = parser.feed("note: c") + let (lineNumber, result) = parser.feed("main.swift:1:1: error: something") + guard case .consumed = result else { return XCTFail("Expected .consumed") } + XCTAssertEqual(lineNumber, 4) + } +} From f39ae85d9751a615cee34038a2cfaf6d6405cd0b Mon Sep 17 00:00:00 2001 From: Jacob Hearst Date: Mon, 20 Jul 2026 08:59:56 -0500 Subject: [PATCH 2/2] fix: attribute events correctly when a buffered line resolves dead LineParser's flushRecordedIssue can resolve a buffered look-ahead line with no event while enqueueing an unrelated overflow event in the same call. TrackingLineParser didn't distinguish that dead-slot case from a plain .ignored line, so it left a stale queue entry and misattributed the next real event to the dead buffered line instead of its actual source line. Co-Authored-By: Claude Sonnet 5 --- Sources/XCSiftCore/LineParser.swift | 22 +++ Sources/XCSiftCore/TrackingLineParser.swift | 125 +++++++++------ .../TrackingLineParserTests.swift | 150 ++++++++++++++---- 3 files changed, 218 insertions(+), 79 deletions(-) diff --git a/Sources/XCSiftCore/LineParser.swift b/Sources/XCSiftCore/LineParser.swift index 0964f09..66a1ad2 100644 --- a/Sources/XCSiftCore/LineParser.swift +++ b/Sources/XCSiftCore/LineParser.swift @@ -132,6 +132,23 @@ public struct LineParser: Sendable { return queued + bufferedLookAhead } + /// `true` if the line most recently passed to ``feed(_:)`` had its text folded into the + /// delivered event's content (currently: `↳`/details comment-continuation lines). + /// + /// Used by ``TrackingLineParser`` to distinguish a true content merge (the current line's + /// range must extend to cover the merged line) from a mere drain trigger (an unrelated line + /// that only caused an already-buffered event to be delivered, and must not be folded into + /// that event's range). + private(set) var didMergeCurrentLine: Bool = false + + /// `true` if the buffered ``pendingRecordedIssueLine`` resolved this call without producing + /// any event (its text didn't match ``parseFailedTest``'s expected format). + /// + /// Used by ``TrackingLineParser`` to discard the guaranteed-future-event slot it reserved + /// when the line first started buffering — that slot will never be delivered, so leaving it + /// in the pending queue would misattribute a later, unrelated event to the dead line. + private(set) var droppedDeadBufferedLine: Bool = false + /// Creates a new `LineParser`. /// /// - Parameter xcbeautify: Pass `true` when the input was pre-processed by xcbeautify or Tuist. @@ -157,6 +174,8 @@ public struct LineParser: Sendable { /// - Returns: `.consumed(event)` when a ``ParseEvent`` was produced, `.buffering` when /// the line was held for look-ahead, or `.ignored` when no pattern matched. public mutating func feed(_ line: String) -> LineResult { + didMergeCurrentLine = false + droppedDeadBufferedLine = false if !eventQueue.isEmpty { // Drain one queued event; schedule current line for next call. let queued = eventQueue.removeFirst() @@ -189,6 +208,7 @@ public struct LineParser: Sendable { || trimmed.hasPrefix(XcodebuildSymbols.swiftTestingDetailsPrefixFallback) if isCommentContinuation { if let event = buffered, case .testFailed(let failed) = event { + didMergeCurrentLine = true let comment = String( trimmed.drop(while: { $0 != " " }).drop(while: { $0 == " " }) ) @@ -201,10 +221,12 @@ public struct LineParser: Sendable { ) return .consumed(.testFailed(amended)) } + if buffered == nil { droppedDeadBufferedLine = true } return buffered.map { .consumed($0) } ?? .ignored } else { // Current line is unrelated — enqueue its result, emit buffered now. if let overflow = processLine(line) { eventQueue.append(overflow) } + if buffered == nil { droppedDeadBufferedLine = true } return buffered.map { .consumed($0) } ?? .ignored } } diff --git a/Sources/XCSiftCore/TrackingLineParser.swift b/Sources/XCSiftCore/TrackingLineParser.swift index 0a1e096..23083d8 100644 --- a/Sources/XCSiftCore/TrackingLineParser.swift +++ b/Sources/XCSiftCore/TrackingLineParser.swift @@ -1,23 +1,24 @@ // MARK: - TrackingLineParser -/// A wrapper around ``LineParser`` that stamps each emitted event with the 1-based number -/// of the input line that **caused** the event to be enqueued internally. +/// A wrapper around ``LineParser`` that stamps each emitted event with the range of 1-based +/// input line numbers that **produced** the event. /// /// ``LineParser`` uses look-ahead buffering and an internal event queue, so a call to -/// ``feed(_:)`` may return an event that was produced by an earlier line. `TrackingLineParser` -/// maintains a FIFO queue of pending line numbers that mirrors the inner queue, allowing -/// each event to be paired with its true source line. +/// ``feed(_:)`` may return an event that was produced by an earlier line, or by several lines +/// merged together (e.g. a Swift Testing failure and its trailing `↳` comment). `TrackingLineParser` +/// maintains a FIFO queue of pending line ranges that mirrors the inner queue, allowing each +/// event to be paired with the exact span of source lines that contributed to it. /// /// ```swift /// var parser = TrackingLineParser() /// for line in lines { -/// let (lineNumber, result) = parser.feed(line) +/// let (lineRange, result) = parser.feed(line) /// if case .consumed(let event) = result { -/// print("line \(lineNumber): \(event)") +/// print("lines \(lineRange!): \(event)") /// } /// } -/// for (lineNumber, event) in parser.flush() { -/// print("line \(lineNumber): \(event)") +/// for (lineRange, event) in parser.flush() { +/// print("lines \(lineRange): \(event)") /// } /// ``` public struct TrackingLineParser: Sendable { @@ -27,12 +28,14 @@ public struct TrackingLineParser: Sendable { private var inner: LineParser private var lineCounter: Int = 0 - /// FIFO queue of source line numbers awaiting delivery. + /// FIFO queue of source line ranges awaiting delivery. /// /// An entry is pushed for each net-new event the current `feed` call contributes to the /// inner buffer. An entry is popped for every `.consumed` result from `feed`, and for each - /// event returned by `flush`. - private var pendingLineNumbers: [Int] = [] + /// event returned by `flush`. When the current line's text is merged into an already-buffered + /// event (rather than merely triggering delivery of one), the front entry's upper bound is + /// extended to the current line instead of pushing a new entry. + private var pendingLineRanges: [ClosedRange] = [] // MARK: - Init @@ -57,16 +60,17 @@ public struct TrackingLineParser: Sendable { // MARK: - Parsing /// Feeds one line to the underlying ``LineParser`` and returns the result paired with the - /// 1-based number of the input line that **produced** the event. + /// range of 1-based input line numbers that **produced** the event. /// - /// The returned `lineNumber` reflects the line that caused the event to be enqueued - /// internally — which may be earlier than the line currently being fed, due to look-ahead - /// buffering. When the result is `.ignored`, `lineNumber` is `0`. + /// The returned `lineRange` reflects the line(s) that caused the event to be enqueued + /// internally or whose text was folded into it — which may be earlier than the line + /// currently being fed, due to look-ahead buffering. When the result is `.ignored`, + /// `lineRange` is `nil`. /// /// - Parameter line: A single raw line of build output. - /// - Returns: A tuple of `(lineNumber, LineResult)`. + /// - Returns: A tuple of `(lineRange, LineResult)`. @discardableResult - public mutating func feed(_ line: String) -> (lineNumber: Int, LineResult) { + public mutating func feed(_ line: String) -> (lineRange: ClosedRange?, LineResult) { lineCounter += 1 let currentLine = lineCounter @@ -75,57 +79,84 @@ public struct TrackingLineParser: Sendable { let pendingBefore = inner.pendingEventCount let result = inner.feed(line) let pendingAfter = inner.pendingEventCount + let didMerge = inner.didMergeCurrentLine + let droppedDeadBufferedLine = inner.droppedDeadBufferedLine switch result { case .ignored: - // Line matched nothing; no event will ever be attributed to it. - return (0, result) + guard droppedDeadBufferedLine else { + // Line matched nothing; no event will ever be attributed to it. + return (nil, result) + } + // The buffered look-ahead slot resolved without producing an event (its text never + // matched the expected format) — discard the stale slot reserved for it, since no + // future feed()/flush() call will ever deliver an event for that line. + if !pendingLineRanges.isEmpty { + pendingLineRanges.removeFirst() + } + // The current line may still have contributed its own event as overflow (see the + // non-comment-continuation branch of `flushRecordedIssue`). `extraSlots` isolates + // that net-new contribution the same way the `.consumed` branch below does. + let extraSlots = pendingAfter - pendingBefore + let pushCount = max(0, 1 + extraSlots) + for _ in 0 ..< pushCount { + pendingLineRanges.append(currentLine ... currentLine) + } + return (nil, result) case .buffering: // Line entered pendingRecordedIssueLine — exactly 1 future event guaranteed. // pendingAfter == pendingBefore + 1 by definition; push one slot. - pendingLineNumbers.append(currentLine) - return (currentLine, result) + pendingLineRanges.append(currentLine ... currentLine) + return (currentLine ... currentLine, result) case .consumed: - // One queued event was just delivered. The current line may also have contributed - // net-new entries to the inner buffer. `extraSlots` captures that delta: - // > 0 — overflow (e.g. fatalError double-event, Path A side-effect enqueue) - // = 0 — current line produced exactly one new queued event (normal path) - // < 0 — comment-continuation: current line was absorbed into the buffered - // event and produced no future event of its own - let extraSlots = pendingAfter - pendingBefore - // Push currentLine once per net-new future event it contributed. - // When extraSlots == 0: push once (normal case). - // When extraSlots == -1: push zero times (comment-continuation, no orphaned slot). - // When extraSlots == 1: push twice (double-event path). - let pushCount = max(0, 1 + extraSlots) - for _ in 0 ..< pushCount { - pendingLineNumbers.append(currentLine) + if didMerge, let front = pendingLineRanges.first { + // The current line's text was folded into the front-of-queue event's content + // (e.g. a `↳` comment-continuation line) — extend its range rather than + // attributing the current line to a separate, unrelated slot. + pendingLineRanges[0] = front.lowerBound ... currentLine + } else { + // One queued event was just delivered. The current line may also have contributed + // net-new entries to the inner buffer. `extraSlots` captures that delta: + // > 0 — overflow (e.g. fatalError double-event, Path A side-effect enqueue) + // = 0 — current line produced exactly one new queued event (normal path) + // < 0 — current line only triggered delivery of an unrelated buffered event + // and produced no future event of its own + let extraSlots = pendingAfter - pendingBefore + // Push currentLine once per net-new future event it contributed. + // When extraSlots == 0: push once (normal case). + // When extraSlots == -1: push zero times (drain trigger, no orphaned slot). + // When extraSlots == 1: push twice (double-event path). + let pushCount = max(0, 1 + extraSlots) + for _ in 0 ..< pushCount { + pendingLineRanges.append(currentLine ... currentLine) + } } - let sourceLineNumber = pendingLineNumbers.removeFirst() - return (sourceLineNumber, result) + let sourceLineRange = pendingLineRanges.removeFirst() + return (sourceLineRange, result) } } - /// Flushes all buffered state and returns remaining events paired with their source line numbers. + /// Flushes all buffered state and returns remaining events paired with their source line ranges. /// /// Mirrors ``LineParser/flush()``; call once after all lines have been fed. /// - /// - Returns: An array of `(lineNumber: Int, ParseEvent)` pairs. The `lineNumber` is - /// `0` for events that have no attributable source line (e.g. synthetic crash events - /// emitted by the underlying parser when no buffered line caused them directly). - public mutating func flush() -> [(lineNumber: Int, ParseEvent)] { + /// - Returns: An array of `(lineRange: ClosedRange, ParseEvent)` pairs. The `lineRange` + /// collapses to the current line alone for events that have no attributable source line + /// (e.g. synthetic crash events emitted by the underlying parser when no buffered line + /// caused them directly). + public mutating func flush() -> [(lineRange: ClosedRange, ParseEvent)] { let events = inner.flush() - var out: [(lineNumber: Int, ParseEvent)] = [] + var out: [(lineRange: ClosedRange, ParseEvent)] = [] out.reserveCapacity(events.count) for event in events { - let lineNumber = pendingLineNumbers.isEmpty ? 0 : pendingLineNumbers.removeFirst() - out.append((lineNumber, event)) + let lineRange = pendingLineRanges.isEmpty ? lineCounter ... lineCounter : pendingLineRanges.removeFirst() + out.append((lineRange, event)) } // Clear any orphaned entries that did not produce an event (e.g. unemitted state // left over after a run that ended without all buffers draining normally). - pendingLineNumbers.removeAll() + pendingLineRanges.removeAll() return out } } diff --git a/Tests/XCSiftCoreTests/TrackingLineParserTests.swift b/Tests/XCSiftCoreTests/TrackingLineParserTests.swift index 5b70ceb..6533333 100644 --- a/Tests/XCSiftCoreTests/TrackingLineParserTests.swift +++ b/Tests/XCSiftCoreTests/TrackingLineParserTests.swift @@ -8,26 +8,26 @@ final class TrackingLineParserTests: XCTestCase { func testImmediateConsume() { var parser = TrackingLineParser() - let (lineNumber, result) = parser.feed("main.swift:10:5: error: use of undeclared identifier 'foo'") + let (lineRange, result) = parser.feed("main.swift:10:5: error: use of undeclared identifier 'foo'") guard case .consumed(let event) = result, case .error = event else { return XCTFail("Expected .consumed(.error), got \(result)") } - XCTAssertEqual(lineNumber, 1) + XCTAssertEqual(lineRange, 1 ... 1) } // MARK: - Ignored line does not consume a slot func testIgnoredLineDoesNotAffectNumbering() { var parser = TrackingLineParser() - let (n1, r1) = parser.feed("note: some note message") + let (r1Range, r1) = parser.feed("note: some note message") XCTAssertEqual(r1, .ignored) - XCTAssertEqual(n1, 0) + XCTAssertNil(r1Range) - let (n2, r2) = parser.feed("main.swift:10:5: error: use of undeclared identifier 'foo'") + let (r2Range, r2) = parser.feed("main.swift:10:5: error: use of undeclared identifier 'foo'") guard case .consumed(let event) = r2, case .error = event else { return XCTFail("Expected .consumed(.error), got \(r2)") } - XCTAssertEqual(n2, 2) + XCTAssertEqual(r2Range, 2 ... 2) } // MARK: - Buffering then consumed: event attributed to the buffered line @@ -36,46 +36,50 @@ final class TrackingLineParserTests: XCTestCase { var parser = TrackingLineParser() // Line 1: recorded-issue → buffering - let (n1, r1) = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") + let (r1Range, r1) = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") XCTAssertEqual(r1, .buffering) - XCTAssertEqual(n1, 1) + XCTAssertEqual(r1Range, 1 ... 1) // Line 2: unrelated error → emits testFailed from the line-1 buffer - let (n2, r2) = parser.feed("other.swift:5:1: error: something broke") + let (r2Range, r2) = parser.feed("other.swift:5:1: error: something broke") guard case .consumed(let e2) = r2, case .testFailed = e2 else { return XCTFail("Expected .consumed(.testFailed) from flushed buffer, got \(r2)") } - XCTAssertEqual(n2, 1, "testFailed should be attributed to line 1, not line 2") + XCTAssertEqual(r2Range, 1 ... 1, "testFailed should be attributed to line 1, not line 2") // Line 3: anything that drains the overflow error from line 2 - let (n3, r3) = parser.feed("note: irrelevant") + let (r3Range, r3) = parser.feed("note: irrelevant") guard case .consumed(let e3) = r3, case .error = e3 else { return XCTFail("Expected .consumed(.error) from overflow, got \(r3)") } - XCTAssertEqual(n3, 2, "overflow error should be attributed to line 2") + XCTAssertEqual(r3Range, 2 ... 2, "overflow error should be attributed to line 2, not extended by line 3") } - // MARK: - Comment-continuation: no orphaned slot + // MARK: - Comment-continuation: range extends to include the comment line - func testCommentContinuationDoesNotShiftSubsequentLines() { + func testCommentContinuationExtendsRangeToIncludeCommentLine() { var parser = TrackingLineParser() // Line 1: recorded-issue → buffering _ = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") - // Line 2: comment continuation → merges into line 1's event, no overflow - let (n2, r2) = parser.feed("↳ Custom failure reason") + // Line 2: comment continuation → merges into line 1's event; range grows to 1...2 + let (r2Range, r2) = parser.feed("↳ Custom failure reason") guard case .consumed(let e2) = r2, case .testFailed = e2 else { return XCTFail("Expected .consumed(.testFailed), got \(r2)") } - XCTAssertEqual(n2, 1, "merged testFailed should be attributed to line 1") - - // Line 3: next event should be line 3, not 2 (no orphaned slot) - let (n3, r3) = parser.feed("main.swift:7:1: error: bad") + XCTAssertEqual( + r2Range, + 1 ... 2, + "merged testFailed should span lines 1 and 2, since line 2's text is in the message" + ) + + // Line 3: next event should be attributed to line 3 alone (no orphaned slot) + let (r3Range, r3) = parser.feed("main.swift:7:1: error: bad") guard case .consumed(let e3) = r3, case .error = e3 else { return XCTFail("Expected .consumed(.error), got \(r3)") } - XCTAssertEqual(n3, 3, "error on line 3 should not be bumped to line 2 by an orphaned slot") + XCTAssertEqual(r3Range, 3 ... 3, "error on line 3 should not be bumped to line 2 by an orphaned slot") } // MARK: - flush attributes events to their buffered lines @@ -85,7 +89,7 @@ final class TrackingLineParserTests: XCTestCase { _ = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") let events = parser.flush() XCTAssertEqual(events.count, 1) - XCTAssertEqual(events[0].lineNumber, 1) + XCTAssertEqual(events[0].lineRange, 1 ... 1) guard case .testFailed = events[0].1 else { return XCTFail("Expected .testFailed, got \(events[0].1)") } @@ -102,19 +106,19 @@ final class TrackingLineParserTests: XCTestCase { func testMultiLineLinkerAttributedToFinalLine() { var parser = TrackingLineParser() - let (n1, r1) = parser.feed("Undefined symbols for architecture arm64:") + let (r1Range, r1) = parser.feed("Undefined symbols for architecture arm64:") XCTAssertEqual(r1, .ignored) - XCTAssertEqual(n1, 0) + XCTAssertNil(r1Range) - let (n2, r2) = parser.feed(" \"_MissingSymbol\", referenced from:") + let (r2Range, r2) = parser.feed(" \"_MissingSymbol\", referenced from:") XCTAssertEqual(r2, .ignored) - XCTAssertEqual(n2, 0) + XCTAssertNil(r2Range) - let (n3, r3) = parser.feed(" objc-class-ref in SomeFile.o") + let (r3Range, r3) = parser.feed(" objc-class-ref in SomeFile.o") guard case .consumed(let e3) = r3, case .linkerError = e3 else { return XCTFail("Expected .consumed(.linkerError), got \(r3)") } - XCTAssertEqual(n3, 3) + XCTAssertEqual(r3Range, 3 ... 3) } // MARK: - Multiple consecutive consumed events @@ -127,11 +131,11 @@ final class TrackingLineParserTests: XCTestCase { "main.swift:3:1: warning: warning three", ] for (index, line) in lines.enumerated() { - let (lineNumber, result) = parser.feed(line) + let (lineRange, result) = parser.feed(line) guard case .consumed = result else { return XCTFail("Expected .consumed for line \(index + 1), got \(result)") } - XCTAssertEqual(lineNumber, index + 1) + XCTAssertEqual(lineRange, (index + 1) ... (index + 1)) } } @@ -159,8 +163,90 @@ final class TrackingLineParserTests: XCTestCase { _ = parser.feed("note: a") _ = parser.feed("note: b") _ = parser.feed("note: c") - let (lineNumber, result) = parser.feed("main.swift:1:1: error: something") + let (lineRange, result) = parser.feed("main.swift:1:1: error: something") guard case .consumed = result else { return XCTFail("Expected .consumed") } - XCTAssertEqual(lineNumber, 4) + XCTAssertEqual(lineRange, 4 ... 4) + } + + // MARK: - Consecutive recorded-issue lines (no comment continuation) attribute independently + + func testConsecutiveRecordedIssuesAttributeIndependently() { + var parser = TrackingLineParser() + + // Line 1: recorded-issue → buffering + let (r1Range, r1) = parser.feed("✘ Test \"myTest()\" recorded an issue at Foo.swift:10:1: Expectation failed") + XCTAssertEqual(r1, .buffering) + XCTAssertEqual(r1Range, 1 ... 1) + + // Line 2: another recorded-issue → flushes line 1's event, buffers itself + let (r2Range, r2) = parser.feed( + "✘ Test \"myTest()\" recorded an issue at Foo.swift:11:1: Expectation failed" + ) + guard case .consumed(let e2) = r2, case .testFailed = e2 else { + return XCTFail("Expected .consumed(.testFailed) flushed from line 1, got \(r2)") + } + XCTAssertEqual(r2Range, 1 ... 1, "line 1's event must not absorb line 2 just because line 2 also buffers") + + // Line 3: unrelated line drains line 2's buffered event + let (r3Range, r3) = parser.feed("note: irrelevant") + guard case .consumed(let e3) = r3, case .testFailed = e3 else { + return XCTFail("Expected .consumed(.testFailed) flushed from line 2, got \(r3)") + } + XCTAssertEqual(r3Range, 2 ... 2, "line 2's event must be attributed to line 2 alone, not extended by line 3") + } + + // MARK: - Unparseable recorded-issue line: buffered slot resolves via .ignored, not .consumed + + func testUnparseableRecordedIssueThenOverflowAttributesToOverflowLine() { + var parser = TrackingLineParser() + + // Line 1: contains the recordedIssue marker but not the strict " recorded an issue at " + // pattern parseFailedTest requires, so it buffers but will never produce an event. + let (r1Range, r1) = parser.feed( + "✘ Test \"myTest()\" recorded an issue: something went wrong" + ) + XCTAssertEqual(r1, .buffering) + XCTAssertEqual(r1Range, 1 ... 1) + + // Line 2: unrelated error. The buffered line-1 slot resolves with no event (.ignored + // overall), but line 2's own error is queued internally as overflow. + let (r2Range, r2) = parser.feed("main.swift:5:1: error: something broke") + XCTAssertEqual(r2, .ignored) + XCTAssertNil(r2Range, "line 1's dead slot must not be reported as a delivered event") + + // Line 3: drains the queue, delivering line 2's overflow error. + let (r3Range, r3) = parser.feed("note: irrelevant") + guard case .consumed(let e3) = r3, case .error = e3 else { + return XCTFail("Expected .consumed(.error) from overflow, got \(r3)") + } + XCTAssertEqual( + r3Range, + 2 ... 2, + "overflow error should be attributed to line 2, not the dead line-1 slot" + ) + } + + func testUnparseableRecordedIssueThenCommentContinuationDropsDeadRange() { + var parser = TrackingLineParser() + + // Line 1: unparseable recorded-issue line, same as above. + let (r1Range, r1) = parser.feed( + "✘ Test \"myTest()\" recorded an issue: something went wrong" + ) + XCTAssertEqual(r1, .buffering) + XCTAssertEqual(r1Range, 1 ... 1) + + // Line 2: looks like a comment continuation, but there is no buffered event to merge + // into, so nothing is emitted and no overflow is queued either. + let (r2Range, r2) = parser.feed("↳ Custom failure reason") + XCTAssertEqual(r2, .ignored) + XCTAssertNil(r2Range) + + // Line 3: an unrelated error must be attributed to itself, not to the dead line-1 slot. + let (r3Range, r3) = parser.feed("main.swift:7:1: error: bad") + guard case .consumed(let e3) = r3, case .error = e3 else { + return XCTFail("Expected .consumed(.error), got \(r3)") + } + XCTAssertEqual(r3Range, 3 ... 3, "error on line 3 should not be bumped to the dead line-1 slot") } }