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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,12 @@ The codebase follows a modular architecture:
- For multi-line context, use **look-ahead/look-back** in the `parse()` loop (has access to `lines` array by index)
- Existing examples: look-back for `PhaseScriptExecution` context, look-ahead for Swift Testing `#expect` comments
- `failedTests` are deduplicated by normalized test name — duplicate names get merged, not appended
- **Source-context echo**: a `file:line:col: error:/warning:/note:` header opens a block that holds
the indented source line and the caret line. `LineParser.sourceContextOpen` tracks the block and
skips error/warning parsing inside it, because echoed source can carry `: error: ` in a string
literal. The block closes on the caret line or on the next line without indentation. Indentation
alone must never suppress a diagnostic: indented tool output (`swiftgen: error: …`) stays
reportable.

### Key Features
- **Error/Warning Parsing**: Multiple regex patterns handle various Xcode error formats
Expand Down
62 changes: 59 additions & 3 deletions Sources/XCSiftCore/LineParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ public struct LineParser: Sendable {
private var pendingRecordedIssueLine: String?
private var lookBackBuffer: [String] = []

/// True while a compiler source-context block is open. A `file:line:col: error:/warning:/note:`
/// header is followed by the offending source line, indented, then a caret line. Echoed source
/// can carry `: error: ` inside a string literal or a comment, which is not a diagnostic.
/// Tracking the block keeps indented tool output — `swiftgen: error: …` — reportable.
private var sourceContextOpen = false

// MARK: - xcbeautify
private let shouldParseXcbeautify: Bool
private let shouldParseBuildInfo: Bool
Expand Down Expand Up @@ -331,7 +337,7 @@ public struct LineParser: Sendable {
// MARK: - Core dispatch

private struct LineCandidates: OptionSet, Sendable {
let rawValue: UInt8
let rawValue: UInt16

static let error = LineCandidates(rawValue: 1 << 0)
static let warning = LineCandidates(rawValue: 1 << 1)
Expand All @@ -341,6 +347,9 @@ public struct LineParser: Sendable {
static let executable = LineCandidates(rawValue: 1 << 5)
static let recordedIssue = LineCandidates(rawValue: 1 << 6)
static let jsonSyntax = LineCandidates(rawValue: 1 << 7)
/// `note:` opens a source-context block but is not itself a reported diagnostic, so the
/// bit stays out of ``parserCategories``.
static let diagnosticNote = LineCandidates(rawValue: 1 << 8)
static let parserCategories: LineCandidates = [
.error, .warning, .test, .status, .buildInfo, .executable,
]
Expand All @@ -365,6 +374,7 @@ public struct LineParser: Sendable {

add(XcodebuildSymbols.warningKeyword, candidates: .warning)
add(XcodebuildSymbols.errorKeyword, candidates: .error)
add(XcodebuildSymbols.noteKeyword, candidates: .diagnosticNote)
add(XcodebuildSymbols.failedKeyword, candidates: [.error, .test, .status])
add(XcodebuildSymbols.passedKeyword, candidates: .test)

Expand Down Expand Up @@ -513,6 +523,20 @@ public struct LineParser: Sendable {
candidates.remove(.buildInfo)
}

// Source-context echo tracking (state only, no event emitted)
let firstByte = line.utf8.first
let isIndented = firstByte == UInt8(ascii: " ") || firstByte == UInt8(ascii: "\t")
let insideSourceContext = isIndented && sourceContextOpen
if insideSourceContext {
if Self.isCaretLine(line) { sourceContextOpen = false }
} else if !isIndented {
// A `note:` header opens a block too, and no other parser reads those lines. An
// `error:`/`warning:` header sets the flag from its own parse result below, so the
// hot path never scans the same line twice.
sourceContextOpen =
candidates.contains(.diagnosticNote) && isLocatedHeader(line, Self.noteFormatNeedle)
}

if candidates.intersection(.parserCategories).isEmpty { return nil }

// Suite name tracking (state only, no event emitted)
Expand Down Expand Up @@ -544,9 +568,11 @@ public struct LineParser: Sendable {
}

// Error
if candidates.contains(.error),
if candidates.contains(.error), !insideSourceContext,
let error = parseError(line, checkJSON: candidates.contains(.jsonSyntax))
{
// A located diagnostic header opens a source-context block.
if !isIndented, error.line != nil { sourceContextOpen = true }
// Fatal error + lastStartedTestName → also emit a synthetic testFailed (matches original)
if line.contains("Fatal error"), let testName = lastStartedTestName {
lastStartedTestName = nil
Expand All @@ -565,8 +591,9 @@ public struct LineParser: Sendable {
}

// Warning
if candidates.contains(.warning) {
if candidates.contains(.warning), !insideSourceContext {
if let warning = parseWarning(line, checkJSON: candidates.contains(.jsonSyntax)) {
if !isIndented, warning.line != nil { sourceContextOpen = true }
return .warning(warning)
}
if let warning = parseRuntimeWarning(line) { return .warning(warning) }
Expand Down Expand Up @@ -999,6 +1026,7 @@ public struct LineParser: Sendable {

static let warningFormatNeedle = UTF8Needle(XcodebuildSymbols.warningFormat)
static let errorFormatNeedle = UTF8Needle(XcodebuildSymbols.errorFormat)
static let noteFormatNeedle = UTF8Needle(XcodebuildSymbols.noteFormat)
static let xctestBundleNeedle = UTF8Needle(".xctest")

/// Byte-exact substring search. `String.range(of:)` is Unicode-aware and dominates the
Expand Down Expand Up @@ -1084,6 +1112,34 @@ public struct LineParser: Sendable {
return false
}

/// True for a `file:line:col: <marker>` header. The location test rejects tool output such as
/// `swiftgen: error: …`, which never echoes source.
private func isLocatedHeader(_ line: String, _ needle: UTF8Needle) -> Bool {
guard let range = Self.range(of: needle, in: line) else { return false }
return parseLocation(line[..<range.lowerBound]).line != nil
}

/// True for the ` ^~~~~` line that closes a source-context block. The last byte is a
/// cheap gate: every indented log line reaches this test.
private static func isCaretLine(_ line: String) -> Bool {
guard let last = line.utf8.last,
last == UInt8(ascii: "^") || last == UInt8(ascii: "~")
else { return false }

var sawCaret = false
for byte in line.utf8 {
switch byte {
case UInt8(ascii: " "), UInt8(ascii: "\t"), UInt8(ascii: "~"):
continue
case UInt8(ascii: "^"):
sawCaret = true
default:
return false
}
}
return sawCaret
}

private func parseError(_ line: String, checkJSON: Bool) -> BuildError? {
if checkJSON && isJSONLikeLine(line) { return nil }
if isRuntimeLogNoise(line) { return nil }
Expand Down
2 changes: 2 additions & 0 deletions Sources/XCSiftCore/XcodebuildSymbols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum XcodebuildSymbols {
// Diagnostic format patterns (used in parseError/parseWarning)
static let errorFormat = ": error: "
static let warningFormat = ": warning: "
static let noteFormat = ": note: "
static let fatalErrorFormat = ": Fatal error: "
static let fatalErrorSuffix = ": Fatal error"

Expand All @@ -15,6 +16,7 @@ enum XcodebuildSymbols {
// Fast-path filter keywords
static let errorKeyword = "error:"
static let warningKeyword = "warning:"
static let noteKeyword = "note:"
static let fatalErrorKeyword = "Fatal error"
static let passedKeyword = "passed"
static let failedKeyword = "failed"
Expand Down
141 changes: 141 additions & 0 deletions Tests/XCSiftCoreTests/SourceContextEchoTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import XCTest

import XCSiftCore

/// Tests for compiler source-context echo (issue #78).
///
/// A `file:line:col: error:/warning:/note:` header is followed by the offending source line,
/// indented, and a caret line. The echoed source can carry `: error: ` inside a string literal or
/// a comment. Those bytes are not a diagnostic. Indentation alone must not decide this: indented
/// tool output stands on its own and stays reportable.
final class SourceContextEchoTests: XCTestCase {
func testEchoedSourceLineWithErrorTextDoesNotFailTheBuild() {
let parser = OutputParser()
let input = """
/p/M.swift:8:19: warning: expression took 4ms to type-check (limit: 1ms)
let msg = "upload: error: " + String(1) + String(2)
^~~~~~~~~~~~~~~~~
Build complete!
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "success")
XCTAssertEqual(result.summary.errors, 0)
XCTAssertEqual(result.summary.warnings, 1)
}

func testEchoedSourceLineWithWarningTextIsNotAWarning() {
let parser = OutputParser()
let input = """
/p/M.swift:8:19: error: cannot find 'bar' in scope
let msg = "upload: warning: " + bar()
^~~~~~~~~~~~~~~~~~~
** BUILD FAILED **
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.summary.errors, 1)
XCTAssertEqual(result.summary.warnings, 0)
}

func testEchoedSourceLineUnderNoteHeaderIsNotADiagnostic() {
let parser = OutputParser()
let input = """
/p/A.swift:36:39: warning: call to main actor-isolated initializer
dateProvider: DateProviding = LiveDateProvider(),
^
/p/B.swift:16:8: note: calls from outside the actor context are asynchronous
init() { log("actor: error: none") }
^
Build complete!
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "success")
XCTAssertEqual(result.summary.errors, 0)
XCTAssertEqual(result.summary.warnings, 1)
}

/// Indented tool output is not source context. Dropping it would report a broken build as a
/// successful one.
func testIndentedToolErrorStillFailsTheBuild() {
let parser = OutputParser()
let input = """
swiftgen: error: template not found
** BUILD SUCCEEDED **
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "failed")
XCTAssertEqual(result.summary.errors, 1)
}

func testIndentedScriptPhaseFailureStillFailsTheBuild() {
let parser = OutputParser()
let input = """
Command PhaseScriptExecution failed with a nonzero exit code
** BUILD SUCCEEDED **
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "failed")
XCTAssertEqual(result.summary.errors, 1)
}

/// The caret line ends the block, so the next indented line is tool output again.
func testCaretLineClosesTheEchoBlock() {
let parser = OutputParser()
let input = """
/p/M.swift:1:1: warning: expression took 4ms to type-check (limit: 1ms)
let msg = "upload: error: " + String(1)
^~~~~~~~~~~~~~~~~
swiftgen: error: template not found
** BUILD SUCCEEDED **
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.status, "failed")
XCTAssertEqual(result.summary.errors, 1)
XCTAssertEqual(result.errors[0].message, "template not found")
XCTAssertEqual(result.summary.warnings, 1)
}

func testDiagnosticAfterAnEchoBlockIsStillParsed() {
let parser = OutputParser()
let input = """
/p/M.swift:1:1: warning: expression took 4ms to type-check (limit: 1ms)
let msg = "upload: error: " + String(1)
^~~~~~~~~~~~~~~~~
/p/N.swift:9:5: error: cannot find 'bar' in scope
** BUILD FAILED **
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.summary.errors, 1)
XCTAssertEqual(result.errors[0].file, "/p/N.swift")
XCTAssertEqual(result.errors[0].line, 9)
XCTAssertEqual(result.summary.warnings, 1)
}

/// Tool output such as `swiftgen: error: …` carries no `:line:` location, so it must not open
/// a block and hide the indented line that follows it.
func testToolErrorDoesNotOpenAnEchoBlock() {
let parser = OutputParser()
let input = """
swiftgen: error: template not found
sourcery: error: could not parse the model
** BUILD SUCCEEDED **
"""

let result = parser.parse(input: input)

XCTAssertEqual(result.summary.errors, 2)
}
}