Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Sources/XCSiftCore/LineParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,37 @@ 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
}

/// `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.
Expand All @@ -143,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()
Expand Down Expand Up @@ -175,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 == " " })
)
Expand All @@ -187,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
}
}
Expand Down
162 changes: 162 additions & 0 deletions Sources/XCSiftCore/TrackingLineParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// MARK: - TrackingLineParser

/// 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, 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 (lineRange, result) = parser.feed(line)
/// if case .consumed(let event) = result {
/// print("lines \(lineRange!): \(event)")
/// }
/// }
/// for (lineRange, event) in parser.flush() {
/// print("lines \(lineRange): \(event)")
/// }
/// ```
public struct TrackingLineParser: Sendable {

// MARK: - State

private var inner: LineParser
private var lineCounter: Int = 0

/// 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`. 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<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
/// range of 1-based input line numbers that **produced** the event.
///
/// 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 `(lineRange, LineResult)`.
@discardableResult
public mutating func feed(_ line: String) -> (lineRange: ClosedRange<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
let didMerge = inner.didMergeCurrentLine
let droppedDeadBufferedLine = inner.droppedDeadBufferedLine

switch result {
case .ignored:
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.
pendingLineRanges.append(currentLine ... currentLine)
return (currentLine ... currentLine, result)

case .consumed:
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 sourceLineRange = pendingLineRanges.removeFirst()
return (sourceLineRange, result)
}
}

/// 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 `(lineRange: ClosedRange<Int>, 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<Int>, ParseEvent)] {
let events = inner.flush()
var out: [(lineRange: ClosedRange<Int>, ParseEvent)] = []
out.reserveCapacity(events.count)
for event in events {
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).
pendingLineRanges.removeAll()
return out
}
}
Loading