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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,15 @@ The codebase follows a modular architecture:
### Core Components

1. **main.swift** - Entry point using Swift ArgumentParser
- Reads from stdin and coordinates parsing/output
- Reads stdin through `StreamingLineReader` and coordinates parsing/output
- Outputs JSON or TOON format (controlled by `--format` / `-f` flag)

1b. **StreamingLineReader.swift** - Bounded stdin reader
- Reads 64 KiB chunks and frames lines on the newline byte (CRLF input keeps a trailing `\r`)
- Lines longer than `LineParser.maximumLineBytes` (64 KiB) are ignored and reported to stderr

2. **OutputParser.swift** - Core parsing logic
- `StreamingOutputParser` accumulates events line-by-line; `OutputParser` wraps it for complete input
- `OutputParser` class with regex-based line parsing
- Defines data structures: `BuildResult`, `BuildSummary`, `BuildError`, `BuildWarning`, `FailedTest`, `SlowTest`, `CodeCoverage`, `FileCoverage`, `BuildInfo`, `TargetBuildInfo`, `Executable`
- Pattern matching for various Xcode/SPM output formats
Expand Down
150 changes: 84 additions & 66 deletions Sources/XCSiftCore/LineParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,19 @@ public enum LineResult {
///
/// ```swift
/// var parser = LineParser()
/// for line in output.split(separator: "\n") {
/// if case .consumed(let event) = parser.feed(String(line)) {
/// for line in lines {
/// if case .consumed(let event) = parser.feed(line) {
/// handle(event)
/// }
/// }
/// for event in parser.flush() { handle(event) }
/// ```
public struct LineParser: Sendable {

/// Maximum UTF-8 byte length accepted for one input line.
public static let maximumLineBytes = 5_000
/// Maximum UTF-8 byte length accepted for one input line. Longer lines are ignored.
///
/// The budget is in bytes, so a non-ASCII diagnostic spends two to four bytes per character.
public static let maximumLineBytes = 64 * 1024

// MARK: - Multi-line linker state
private var currentLinkerArchitecture: String?
Expand Down Expand Up @@ -753,9 +755,10 @@ public struct LineParser: Sendable {
private func hasPotentialLinkerPrefix(_ line: String) -> Bool {
for byte in line.utf8 {
switch byte {
case 0x20, 0x09:
case UInt8(ascii: " "), UInt8(ascii: "\t"):
continue
case 0x22, 0x55, 0x64, 0x66, 0x6C:
// First byte of `"symbol"`, `Undefined symbols`, `duplicate symbol`, `ld:`.
case UInt8(ascii: "\""), UInt8(ascii: "U"), UInt8(ascii: "d"), UInt8(ascii: "l"):
return true
default:
return false
Expand Down Expand Up @@ -983,10 +986,25 @@ public struct LineParser: Sendable {

// MARK: - Error / Warning Parsing

private static let warningFormatUTF8 = Array(XcodebuildSymbols.warningFormat.utf8)
/// A literal marker kept in both forms so the search never re-encodes it per line.
struct UTF8Needle: Sendable {
let bytes: [UInt8]
let text: String

init(_ text: String) {
self.bytes = Array(text.utf8)
self.text = text
}
}

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

private func warningFormatRange(in line: String) -> Range<String.Index>? {
let marker = Self.warningFormatUTF8
/// Byte-exact substring search. `String.range(of:)` is Unicode-aware and dominates the
/// profile when every line of a large log runs several searches.
static func range(of needle: UTF8Needle, in line: String) -> Range<String.Index>? {
let marker = needle.bytes
if let byteOffset = line.utf8.withContiguousStorageIfAvailable({ bytes in
guard bytes.count >= marker.count else { return -1 }

Expand All @@ -1006,7 +1024,31 @@ public struct LineParser: Sendable {
return lowerBound ..< upperBound
}

return line.range(of: XcodebuildSymbols.warningFormat, options: .literal)
return line.range(of: needle.text, options: .literal)
}

static func contains(_ needle: UTF8Needle, in line: String) -> Bool {
range(of: needle, in: line) != nil
}

/// Splits the `file:line` prefix that precedes a diagnostic marker.
private func parseFileAndLine(_ prefix: Substring) -> (file: Substring, line: Int?) {
guard let finalColon = prefix.lastIndex(of: ":"),
let number = Int(prefix[prefix.index(after: finalColon)...])
else {
return (prefix, nil)
}
return (prefix[..<finalColon], number)
}

/// Splits the `file:line:column` prefix that precedes a diagnostic marker.
private func parseLocation(_ prefix: Substring) -> (file: Substring, line: Int?, column: Int?) {
let (withoutFinal, finalNumber) = parseFileAndLine(prefix)
guard let column = finalNumber else { return (prefix, nil, nil) }

let (file, lineNumber) = parseFileAndLine(withoutFinal)
guard let lineNumber else { return (withoutFinal, column, nil) }
return (file, lineNumber, column)
}

private func isJSONLikeLine(_ line: String) -> Bool {
Expand Down Expand Up @@ -1047,41 +1089,36 @@ public struct LineParser: Sendable {
if isRuntimeLogNoise(line) { return nil }
if line.hasPrefix(" "), line.contains("|") || line.contains("`") { return nil }

if let errorRange = line.range(of: XcodebuildSymbols.errorFormat) {
let beforeError = String(line[..<errorRange.lowerBound])
let message = String(line[errorRange.upperBound...])
let components = beforeError.split(separator: ":", omittingEmptySubsequences: false)
if components.count >= 3, let lineNum = Int(components[components.count - 2]),
let colNum = Int(components[components.count - 1])
{
let file = components[0 ..< (components.count - 2)].joined(separator: ":")
return BuildError(file: file, line: lineNum, message: message, column: colNum)
} else if components.count >= 2, let lineNum = Int(components[components.count - 1]) {
let file = components[0 ..< (components.count - 1)].joined(separator: ":")
return BuildError(file: file, line: lineNum, message: message, column: nil)
} else {
return BuildError(file: beforeError, line: nil, message: message, column: nil)
}
if let errorRange = Self.range(of: Self.errorFormatNeedle, in: line) {
let location = parseLocation(line[..<errorRange.lowerBound])
return BuildError(
file: String(location.file),
line: location.line,
message: String(line[errorRange.upperBound...]),
column: location.column
)
}

if let fatalRange = line.range(of: XcodebuildSymbols.fatalErrorFormat) {
let beforeError = String(line[..<fatalRange.lowerBound])
let message = String(line[fatalRange.upperBound...])
let components = beforeError.split(separator: ":", omittingEmptySubsequences: false)
if components.count >= 2, let lineNum = Int(components[components.count - 1]) {
let file = components[0 ..< (components.count - 1)].joined(separator: ":")
return BuildError(file: file, line: lineNum, message: message, column: nil)
} else {
return BuildError(file: beforeError, line: nil, message: message, column: nil)
}
let (file, lineNumber) = parseFileAndLine(line[..<fatalRange.lowerBound])
return BuildError(
file: String(file),
line: lineNumber,
message: String(line[fatalRange.upperBound...]),
column: nil
)
}

if line.hasSuffix(XcodebuildSymbols.fatalErrorSuffix), !line.contains(" xctest[") {
let beforeFatal = String(line.dropLast(XcodebuildSymbols.fatalErrorSuffix.count))
let components = beforeFatal.split(separator: ":", omittingEmptySubsequences: false)
if components.count >= 2, let lineNum = Int(components[components.count - 1]) {
let file = components[0 ..< (components.count - 1)].joined(separator: ":")
return BuildError(file: file, line: lineNum, message: "Fatal error", column: nil)
let beforeFatal = line.dropLast(XcodebuildSymbols.fatalErrorSuffix.count)
let (file, lineNumber) = parseFileAndLine(beforeFatal)
if let lineNumber {
return BuildError(
file: String(file),
line: lineNumber,
message: "Fatal error",
column: nil
)
}
}

Expand All @@ -1105,33 +1142,14 @@ public struct LineParser: Sendable {
if isRuntimeLogNoise(line) { return nil }
if line.hasPrefix(" "), line.contains("|") || line.contains("`") { return nil }

if let warningRange = warningFormatRange(in: line) {
let beforeWarning = line[..<warningRange.lowerBound]
let message = String(line[warningRange.upperBound...])
if let finalColon = beforeWarning.lastIndex(of: ":") {
let finalComponent = beforeWarning[beforeWarning.index(after: finalColon)...]
if let finalNumber = Int(finalComponent) {
let beforeFinalComponent = beforeWarning[..<finalColon]
if let precedingColon = beforeFinalComponent.lastIndex(of: ":"),
let lineNumber = Int(
beforeFinalComponent[beforeFinalComponent.index(after: precedingColon)...]
)
{
return BuildWarning(
file: String(beforeFinalComponent[..<precedingColon]),
line: lineNumber,
message: message,
column: finalNumber
)
}
return BuildWarning(
file: String(beforeFinalComponent),
line: finalNumber,
message: message
)
}
}
return BuildWarning(file: String(beforeWarning), line: nil, message: message)
if let warningRange = Self.range(of: Self.warningFormatNeedle, in: line) {
let location = parseLocation(line[..<warningRange.lowerBound])
return BuildWarning(
file: String(location.file),
line: location.line,
message: String(line[warningRange.upperBound...]),
column: location.column
)
}

if line.hasPrefix("warning: ") {
Expand Down
66 changes: 15 additions & 51 deletions Sources/XCSiftCore/OutputParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,6 @@ public struct StreamingOutputParser {
let message: String
}

/// A single-allocation, exact warning identity for count-only parsing. NUL separates fields;
/// doubling NUL inside the optional file keeps that separator unambiguous.
private struct CompactWarningKey: Hashable {
private let value: String

init(_ warning: BuildWarning) {
var value = String()
value.reserveCapacity(
(warning.file?.utf8.count ?? 0) + warning.message.utf8.count + 24
)
if let file = warning.file {
value.append("f")
if file.utf8.contains(0) {
value.append(file.replacingOccurrences(of: "\0", with: "\0\0"))
} else {
value.append(file)
}
} else {
value.append("n")
}
value.append("\0")
if let line = warning.line {
value.append("l")
value.append(String(line))
} else {
value.append("n")
}
value.append("\0m")
value.append(warning.message)
self.value = value
}
}

private struct ParseState {
var errors: [BuildError] = []
var warnings: [BuildWarning] = []
Expand All @@ -64,8 +31,6 @@ public struct StreamingOutputParser {
var testTimeAccumulator: Double = 0
var seenTestNames: Set<String> = []
var seenWarnings: Set<WarningKey> = []
var seenCompactWarnings: Set<CompactWarningKey> = []
var lastCountOnlyWarning: WarningKey?
var warningCount = 0
var seenErrors: Set<String> = []
var seenLinkerErrors: Set<String> = []
Expand Down Expand Up @@ -156,7 +121,9 @@ public struct StreamingOutputParser {
/// Feeding after ``finish(coverage:)`` is a programmer error.
public mutating func feed(_ line: String) {
precondition(finishedResult == nil, "Cannot feed a finished StreamingOutputParser")
if shouldDiscoverTestedTarget, testedTarget == nil {
if shouldDiscoverTestedTarget, testedTarget == nil,
LineParser.contains(LineParser.xctestBundleNeedle, in: line)
{
testedTarget = Self.extractTestedTarget(fromLine: line)
}
if case .consumed(let event) = lineParser.feed(line) {
Expand Down Expand Up @@ -327,15 +294,7 @@ public struct StreamingOutputParser {

case .warning(let w):
let key = WarningKey(file: w.file, line: w.line, message: w.message)
let inserted: Bool
if shouldRetainWarnings {
inserted = state.seenWarnings.insert(key).inserted
} else {
guard state.lastCountOnlyWarning != key else { return }
state.lastCountOnlyWarning = key
inserted = state.seenCompactWarnings.insert(CompactWarningKey(w)).inserted
}
guard inserted else { return }
guard state.seenWarnings.insert(key).inserted else { return }
state.warningCount += 1
if shouldRetainWarnings {
state.warnings.append(w)
Expand Down Expand Up @@ -530,6 +489,13 @@ public class OutputParser {

public init() {}

/// Splits on the newline byte. `String.split(separator: "\n")` never matches a CRLF line
/// ending, because Swift treats `\r\n` as one `Character`.
private static func lines(of input: String) -> [String] {
input.utf8.split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: false)
.map { String(decoding: $0, as: UTF8.self) }
}

/// Parses raw xcodebuild or SPM output and returns a structured ``BuildResult``.
///
/// Each invocation uses a fresh streaming session, so an `OutputParser` instance can be reused
Expand Down Expand Up @@ -566,8 +532,8 @@ public class OutputParser {
xcbeautify: xcbeautify
)

for line in input.split(separator: "\n", omittingEmptySubsequences: false) {
parser.feed(String(line))
for line in Self.lines(of: input) {
parser.feed(line)
}

let result = parser.finish(coverage: coverage)
Expand All @@ -579,10 +545,8 @@ public class OutputParser {
///
/// A `.xctest` suite name such as `MyAppTests.xctest` resolves to `MyApp`.
public func extractTestedTarget(from input: String) -> String? {
for line in input.split(separator: "\n") {
if let testedTarget = StreamingOutputParser.extractTestedTarget(
fromLine: String(line)
) {
for line in Self.lines(of: input) {
if let testedTarget = StreamingOutputParser.extractTestedTarget(fromLine: line) {
return testedTarget
}
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/xcsift/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,13 @@ struct XCSift: ParsableCommand {
throw ExitCode.failure
}

if inputScan.oversizedLinesDropped > 0 {
writeToStderr(
"hint: Ignored \(inputScan.oversizedLinesDropped) input line(s) longer than "
+ "\(LineParser.maximumLineBytes) bytes.\n"
)
}

// Check if input is empty
if !inputScan.containsNonWhitespace {
throw ValidationError(
Expand Down
7 changes: 7 additions & 0 deletions Sources/xcsift/xcsift.docc/Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,13 @@ xcodebuild build 2>&1 | xcsift --Werror --exit-on-failure
swift test 2>&1 | xcsift -W -E -f toon
```

## Large Build Logs

xcsift parses stdin as it arrives, so memory stays bounded no matter how large the log is.

One input line is limited to 64 KiB of UTF-8. Longer lines are ignored, and xcsift reports the
count to stderr.

## Exit Codes

- `0` — Build succeeded (or xcsift completed normally without `--exit-on-failure`)
Expand Down
Loading