diff --git a/Benchmarks/README.md b/Benchmarks/README.md new file mode 100644 index 0000000..a55b7ad --- /dev/null +++ b/Benchmarks/README.md @@ -0,0 +1,72 @@ +# Large-log benchmark + +`large-log.sh` measures the release CLI against deterministic synthetic Xcode build logs. It +records elapsed time, throughput, and peak resident memory while also verifying that the TOON +summary reports a successful build. + +Run the 10 MiB pull-request smoke benchmark: + +```bash +Benchmarks/large-log.sh 10 +``` + +Run the full size progression used for large-log regression analysis: + +```bash +Benchmarks/large-log.sh 10 100 500 +``` + +To benchmark an existing binary without rebuilding, set `XCSIFT_BENCHMARK_BINARY`: + +```bash +XCSIFT_BENCHMARK_BINARY=/path/to/xcsift Benchmarks/large-log.sh 10 +``` + +Select a workload with `XCSIFT_BENCHMARK_PROFILE` (the default is `phase`): + +```bash +XCSIFT_BENCHMARK_PROFILE=video-go-shaped Benchmarks/large-log.sh 10 100 500 +``` + +| Profile | Purpose | +| --- | --- | +| `phase` | Phase-heavy parser stress case | +| `fast-reject` | ASCII build-command noise with no reportable event | +| `fast-reject-unicode` | The same rejection path with Unicode input | +| `warning-duplicate` | Repeated compiler warning and deduplication path | +| `warning-unique` | Distinct warning identities and retained-state growth | +| `fixture-mixed` | Repeated sections of the checked-in real build fixture | +| `video-go-shaped` | 0.13% phases, 9.43% warnings, and otherwise build-command noise | + +The CSV output is intended for comparison on the same machine. Hosted CI timing and RSS vary too +much for a strict wall-clock gate, so normal XCTest coverage asserts bounded framing and output +correctness instead. + +Every profile appends a successful terminal marker. The synthetic profiles isolate specific parser +paths; they are not substitutes for checking output equivalence on real build logs. Reader +buffering is bounded, but retained errors and unique-warning deduplication state still scale with +the number of distinct diagnostics. + +## Reference results + +Recorded on 2026-08-07 using the release build on an arm64 Mac with macOS 26.6 and Swift 6.3.3. +The primary results are medians of three runs: + +| Profile | Nominal input | Elapsed | Throughput | Peak RSS | +| --- | ---: | ---: | ---: | ---: | +| `phase` | 10 MiB | 0.02 s | 500.00 MiB/s | 8.70 MiB | +| `phase` | 100 MiB | 0.18 s | 555.56 MiB/s | 8.75 MiB | +| `phase` | 500 MiB | 0.87 s | 574.71 MiB/s | 8.75 MiB | +| `video-go-shaped` | 10 MiB | 0.02 s | 500.00 MiB/s | 10.28 MiB | +| `video-go-shaped` | 100 MiB | 0.23 s | 434.78 MiB/s | 20.45 MiB | +| `video-go-shaped` | 500 MiB | 1.10 s | 454.55 MiB/s | 20.47 MiB | + +Additional single-run 500 MiB checks measured 0.72 s for `fast-reject`, 0.94 s for +`fast-reject-unicode`, 0.63 s for `fixture-mixed`, and 3.41 s for `warning-duplicate`. The +`warning-unique` profile measured 1.36 s and 265.92 MiB RSS at 100 MiB, illustrating that exact +deduplication state grows with distinct diagnostics even though input framing remains bounded. + +For comparison, the initial streaming implementation processed the same 500 MiB `phase` workload +in 226.97 s at 2.20 MiB/s with 8.92 MiB peak RSS. The optimized parser completes it in 0.87 s at +574.71 MiB/s with 8.75 MiB peak RSS: about 261 times faster with the same bounded-memory behavior. +Treat these numbers as a reference snapshot rather than a portable performance threshold. diff --git a/Benchmarks/large-log.sh b/Benchmarks/large-log.sh new file mode 100755 index 0000000..56e58fa --- /dev/null +++ b/Benchmarks/large-log.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +set -eu + +script_directory=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repository_root=$(CDPATH= cd -- "$script_directory/.." && pwd) +benchmark_root=$(mktemp -d "${TMPDIR:-/tmp}/xcsift-large-log.XXXXXX") +trap 'rm -rf -- "$benchmark_root"' EXIT + +if [ -n "${XCSIFT_BENCHMARK_BINARY:-}" ]; then + xcsift_binary=$XCSIFT_BENCHMARK_BINARY +else + (cd "$repository_root" && swift build -c release >/dev/null) + xcsift_binary="$repository_root/.build/release/xcsift" +fi + +if [ ! -x "$xcsift_binary" ]; then + echo "error: xcsift binary is not executable: $xcsift_binary" >&2 + exit 1 +fi + +if [ "$#" -gt 0 ]; then + sizes_mib="$*" +else + sizes_mib="10 100 500" +fi + +profile=${XCSIFT_BENCHMARK_PROFILE:-phase} + +generate_input() { + local requested_bytes=$1 + local generated_path=$2 + + case "$profile" in + phase) + yes "CompileSwiftSources normal arm64 /tmp/Foo.swift (in target 'VideoGo' from project 'VideoGo')" \ + | head -c "$requested_bytes" >"$generated_path" + ;; + fast-reject) + yes " cd /Users/runner/work/VideoGo/VideoGo && /usr/bin/touch /private/tmp/DerivedData/VideoGo/Build/Intermediates.noindex/stamp" \ + | head -c "$requested_bytes" >"$generated_path" + ;; + fast-reject-unicode) + yes " cd /Users/runner/work/VideoGo/构建路径 && /usr/bin/touch /private/tmp/DerivedData/VideoGo/Build/Intermediates.noindex/stamp" \ + | head -c "$requested_bytes" >"$generated_path" + ;; + warning-duplicate) + yes "/tmp/VideoGo/Sources/Foo.swift:42:7: warning: immutable value 'value' was never used" \ + | head -c "$requested_bytes" >"$generated_path" + ;; + warning-unique) + awk -v target="$requested_bytes" ' + BEGIN { + bytes = 0 + for (i = 1; bytes < target; i++) { + line = sprintf("/tmp/VideoGo/Sources/File%d.swift:%d:7: warning: unique diagnostic number %d", i % 10000, i % 500 + 1, i) + print line + bytes += length(line) + 1 + } + } + ' >"$generated_path" + ;; + fixture-mixed) + while :; do + sed '$d' "$repository_root/Tests/XCSiftCoreTests/Fixtures/build.txt" + done | head -c "$requested_bytes" >"$generated_path" + ;; + video-go-shaped) + awk -v target="$requested_bytes" ' + BEGIN { + bytes = 0 + quote = sprintf("%c", 39) + for (i = 0; bytes < target; i++) { + slot = i % 10000 + if (slot < 13) { + line = "CompileSwiftSources normal arm64 /tmp/Foo.swift (in target " \ + quote "VideoGo" quote " from project " quote "VideoGo" quote ")" + } else if (slot < 956) { + line = sprintf("/tmp/VideoGo/Sources/File%d.swift:%d:7: warning: repeated diagnostic group %d", i % 1024, i % 500 + 1, i % 1024) + } else { + line = " cd /Users/runner/work/VideoGo/VideoGo && /usr/bin/touch " \ + "/private/tmp/DerivedData/VideoGo/Build/Intermediates.noindex/generated-stamp" + } + print line + bytes += length(line) + 1 + } + } + ' >"$generated_path" + ;; + *) + echo "error: unknown benchmark profile: $profile" >&2 + exit 1 + ;; + esac + + printf '\n** BUILD SUCCEEDED **\n' >>"$generated_path" +} + +printf 'profile,size_mib,bytes,elapsed_seconds,throughput_mib_per_second,peak_rss_mib\n' + +for size_mib in $sizes_mib; do + input_path="$benchmark_root/input-${size_mib}m.log" + output_path="$benchmark_root/output-${size_mib}m.toon" + metrics_path="$benchmark_root/metrics-${size_mib}m.txt" + requested_bytes=$((size_mib * 1024 * 1024)) + + generate_input "$requested_bytes" "$input_path" + + if [ "$(uname -s)" = "Darwin" ]; then + /usr/bin/time -lp "$xcsift_binary" -f toon <"$input_path" >"$output_path" 2>"$metrics_path" + elapsed_seconds=$(awk '$1 == "real" { print $2 }' "$metrics_path") + peak_rss_bytes=$(awk '/maximum resident set size/ { print $1 }' "$metrics_path") + peak_rss_mib=$(awk -v bytes="$peak_rss_bytes" 'BEGIN { printf "%.2f", bytes / 1048576 }') + else + /usr/bin/time -f 'elapsed_seconds=%e\npeak_rss_kib=%M' \ + "$xcsift_binary" -f toon <"$input_path" >"$output_path" 2>"$metrics_path" + elapsed_seconds=$(awk -F= '$1 == "elapsed_seconds" { print $2 }' "$metrics_path") + peak_rss_kib=$(awk -F= '$1 == "peak_rss_kib" { print $2 }' "$metrics_path") + peak_rss_mib=$(awk -v kib="$peak_rss_kib" 'BEGIN { printf "%.2f", kib / 1024 }') + fi + + if ! grep -q '^status: success$' "$output_path"; then + echo "error: benchmark output did not report success for ${size_mib} MiB" >&2 + exit 1 + fi + + actual_bytes=$(wc -c <"$input_path" | tr -d ' ') + throughput=$(awk -v bytes="$actual_bytes" -v seconds="$elapsed_seconds" \ + 'BEGIN { if (seconds > 0) printf "%.2f", (bytes / 1048576) / seconds; else printf "n/a" }') + + printf '%s,%s,%s,%s,%s,%s\n' \ + "$profile" "$size_mib" "$actual_bytes" "$elapsed_seconds" "$throughput" "$peak_rss_mib" +done diff --git a/README.md b/README.md index 16e52c1..906f3a8 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A Swift command-line tool to parse and format xcodebuild/SPM output for coding a - **Configuration files** — `.xcsift.toml` for project or user-wide defaults - **Quiet/Werror/exit-on-failure modes** — for CI pipelines - **xcbeautify/Tuist input** — parse pre-formatted output with `--xcbeautify` +- **Streaming large-log parsing** — consumes stdin incrementally with bounded line buffering See the [full documentation](https://ldomaradzki.github.io/xcsift/documentation/xcsift) for details. @@ -114,6 +115,13 @@ swift test # Run tests swift format --recursive --in-place . # Format (required before committing) ``` +Large-log release benchmarks are available in [`Benchmarks/`](Benchmarks/README.md): + +```bash +Benchmarks/large-log.sh 10 # Pull-request smoke benchmark +Benchmarks/large-log.sh 10 100 500 # Full size progression +``` + Documentation source is in `Sources/xcsift.docc/`. Preview locally: ```bash diff --git a/Sources/XCSiftCore/LineParser.swift b/Sources/XCSiftCore/LineParser.swift index 66a1ad2..543d5b0 100644 --- a/Sources/XCSiftCore/LineParser.swift +++ b/Sources/XCSiftCore/LineParser.swift @@ -66,7 +66,7 @@ public enum LineResult { /// after the last line to drain buffered state (look-ahead windows, in-flight crash detection). /// /// ```swift -/// let parser = LineParser() +/// var parser = LineParser() /// for line in output.split(separator: "\n") { /// if case .consumed(let event) = parser.feed(String(line)) { /// handle(event) @@ -76,6 +76,9 @@ public enum LineResult { /// ``` public struct LineParser: Sendable { + /// Maximum UTF-8 byte length accepted for one input line. + public static let maximumLineBytes = 5_000 + // MARK: - Multi-line linker state private var currentLinkerArchitecture: String? private var pendingLinkerSymbol: String? @@ -99,6 +102,7 @@ public struct LineParser: Sendable { // MARK: - xcbeautify private let shouldParseXcbeautify: Bool + private let shouldParseBuildInfo: Bool private var xcbeautifyHintEmitted: Bool = false /// `true` if the parser wrote an xcbeautify auto-detection hint to stderr during parsing. @@ -154,7 +158,13 @@ public struct LineParser: Sendable { /// - Parameter xcbeautify: Pass `true` when the input was pre-processed by xcbeautify or Tuist. /// Enables parsing of `[x]`/`❌` error markers, `[!]`/`⚠️` warning markers, and `✔`/`✖` test markers. public init(xcbeautify: Bool = false) { + self.init(xcbeautify: xcbeautify, parseBuildInfo: true) + } + + /// Internal feature gate used by aggregate parsers that omit build information. + init(xcbeautify: Bool = false, parseBuildInfo: Bool) { self.shouldParseXcbeautify = xcbeautify + self.shouldParseBuildInfo = parseBuildInfo } // MARK: - Public interface @@ -189,9 +199,10 @@ public struct LineParser: Sendable { // Called when a queued event is being returned; current line must still be processed. private mutating func enqueueFromLine(_ line: String) { updateLookBackBuffer(line) - if line.contains(XcodebuildSymbols.recordedIssue) { + let candidates = Self.relevantCandidates(in: line) + if candidates.contains(.recordedIssue), line.contains(XcodebuildSymbols.recordedIssue) { pendingRecordedIssueLine = line - } else if let event = processLine(line) { + } else if let event = processLine(line, candidates: candidates) { eventQueue.append(event) } } @@ -233,17 +244,18 @@ public struct LineParser: Sendable { // Path C: normal processing with look-back enrichment. private mutating func normalFeed(_ line: String) -> LineResult { - if line.contains(XcodebuildSymbols.recordedIssue) { + let candidates = Self.relevantCandidates(in: line) + if candidates.contains(.recordedIssue), line.contains(XcodebuildSymbols.recordedIssue) { pendingRecordedIssueLine = line updateLookBackBuffer(line) return .buffering } - var event = processLine(line) + var event = processLine(line, candidates: candidates) // PhaseScriptExecution look-back: enrich the error message with preceding context. - if line.contains("Command PhaseScriptExecution failed with a nonzero exit"), - case .error(let error) = event, error.message == line + if case .error(let error) = event, error.message == line, + line.contains("Command PhaseScriptExecution failed with a nonzero exit") { var contextLines: [String] = [] for contextLine in lookBackBuffer { @@ -315,20 +327,140 @@ public struct LineParser: Sendable { // MARK: - Core dispatch - /// Returns at most one ParseEvent for a line. Uses if/else if so only one branch fires. - private mutating func processLine(_ line: String) -> ParseEvent? { - if line.isEmpty || line.count > 5000 { return nil } + private struct LineCandidates: OptionSet, Sendable { + let rawValue: UInt8 + + static let error = LineCandidates(rawValue: 1 << 0) + static let warning = LineCandidates(rawValue: 1 << 1) + static let test = LineCandidates(rawValue: 1 << 2) + static let status = LineCandidates(rawValue: 1 << 3) + static let buildInfo = LineCandidates(rawValue: 1 << 4) + static let executable = LineCandidates(rawValue: 1 << 5) + static let recordedIssue = LineCandidates(rawValue: 1 << 6) + static let jsonSyntax = LineCandidates(rawValue: 1 << 7) + static let parserCategories: LineCandidates = [ + .error, .warning, .test, .status, .buildInfo, .executable, + ] + static let all = parserCategories.union(.jsonSyntax) + } - // Suite name tracking (state only, no event emitted) - if let suiteName = parseCompletedXCTestSuiteName(line) { - lastCompletedXCTestSuiteName = suiteName + private struct UTF8Marker: Sendable { + let bytes: [UInt8] + let candidates: LineCandidates + } + + /// Existing relevance markers grouped by their first UTF-8 byte. Scanning the line once avoids + /// asking Foundation to perform a separate Unicode-aware search for every marker. + private static let markerBuckets: [[UTF8Marker]] = { + var buckets = Array(repeating: [UTF8Marker](), count: 256) + + func add(_ marker: String, candidates: LineCandidates) { + let bytes = Array(marker.utf8) + precondition(!bytes.isEmpty) + buckets[Int(bytes[0])].append(UTF8Marker(bytes: bytes, candidates: candidates)) + } + + add(XcodebuildSymbols.warningKeyword, candidates: .warning) + add(XcodebuildSymbols.errorKeyword, candidates: .error) + add(XcodebuildSymbols.failedKeyword, candidates: [.error, .test, .status]) + add(XcodebuildSymbols.passedKeyword, candidates: .test) + + for marker in [ + "Build succeeded", + XcodebuildSymbols.succeededKeyword, + XcodebuildSymbols.buildFailedKeyword, + XcodebuildSymbols.testFailed, + XcodebuildSymbols.buildComplete, + ] { + add(marker, candidates: .status) + } + + for marker in [ + "Executed", + "] Testing ", + XcodebuildSymbols.startedSuffix, + "\" started", + XcodebuildSymbols.signalCode, + "Test run with ", + ] { + add(marker, candidates: .test) + } + + add(XcodebuildSymbols.fatalErrorKeyword, candidates: .error) + add(XcodebuildSymbols.swiftFilePattern, candidates: .warning) + add(XcodebuildSymbols.recordedIssue, candidates: [.test, .recordedIssue]) + add(XcodebuildSymbols.swiftTestingPass, candidates: .test) + add(XcodebuildSymbols.swiftTestingFail, candidates: .test) + add(XcodebuildSymbols.swiftTestingStartedPrefix, candidates: .test) + add(XcodebuildSymbols.emojiError, candidates: [.error, .test]) + + for marker in ["{", "[", "}", "]", "\"", "\\"] { + add(marker, candidates: .jsonSyntax) + } + + for marker in [ + XcodebuildSymbols.targetPrefix, + XcodebuildSymbols.dependencyOnTarget, + XcodebuildSymbols.spmCompiling, + XcodebuildSymbols.spmLinking, + "SwiftDriver", + ] { + add(marker, candidates: .buildInfo) + } + + return buckets + }() + + private static func relevantCandidates(in line: String) -> LineCandidates { + guard + let candidates = line.utf8.withContiguousStorageIfAvailable({ bytes in + var candidates: LineCandidates = [] + + for index in bytes.indices { + let byte = bytes[index] + for marker in markerBuckets[Int(byte)] { + if marker.bytes.count > bytes.count - index { continue } + + var matches = true + for offset in marker.bytes.indices + where bytes[index + offset] != marker.bytes[offset] { + matches = false + break + } + if matches { + candidates.formUnion(marker.candidates) + } + } + } + + return candidates + }) + else { + // Preserve parser behavior for an unusual non-contiguous UTF-8 view. + var candidates = LineCandidates.all + if line.contains(XcodebuildSymbols.recordedIssue) { + candidates.insert(.recordedIssue) + } + return candidates } + return candidates + } + + /// Returns at most one ParseEvent for a line. Uses if/else if so only one branch fires. + private mutating func processLine( + _ line: String, + candidates preclassifiedCandidates: LineCandidates? = nil + ) -> ParseEvent? { + if line.isEmpty || line.utf8.count > Self.maximumLineBytes { return nil } // Linker (multi-line state machine) if let event = parseLinkerLine(line) { return event } // xcbeautify auto-detection hint - if !shouldParseXcbeautify && !xcbeautifyHintEmitted { + if !shouldParseXcbeautify && !xcbeautifyHintEmitted, + let firstByte = line.utf8.first, + firstByte == 0x5B || firstByte == 0xE2 + { if line.hasPrefix(XCBeautifySymbols.asciiError + " ") || line.hasPrefix(XCBeautifySymbols.asciiWarning + " ") || line.hasPrefix(XCBeautifySymbols.error + " ") @@ -359,72 +491,60 @@ public struct LineParser: Sendable { } // Fast-path filter - let containsRelevant = - line.contains(XcodebuildSymbols.errorKeyword) - || line.contains(XcodebuildSymbols.warningKeyword) - || line.contains(XcodebuildSymbols.failedKeyword) - || line.contains(XcodebuildSymbols.passedKeyword) - || line.contains(XcodebuildSymbols.swiftTestingFail) - || line.contains(XcodebuildSymbols.swiftTestingPass) - || line.contains(XcodebuildSymbols.emojiError) - || line.contains("Build succeeded") - || line.contains("Build failed") - || line.contains("Executed") - || line.contains("] Testing ") - || line.contains(XcodebuildSymbols.succeededKeyword) - || line.contains(XcodebuildSymbols.buildFailedKeyword) - || line.contains(XcodebuildSymbols.testFailed) - || line.contains(XcodebuildSymbols.buildComplete) - || line.contains(XcodebuildSymbols.recordedIssue) - || line.hasPrefix(XcodebuildSymbols.registerWithLaunchServices) + var candidates = preclassifiedCandidates ?? Self.relevantCandidates(in: line) + if line.hasPrefix(XcodebuildSymbols.registerWithLaunchServices) || line.hasPrefix(XcodebuildSymbols.validate) - || line.contains(XcodebuildSymbols.fatalErrorKeyword) - || (line.hasPrefix("/") && line.contains(XcodebuildSymbols.swiftFilePattern)) - || line.contains(XcodebuildSymbols.startedSuffix) - || line.contains("\" started") - || line.contains(XcodebuildSymbols.signalCode) - || line.hasPrefix(XcodebuildSymbols.restartingAfter) - || line.hasPrefix("Build target ") - || line.hasPrefix("Build target '") - || line.contains(XcodebuildSymbols.targetPrefix) - || line.contains(XcodebuildSymbols.dependencyOnTarget) - || line.contains(XcodebuildSymbols.spmCompiling) - || line.contains(XcodebuildSymbols.spmLinking) - || line.contains("Test run with ") - || line.hasPrefix("CompileSwiftSources ") - || line.hasPrefix("CompileC ") - || line.hasPrefix("Ld ") - || line.hasPrefix("CopySwiftLibs ") - || line.hasPrefix("PhaseScriptExecution ") - || line.hasPrefix("LinkAssetCatalog ") - || line.hasPrefix("ProcessInfoPlistFile ") - || (line.contains("SwiftDriver") && line.contains("Compilation")) - || line.hasPrefix("RegisterWithLaunchServices ") - || line.hasPrefix("Validate ") - - if !containsRelevant { return nil } + { + candidates.insert(.executable) + } + if line.hasPrefix(XcodebuildSymbols.restartingAfter) { + candidates.insert(.test) + } + if shouldParseBuildInfo + && (Self.phasePatterns.contains(where: { line.hasPrefix($0.prefix) }) + || line.hasPrefix("Build target ") + || line.hasPrefix("Build target '")) + { + candidates.insert(.buildInfo) + } + if !shouldParseBuildInfo { + candidates.remove(.buildInfo) + } + + if candidates.intersection(.parserCategories).isEmpty { return nil } + + // Suite name tracking (state only, no event emitted) + if candidates.contains(.test), let suiteName = parseCompletedXCTestSuiteName(line) { + lastCompletedXCTestSuiteName = suiteName + } // Crash detection - if let event = parseCrashLine(line) { return event } + if candidates.contains(.test), let event = parseCrashLine(line) { return event } // Parallel test scheduling - if line.contains("] Testing "), let match = line.firstMatch(of: Self.parallelTestSchedulingRegex) { + if candidates.contains(.test), line.contains("] Testing "), + let match = line.firstMatch(of: Self.parallelTestSchedulingRegex) + { if let index = Int(match.1), let total = Int(match.2) { return .parallelTestScheduled(index: index, total: total) } } // Executables - if let exec = parseExecutable(line) { return .executable(exec) } + if candidates.contains(.executable), let exec = parseExecutable(line) { + return .executable(exec) + } // Failed test - if let failed = parseFailedTest(line) { + if candidates.contains(.test), let failed = parseFailedTest(line) { if lastStartedTestName == failed.test { lastStartedTestName = nil } return .testFailed(failed) } // Error - if let error = parseError(line) { + if candidates.contains(.error), + let error = parseError(line, checkJSON: candidates.contains(.jsonSyntax)) + { // Fatal error + lastStartedTestName → also emit a synthetic testFailed (matches original) if line.contains("Fatal error"), let testName = lastStartedTestName { lastStartedTestName = nil @@ -443,27 +563,41 @@ public struct LineParser: Sendable { } // Warning - if let warning = parseWarning(line) { return .warning(warning) } - if let warning = parseRuntimeWarning(line) { return .warning(warning) } + if candidates.contains(.warning) { + if let warning = parseWarning(line, checkJSON: candidates.contains(.jsonSyntax)) { + return .warning(warning) + } + if let warning = parseRuntimeWarning(line) { return .warning(warning) } + } // Passed test - if let (name, duration) = parsePassedTest(line) { + if candidates.contains(.test), let (name, duration) = parsePassedTest(line) { if lastStartedTestName == name { lastStartedTestName = nil } return .testPassed(name: name, duration: duration) } // Build / test time, XCTest summaries, Swift Testing summaries - if let event = parseBuildAndTestTime(line) { return event } + if candidates.contains(.status) || candidates.contains(.test) { + if let event = parseBuildAndTestTime(line) { return event } + } - // Build phases - if let (phase, target) = parseBuildPhase(line) { return .buildPhase(target: target, phase: phase) } - if let (phase, target) = parseSPMPhase(line) { return .buildPhase(target: target, phase: phase) } + if candidates.contains(.buildInfo) { + // Build phases + if let (phase, target) = parseBuildPhase(line) { + return .buildPhase(target: target, phase: phase) + } + if let (phase, target) = parseSPMPhase(line) { + return .buildPhase(target: target, phase: phase) + } - // Target timing - if let (name, duration) = parseTargetTiming(line) { return .targetCompleted(name: name, duration: duration) } + // Target timing + if let (name, duration) = parseTargetTiming(line) { + return .targetCompleted(name: name, duration: duration) + } - // Dependency graph - if let event = parseDependencyGraph(line) { return event } + // Dependency graph + if let event = parseDependencyGraph(line) { return event } + } return nil } @@ -513,7 +647,19 @@ public struct LineParser: Sendable { // MARK: - Linker Parsing private mutating func parseLinkerLine(_ line: String) -> ParseEvent? { - let trimmed = line.trimmingCharacters(in: .whitespaces) + let hasPendingContext = pendingLinkerSymbol != nil || pendingDuplicateSymbol != nil + guard hasPendingContext || hasPotentialLinkerPrefix(line) else { + return nil + } + let leadingTrimmed = line.drop { $0 == " " || $0 == "\t" } + guard + let lastNonWhitespace = leadingTrimmed.lastIndex(where: { + $0 != " " && $0 != "\t" + }) + else { + return nil + } + let trimmed = leadingTrimmed[...lastNonWhitespace] if trimmed.hasPrefix(XcodebuildSymbols.undefinedSymbols) { let afterPrefix = trimmed.dropFirst(XcodebuildSymbols.undefinedSymbols.count) @@ -573,12 +719,12 @@ public struct LineParser: Sendable { if pendingDuplicateSymbol != nil && (trimmed.hasSuffix(".o") || trimmed.hasSuffix(".a")) && (line.hasPrefix(" ") || line.hasPrefix("\t")) { - pendingConflictingFiles.append(trimmed) + pendingConflictingFiles.append(String(trimmed)) return nil } if trimmed.hasPrefix("ld: building for ") && trimmed.contains("but linking") { - return .linkerError(LinkerError(message: trimmed)) + return .linkerError(LinkerError(message: String(trimmed))) } if trimmed.hasPrefix("ld: ") && trimmed.contains("duplicate symbol") { @@ -604,6 +750,20 @@ public struct LineParser: Sendable { return nil } + private func hasPotentialLinkerPrefix(_ line: String) -> Bool { + for byte in line.utf8 { + switch byte { + case 0x20, 0x09: + continue + case 0x22, 0x55, 0x64, 0x66, 0x6C: + return true + default: + return false + } + } + return false + } + // MARK: - Crash Detection private mutating func parseCrashLine(_ line: String) -> ParseEvent? { @@ -658,8 +818,15 @@ public struct LineParser: Sendable { } } - if line.hasPrefix("◇ Test ") { - let afterPrefix = line.index(line.startIndex, offsetBy: "◇ Test ".count) + if line.hasPrefix(XcodebuildSymbols.swiftTestingStartedPrefix) { + let lineWithoutCarriageReturn = line.last == "\r" ? line.dropLast() : line[...] + if lineWithoutCarriageReturn == XcodebuildSymbols.swiftTestingRunStarted { + return nil + } + let afterPrefix = line.index( + line.startIndex, + offsetBy: XcodebuildSymbols.swiftTestingStartedPrefix.count + ) if let result = extractSwiftTestingName(from: line, after: afterPrefix) { let afterName = line[result.endIndex...] if afterName.hasPrefix(" started") { @@ -816,6 +983,32 @@ public struct LineParser: Sendable { // MARK: - Error / Warning Parsing + private static let warningFormatUTF8 = Array(XcodebuildSymbols.warningFormat.utf8) + + private func warningFormatRange(in line: String) -> Range? { + let marker = Self.warningFormatUTF8 + if let byteOffset = line.utf8.withContiguousStorageIfAvailable({ bytes in + guard bytes.count >= marker.count else { return -1 } + + for start in 0 ... (bytes.count - marker.count) where bytes[start] == marker[0] { + var offset = 1 + while offset < marker.count, bytes[start + offset] == marker[offset] { + offset += 1 + } + if offset == marker.count { return start } + } + return -1 + }) { + guard byteOffset >= 0 else { return nil } + let utf8 = line.utf8 + let lowerBound = utf8.index(utf8.startIndex, offsetBy: byteOffset) + let upperBound = utf8.index(lowerBound, offsetBy: marker.count) + return lowerBound ..< upperBound + } + + return line.range(of: XcodebuildSymbols.warningFormat, options: .literal) + } + private func isJSONLikeLine(_ line: String) -> Bool { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("{") || trimmed.hasPrefix("[") || trimmed.hasPrefix("}") @@ -849,8 +1042,8 @@ public struct LineParser: Sendable { return false } - private func parseError(_ line: String) -> BuildError? { - if isJSONLikeLine(line) { return nil } + private func parseError(_ line: String, checkJSON: Bool) -> BuildError? { + if checkJSON && isJSONLikeLine(line) { return nil } if isRuntimeLogNoise(line) { return nil } if line.hasPrefix(" "), line.contains("|") || line.contains("`") { return nil } @@ -907,26 +1100,38 @@ public struct LineParser: Sendable { return nil } - private func parseWarning(_ line: String) -> BuildWarning? { - if isJSONLikeLine(line) { return nil } + private func parseWarning(_ line: String, checkJSON: Bool) -> BuildWarning? { + if checkJSON && isJSONLikeLine(line) { return nil } if isRuntimeLogNoise(line) { return nil } if line.hasPrefix(" "), line.contains("|") || line.contains("`") { return nil } - if let warningRange = line.range(of: XcodebuildSymbols.warningFormat) { - let beforeWarning = String(line[..= 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 BuildWarning(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 BuildWarning(file: file, line: lineNum, message: message) - } else { - return BuildWarning(file: beforeWarning, line: nil, message: message) + if let finalColon = beforeWarning.lastIndex(of: ":") { + let finalComponent = beforeWarning[beforeWarning.index(after: finalColon)...] + if let finalNumber = Int(finalComponent) { + let beforeFinalComponent = beforeWarning[.. = [] - var seenWarnings: Set = [] + var seenWarnings: Set = [] + var seenCompactWarnings: Set = [] + var lastCountOnlyWarning: WarningKey? + var warningCount = 0 var seenErrors: Set = [] var seenLinkerErrors: Set = [] var seenPassedTestNames: Set = [] @@ -47,10 +90,23 @@ public class OutputParser { } private var state = ParseState() - - /// `true` if the most recent ``parse(input:printWarnings:warningsAsErrors:coverage:printCoverageDetails:slowThreshold:printBuildInfo:printExecutables:xcbeautify:)`` call caused an xcbeautify auto-detection hint to be written to stderr. + private var lineParser = LineParser() + private var shouldPrintWarnings = false + private var shouldRetainWarnings = true + private var shouldTreatWarningsAsErrors = false + private var shouldPrintCoverageDetails = false + private var slowThreshold: Double? + private var shouldPrintBuildInfo = false + private var shouldPrintExecutables = false + private var shouldDiscoverTestedTarget = false + private var finishedResult: BuildResult? + + /// `true` if this session caused an xcbeautify auto-detection hint to be written to stderr. public private(set) var didEmitXcbeautifyHint: Bool = false + /// The tested target discovered while feeding build output, when target discovery is enabled. + public private(set) var testedTarget: String? + // Target regex for extractTestedTarget (used externally) private nonisolated(unsafe) static let testSuiteRegex = Regex { /[Tt]est [Ss]uite '/ @@ -58,52 +114,66 @@ public class OutputParser { ".xctest'" } - public init() {} - - /// Parses raw xcodebuild or SPM output and returns a structured ``BuildResult``. - /// - /// The method is stateless across calls — each invocation resets internal accumulators — - /// so a single `OutputParser` instance can be reused for multiple runs. + /// Creates a single-use streaming parse session. /// /// - Parameters: - /// - input: The complete build output as a single string (typically captured from stderr). - /// - printWarnings: When `true`, the returned `BuildResult` includes the full warnings list; - /// when `false` (default), only the warning count appears in the summary. - /// - warningsAsErrors: When `true`, every warning is converted to an error and the warnings - /// list is cleared, mirroring `-Werror` behavior. - /// - coverage: Pre-parsed ``CodeCoverage`` data to embed in the result. Pass `nil` (default) - /// when coverage is not needed. - /// - printCoverageDetails: When `true`, per-file coverage details are included in the result; - /// when `false` (default), only the summary percentage is included. - /// - slowThreshold: Tests whose duration exceeds this value (in seconds) are reported as slow. - /// Pass `nil` (default) to disable slow-test detection. - /// - printBuildInfo: When `true`, per-target phases, durations, and dependency graph data are - /// included in the result. - /// - printExecutables: When `true`, the executables list is populated in the result. - /// - xcbeautify: Pass `true` when the input was pre-processed by xcbeautify or Tuist. - /// - Returns: A ``BuildResult`` representing the parsed build state. - public func parse( - input: String, + /// - printWarnings: Include retained warning details when encoding the result. + /// - retainWarnings: Keep warning models in the result. Disable for exact count-only parsing. + /// - warningsAsErrors: Convert warnings to errors when finishing; this forces retention. + /// - printCoverageDetails: Include per-file coverage details when encoding the result. + /// - slowThreshold: Report tests slower than this many seconds. + /// - printBuildInfo: Accumulate per-target phases, timing, and dependencies. + /// - printExecutables: Include discovered executable targets. + /// - discoverTestedTarget: Detect the `.xctest` target used for coverage filtering. + /// - xcbeautify: Parse xcbeautify/Tuist markers. + public init( printWarnings: Bool = false, + retainWarnings: Bool = true, warningsAsErrors: Bool = false, - coverage: CodeCoverage? = nil, printCoverageDetails: Bool = false, slowThreshold: Double? = nil, printBuildInfo: Bool = false, printExecutables: Bool = false, + discoverTestedTarget: Bool = false, xcbeautify: Bool = false - ) -> BuildResult { - state = ParseState() - var lineParser = LineParser(xcbeautify: xcbeautify) - let lines = input.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + ) { + lineParser = LineParser( + xcbeautify: xcbeautify, + parseBuildInfo: printBuildInfo + ) + shouldPrintWarnings = printWarnings + shouldRetainWarnings = retainWarnings || printWarnings || warningsAsErrors + shouldTreatWarningsAsErrors = warningsAsErrors + shouldPrintCoverageDetails = printCoverageDetails + self.slowThreshold = slowThreshold + shouldPrintBuildInfo = printBuildInfo + shouldPrintExecutables = printExecutables + shouldDiscoverTestedTarget = discoverTestedTarget + } - for line in lines { - if case .consumed(let event) = lineParser.feed(line) { - handleEvent(event, printBuildInfo: printBuildInfo) - } + /// Feeds one complete line, without its trailing newline, into the current parse. + /// + /// 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 { + testedTarget = Self.extractTestedTarget(fromLine: line) + } + if case .consumed(let event) = lineParser.feed(line) { + handleEvent(event, printBuildInfo: shouldPrintBuildInfo) + } + didEmitXcbeautifyHint = lineParser.didEmitXcbeautifyHint + } + + /// Finishes the current parse and returns its aggregate result. + /// + /// Repeated calls return the result produced by the first call. + public mutating func finish(coverage: CodeCoverage? = nil) -> BuildResult { + if let finishedResult { + return finishedResult } for event in lineParser.flush() { - handleEvent(event, printBuildInfo: printBuildInfo) + handleEvent(event, printBuildInfo: shouldPrintBuildInfo) } didEmitXcbeautifyHint = lineParser.didEmitXcbeautifyHint let sawSuccessMarker = lineParser.sawSuccessMarker @@ -113,7 +183,7 @@ public class OutputParser { var finalErrors = state.errors var finalWarnings = state.warnings - if warningsAsErrors && !state.warnings.isEmpty { + if shouldTreatWarningsAsErrors && !state.warnings.isEmpty { for warning in state.warnings { finalErrors.append( BuildError( @@ -185,7 +255,7 @@ public class OutputParser { }() let slowTests: [SlowTest] = { - guard let threshold = slowThreshold else { return [] } + guard let threshold = self.slowThreshold else { return [] } return detectSlowTests(threshold: threshold) }() @@ -198,7 +268,7 @@ public class OutputParser { let summary = BuildSummary( errors: finalErrors.count, - warnings: finalWarnings.count, + warnings: shouldTreatWarningsAsErrors ? 0 : state.warningCount, failedTests: totalFailed, linkerErrors: state.linkerErrors.count, passedTests: computedPassedTests, @@ -207,11 +277,11 @@ public class OutputParser { coveragePercent: coverage?.lineCoverage, slowTests: slowTests.isEmpty ? nil : slowTests.count, flakyTests: flakyTests.isEmpty ? nil : flakyTests.count, - executables: printExecutables && !state.executables.isEmpty ? state.executables.count : nil + executables: shouldPrintExecutables && !state.executables.isEmpty ? state.executables.count : nil ) let buildInfo: BuildInfo? = - printBuildInfo + shouldPrintBuildInfo ? { let targets = state.targetOrder.map { targetName in TargetBuildInfo( @@ -225,7 +295,7 @@ public class OutputParser { return BuildInfo(targets: targets, slowestTargets: slowestTargets) }() : nil - return BuildResult( + let result = BuildResult( status: status, summary: summary, errors: finalErrors, @@ -237,16 +307,18 @@ public class OutputParser { flakyTests: flakyTests, buildInfo: buildInfo, executables: state.executables, - printWarnings: printWarnings, - printCoverageDetails: printCoverageDetails, - printBuildInfo: printBuildInfo, - printExecutables: printExecutables + printWarnings: shouldPrintWarnings, + printCoverageDetails: shouldPrintCoverageDetails, + printBuildInfo: shouldPrintBuildInfo, + printExecutables: shouldPrintExecutables ) + finishedResult = result + return result } // MARK: - Event handling (accumulation + dedup) - private func handleEvent(_ event: ParseEvent, printBuildInfo: Bool) { + private mutating func handleEvent(_ event: ParseEvent, printBuildInfo: Bool) { switch event { case .error(let e): let key = "\(e.file ?? ""):\(e.line ?? 0):\(e.message)" @@ -254,9 +326,20 @@ public class OutputParser { state.errors.append(e) case .warning(let w): - let key = "\(w.file ?? ""):\(w.line ?? 0):\(w.message)" - guard state.seenWarnings.insert(key).inserted else { return } - state.warnings.append(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 } + state.warningCount += 1 + if shouldRetainWarnings { + state.warnings.append(w) + } case .linkerError(let e): let key = "\(e.symbol):\(e.message)" @@ -405,32 +488,19 @@ public class OutputParser { return Array(sorted.prefix(limit).map { $0.name }) } - /// Extracts the name of the tested target from xcodebuild output. - /// - /// Scans for a `Test Suite '*.xctest' started` line and derives the target name by stripping - /// the `.xctest` suffix and an optional `Tests` suffix (e.g. `MyAppTests.xctest` → `MyApp`). - /// - /// This is used internally by ``CoverageParser`` to filter coverage data to the relevant target. - /// - /// - Parameter input: Raw xcodebuild or SPM output. - /// - Returns: The inferred target name, or `nil` if no `.xctest` suite line was found. - public func extractTestedTarget(from input: String) -> String? { - let lines = input.split(separator: "\n") - for line in lines { - let lineStr = String(line) - let hasTestSuite = - lineStr.contains("Test Suite '") || lineStr.contains("Test suite '") - if hasTestSuite, lineStr.contains(".xctest"), lineStr.contains("started") { - if let match = lineStr.firstMatch(of: Self.testSuiteRegex) { - var targetName = String(match.1) - if targetName.hasSuffix("Tests") { - targetName = String(targetName.dropLast(5)) - } - return targetName - } - } + fileprivate static func extractTestedTarget(fromLine line: String) -> String? { + let hasTestSuite = line.contains("Test Suite '") || line.contains("Test suite '") + guard hasTestSuite, line.contains(".xctest"), line.contains("started"), + let match = line.firstMatch(of: Self.testSuiteRegex) + else { + return nil } - return nil + + var targetName = String(match.1) + if targetName.hasSuffix("Tests") { + targetName = String(targetName.dropLast(5)) + } + return targetName } private func normalizeTestName(_ testName: String) -> String { @@ -448,3 +518,74 @@ public class OutputParser { state.sawBundleLevelXCTestSummary ? state.xctestBundleFailedCount : state.xctestFallbackFailedCount } } + +/// Parses a complete xcodebuild or SPM output string into a structured ``BuildResult``. +/// +/// Each call creates an isolated ``StreamingOutputParser`` session, so one `OutputParser` can be +/// reused across multiple complete inputs without carrying state between runs. +public class OutputParser { + /// `true` if the most recent ``parse(input:printWarnings:warningsAsErrors:coverage:printCoverageDetails:slowThreshold:printBuildInfo:printExecutables:xcbeautify:)`` + /// call emitted an xcbeautify auto-detection hint. + public private(set) var didEmitXcbeautifyHint = false + + public init() {} + + /// 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 + /// across multiple complete inputs without carrying state between runs. + /// + /// - Parameters: + /// - input: The complete build output as a single string. + /// - printWarnings: Include full warning details instead of summary count only. + /// - warningsAsErrors: Convert warnings to errors in the final result. + /// - coverage: Pre-parsed coverage data to embed in the result. + /// - printCoverageDetails: Include per-file coverage details. + /// - slowThreshold: Report tests slower than this many seconds. + /// - printBuildInfo: Include per-target phases, timing, and dependencies. + /// - printExecutables: Include discovered executable targets. + /// - xcbeautify: Parse xcbeautify/Tuist markers. + public func parse( + input: String, + printWarnings: Bool = false, + warningsAsErrors: Bool = false, + coverage: CodeCoverage? = nil, + printCoverageDetails: Bool = false, + slowThreshold: Double? = nil, + printBuildInfo: Bool = false, + printExecutables: Bool = false, + xcbeautify: Bool = false + ) -> BuildResult { + var parser = StreamingOutputParser( + printWarnings: printWarnings, + warningsAsErrors: warningsAsErrors, + printCoverageDetails: printCoverageDetails, + slowThreshold: slowThreshold, + printBuildInfo: printBuildInfo, + printExecutables: printExecutables, + xcbeautify: xcbeautify + ) + + for line in input.split(separator: "\n", omittingEmptySubsequences: false) { + parser.feed(String(line)) + } + + let result = parser.finish(coverage: coverage) + didEmitXcbeautifyHint = parser.didEmitXcbeautifyHint + return result + } + + /// Extracts the tested target name used to filter xcodebuild coverage data. + /// + /// 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) + ) { + return testedTarget + } + } + return nil + } +} diff --git a/Sources/XCSiftCore/XcodebuildSymbols.swift b/Sources/XCSiftCore/XcodebuildSymbols.swift index ddeead8..4f63ca2 100644 --- a/Sources/XCSiftCore/XcodebuildSymbols.swift +++ b/Sources/XCSiftCore/XcodebuildSymbols.swift @@ -40,6 +40,8 @@ enum XcodebuildSymbols { // Swift Testing symbols (macOS Private Use Area + Linux fallback) static let swiftTestingPass = "✓" static let swiftTestingFail = "✘" + static let swiftTestingStartedPrefix = "◇ Test " + static let swiftTestingRunStarted = "◇ Test run started." static let emojiError = "❌" // U+100135 (macOS PUA) / U+21B3 (Linux) — carries #expect custom comment on the line after recorded-issue static let swiftTestingDetailsPrefix = "􀄵" diff --git a/Sources/xcsift/StreamingLineReader.swift b/Sources/xcsift/StreamingLineReader.swift new file mode 100644 index 0000000..5ddba7b --- /dev/null +++ b/Sources/xcsift/StreamingLineReader.swift @@ -0,0 +1,182 @@ +import Foundation +import XCSiftCore +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +protocol InputChunkSource { + mutating func read(upToCount count: Int) throws -> Data? +} + +struct POSIXInputSource: InputChunkSource { + let fileDescriptor: Int32 + + mutating func read(upToCount count: Int) throws -> Data? { + var data = Data(count: count) + + while true { + let bytesRead = data.withUnsafeMutableBytes { buffer in + #if canImport(Darwin) + Darwin.read(fileDescriptor, buffer.baseAddress, count) + #elseif canImport(Glibc) + Glibc.read(fileDescriptor, buffer.baseAddress, count) + #elseif canImport(Musl) + Musl.read(fileDescriptor, buffer.baseAddress, count) + #endif + } + + if bytesRead > 0 { + data.removeSubrange(bytesRead ..< data.count) + return data + } + if bytesRead == 0 { + return nil + } + if errno == EINTR { + continue + } + + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } +} + +struct InputScan { + let containsNonWhitespace: Bool + let maximumBufferedBytes: Int + let oversizedLinesDropped: Int +} + +struct StreamingLineReader { + private let chunkSize: Int + private let maximumLineBytes: Int + private var pendingBytes = Data() + private var receivedBytes = false + private var endedWithNewline = false + private var containsNonWhitespace = false + private var isDiscardingOversizedLine = false + private var maximumBufferedBytes = 0 + private var oversizedLinesDropped = 0 + + init(chunkSize: Int = 64 * 1024, maximumLineBytes: Int = LineParser.maximumLineBytes) { + precondition(chunkSize > 0, "chunkSize must be greater than zero") + precondition(maximumLineBytes > 0, "maximumLineBytes must be greater than zero") + self.chunkSize = chunkSize + self.maximumLineBytes = maximumLineBytes + } + + mutating func consume( + from source: inout Source, + onLine: (String) throws -> Void + ) throws -> InputScan { + while let chunk = try source.read(upToCount: chunkSize) { + guard !chunk.isEmpty else { continue } + receivedBytes = true + endedWithNewline = chunk.last == 0x0A + + try chunk.withUnsafeBytes { buffer in + var segmentStart = 0 + while let newline = Self.firstNewline(in: buffer, startingAt: segmentStart) { + try emitCompleteSegment(buffer[segmentStart ..< newline], to: onLine) + segmentStart = newline + 1 + } + append(buffer[segmentStart...]) + } + } + + if isDiscardingOversizedLine || !pendingBytes.isEmpty { + try emitPendingLine(to: onLine) + } else if receivedBytes && endedWithNewline { + try onLine("") + } + + return InputScan( + containsNonWhitespace: containsNonWhitespace, + maximumBufferedBytes: maximumBufferedBytes, + oversizedLinesDropped: oversizedLinesDropped + ) + } + + private static func firstNewline( + in buffer: UnsafeRawBufferPointer, + startingAt start: Int + ) -> Int? { + guard start < buffer.count, let baseAddress = buffer.baseAddress else { return nil } + + #if canImport(Darwin) + let match = Darwin.memchr(baseAddress.advanced(by: start), 0x0A, buffer.count - start) + #elseif canImport(Glibc) + let match = Glibc.memchr(baseAddress.advanced(by: start), 0x0A, buffer.count - start) + #elseif canImport(Musl) + let match = Musl.memchr(baseAddress.advanced(by: start), 0x0A, buffer.count - start) + #endif + + guard let match else { return nil } + return baseAddress.distance(to: UnsafeRawPointer(match)) + } + + private mutating func emitCompleteSegment( + _ bytes: Bytes, + to onLine: (String) throws -> Void + ) throws where Bytes.Element == UInt8 { + if isDiscardingOversizedLine || !pendingBytes.isEmpty { + append(bytes) + try emitPendingLine(to: onLine) + return + } + + guard bytes.count <= maximumLineBytes else { + isDiscardingOversizedLine = true + try emitPendingLine(to: onLine) + return + } + + maximumBufferedBytes = max(maximumBufferedBytes, bytes.count) + try emitDecodedLine(String(decoding: bytes, as: UTF8.self), to: onLine) + } + + private mutating func append(_ bytes: Bytes) where Bytes.Element == UInt8 { + guard !bytes.isEmpty, !isDiscardingOversizedLine else { return } + guard pendingBytes.count + bytes.count <= maximumLineBytes else { + pendingBytes.removeAll(keepingCapacity: true) + isDiscardingOversizedLine = true + return + } + pendingBytes.append(contentsOf: bytes) + maximumBufferedBytes = max(maximumBufferedBytes, pendingBytes.count) + } + + private mutating func emitPendingLine(to onLine: (String) throws -> Void) throws { + if isDiscardingOversizedLine { + oversizedLinesDropped += 1 + isDiscardingOversizedLine = false + pendingBytes.removeAll(keepingCapacity: true) + // The bytes are intentionally unavailable for a full Unicode whitespace scan. Treat + // any oversized line as content so a real build invocation is never rejected as empty. + containsNonWhitespace = true + try onLine("") + return + } + + let line = String(decoding: pendingBytes, as: UTF8.self) + pendingBytes.removeAll(keepingCapacity: true) + try emitDecodedLine(line, to: onLine) + } + + private mutating func emitDecodedLine( + _ line: String, + to onLine: (String) throws -> Void + ) throws { + if !containsNonWhitespace { + let whitespace = CharacterSet.whitespacesAndNewlines + containsNonWhitespace = line.unicodeScalars.contains { + !whitespace.contains($0) + } + } + try onLine(line) + } +} diff --git a/Sources/xcsift/main.swift b/Sources/xcsift/main.swift index ce4a496..42b11e4 100644 --- a/Sources/xcsift/main.swift +++ b/Sources/xcsift/main.swift @@ -265,11 +265,32 @@ struct XCSift: ParsableCommand { ) } - let parser = OutputParser() - let input = readStandardInput() + var parser = StreamingOutputParser( + printWarnings: resolved.warnings, + retainWarnings: resolved.warnings || resolved.warningsAsErrors, + warningsAsErrors: resolved.warningsAsErrors, + printCoverageDetails: resolved.coverageDetails, + slowThreshold: resolved.slowThreshold, + printBuildInfo: resolved.buildInfo, + printExecutables: resolved.executable, + discoverTestedTarget: resolved.coverage, + xcbeautify: resolved.xcbeautify + ) + var inputSource = POSIXInputSource(fileDescriptor: STDIN_FILENO) + var lineReader = StreamingLineReader() + let inputScan: InputScan + + do { + inputScan = try lineReader.consume(from: &inputSource) { line in + parser.feed(line) + } + } catch { + writeToStderr("Error: Failed to read standard input: \(error.localizedDescription)\n") + throw ExitCode.failure + } // Check if input is empty - if input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if !inputScan.containsNonWhitespace { throw ValidationError( "No input provided. Please pipe xcodebuild output to xcsift.\n\nExample: xcodebuild build | xcsift" ) @@ -279,7 +300,7 @@ struct XCSift: ParsableCommand { var coverageData: CodeCoverage? = nil if resolved.coverage { let path = resolved.coveragePath ?? "" - let targetFilter = parser.extractTestedTarget(from: input) + let targetFilter = parser.testedTarget coverageData = CoverageParser.parseCoverageFromPath(path, targetFilter: targetFilter) // Warn if target filter was extracted but no coverage data was found @@ -290,17 +311,7 @@ struct XCSift: ParsableCommand { } } - let result = parser.parse( - input: input, - printWarnings: resolved.warnings, - warningsAsErrors: resolved.warningsAsErrors, - coverage: coverageData, - printCoverageDetails: resolved.coverageDetails, - slowThreshold: resolved.slowThreshold, - printBuildInfo: resolved.buildInfo, - printExecutables: resolved.executable, - xcbeautify: resolved.xcbeautify - ) + let result = parser.finish(coverage: coverageData) outputResult(result, resolved: resolved) if result.status == "incomplete" { @@ -339,22 +350,6 @@ struct XCSift: ParsableCommand { } } - private func readStandardInput() -> String { - if #available(macOS 10.15.4, *) { - // Use modern API that properly handles EOF - do { - let data = try FileHandle.standardInput.readToEnd() ?? Data() - return String(data: data, encoding: .utf8) ?? "" - } catch { - return "" - } - } else { - // Fallback for older systems - let data = FileHandle.standardInput.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - } - private func outputResult(_ result: BuildResult, resolved: ResolvedConfig) { // In quiet mode, suppress output if build succeeded with no warnings or errors if resolved.quiet && result.status == "success" && result.summary.warnings == 0 { diff --git a/Tests/XCSiftCoreTests/LineParserTests.swift b/Tests/XCSiftCoreTests/LineParserTests.swift index f550c25..d2d3447 100644 --- a/Tests/XCSiftCoreTests/LineParserTests.swift +++ b/Tests/XCSiftCoreTests/LineParserTests.swift @@ -4,6 +4,12 @@ import XCTest final class LineParserTests: XCTestCase { + func testXcbeautifyInitializerFunctionReferenceRemainsSourceCompatible() { + let factory: (Bool) -> LineParser = LineParser.init(xcbeautify:) + + _ = factory(false) + } + // MARK: - Ignored line func testIgnoredLine() { @@ -11,6 +17,13 @@ final class LineParserTests: XCTestCase { XCTAssertEqual(parser.feed("note: some note message"), .ignored) } + func testLineLengthLimitUsesUTF8Bytes() { + var parser = LineParser() + let line = "main.swift:1:1: error: " + String(repeating: "é", count: 3_000) + + XCTAssertEqual(parser.feed(line), .ignored) + } + // MARK: - Error func testError() { @@ -37,6 +50,58 @@ final class LineParserTests: XCTestCase { XCTAssertEqual(warning.message, "unused variable 'x'") } + func testJSONLikeDiagnosticTextRemainsIgnored() { + let lines = [ + #"{"message":"Foo.swift:3:1: warning: not a diagnostic"}"#, + #" "message" : "Foo.swift:3:1: error: not a diagnostic""#, + #"payload=\"Foo.swift:3:1: warning: not a diagnostic\""#, + ] + + for line in lines { + var parser = LineParser() + XCTAssertEqual(parser.feed(line), .ignored, "Unexpected diagnostic for: \(line)") + } + } + + func testWarningLocationParsingPreservesColonAndMalformedFieldSemantics() { + let cases: [(String, String?, Int?, Int?, String)] = [ + ("Foo.swift:12: warning: line only", "Foo.swift", 12, nil, "line only"), + ("Foo.swift:12:3: warning: line and column", "Foo.swift", 12, 3, "line and column"), + (#"C:\work\Foo.swift:7:2: warning: Windows path"#, #"C:\work\Foo.swift"#, 7, 2, "Windows path"), + ("scheme:Foo.swift:9:4: warning: colon path", "scheme:Foo.swift", 9, 4, "colon path"), + ("a::2: warning: empty component", "a:", 2, nil, "empty component"), + ("Foo.swift:line:x: warning: malformed", "Foo.swift:line:x", nil, nil, "malformed"), + (":1:2: warning: empty file", "", 1, 2, "empty file"), + ("路径/Fóo.swift:8:1: warning: 注意", "路径/Fóo.swift", 8, 1, "注意"), + ] + + for (line, expectedFile, expectedLine, expectedColumn, expectedMessage) in cases { + var parser = LineParser() + let result = parser.feed(line) + guard case .consumed(let event) = result, case .warning(let warning) = event else { + XCTFail("Expected warning for: \(line)") + continue + } + XCTAssertEqual(warning.file, expectedFile, "Unexpected file for: \(line)") + XCTAssertEqual(warning.line, expectedLine, "Unexpected line for: \(line)") + XCTAssertEqual(warning.column, expectedColumn, "Unexpected column for: \(line)") + XCTAssertEqual(warning.message, expectedMessage, "Unexpected message for: \(line)") + } + } + + func testWarningMarkerRequiresExactASCIIBytes() { + let lines = [ + "Foo.swift:1:1: warning:no space", + "Foo.swift:1:1: Warning: wrong case", + "Foo.swift:1:1: warning:\u{301} combining mark before space", + ] + + for line in lines { + var parser = LineParser() + XCTAssertEqual(parser.feed(line), .ignored, "Unexpected warning for: \(line)") + } + } + // MARK: - Failed test func testFailedTest() { @@ -76,6 +141,15 @@ final class LineParserTests: XCTestCase { XCTAssertEqual(name, "-[MyModule.MyTests testBaz]") } + func testSwiftTestingRunLifecycleIsNotTrackedAsATest() { + for line in ["◇ Test run started.", "◇ Test run started.\r"] { + var parser = LineParser() + + XCTAssertEqual(parser.feed(line), .ignored) + XCTAssertTrue(parser.flush().isEmpty) + } + } + // MARK: - Linker: undefined symbol (3-line sequence) func testLinkerUndefinedSymbol() { @@ -122,6 +196,13 @@ final class LineParserTests: XCTestCase { XCTAssertEqual(result, .buffering) } + func testRecordedIssueCandidateRequiresACharacterBoundary() { + var parser = LineParser() + + XCTAssertEqual(parser.feed("✘ Test x recorded an issue\u{301}"), .ignored) + XCTAssertTrue(parser.flush().isEmpty) + } + // MARK: - Swift Testing look-ahead: comment appended func testSwiftTestingCommentAppended() { @@ -240,6 +321,32 @@ final class LineParserTests: XCTestCase { XCTAssertEqual(phase, "CompileSwiftSources") } + func testBuildPhaseParsingCanBeDisabled() { + var parser = LineParser(parseBuildInfo: false) + + let result = parser.feed( + "CompileSwiftSources /some/path (in target 'MyApp' from project 'MyProject')" + ) + + XCTAssertEqual(result, .ignored) + } + + func testDisablingBuildInfoDoesNotDiscardDiagnosticOnBuildPhaseLine() { + var parser = LineParser(parseBuildInfo: false) + + let result = parser.feed( + "CompileSwiftSources /tmp/Foo.swift:1:2: warning: diagnostic on phase line" + ) + + guard case .consumed(let event) = result, case .warning(let warning) = event else { + return XCTFail("Expected .consumed(.warning), got \(result)") + } + XCTAssertEqual(warning.file, "CompileSwiftSources /tmp/Foo.swift") + XCTAssertEqual(warning.line, 1) + XCTAssertEqual(warning.column, 2) + XCTAssertEqual(warning.message, "diagnostic on phase line") + } + // MARK: - Executable func testExecutable() { diff --git a/Tests/XCSiftCoreTests/LinkerErrorTests.swift b/Tests/XCSiftCoreTests/LinkerErrorTests.swift index 01885fc..ed1fb9c 100644 --- a/Tests/XCSiftCoreTests/LinkerErrorTests.swift +++ b/Tests/XCSiftCoreTests/LinkerErrorTests.swift @@ -195,6 +195,14 @@ final class LinkerErrorTests: XCTestCase { XCTAssertTrue(result.linkerErrors[0].message.contains("framework not found")) } + func testParseWhitespacePrefixedFrameworkNotFound() { + let parser = OutputParser() + let result = parser.parse(input: " ld: framework not found SomeFramework") + + XCTAssertEqual(result.linkerErrors.count, 1) + XCTAssertTrue(result.linkerErrors[0].message.contains("framework not found")) + } + // MARK: - Library Not Found func testParseLibraryNotFound() { diff --git a/Tests/XCSiftCoreTests/StreamingOutputParserTests.swift b/Tests/XCSiftCoreTests/StreamingOutputParserTests.swift new file mode 100644 index 0000000..11a76ee --- /dev/null +++ b/Tests/XCSiftCoreTests/StreamingOutputParserTests.swift @@ -0,0 +1,161 @@ +import XCTest + +@testable import XCSiftCore + +final class StreamingOutputParserTests: XCTestCase { + func testIncrementalFeedProducesBuildResultAtFinish() { + var parser = StreamingOutputParser(printWarnings: true) + + parser.feed("App.swift:12:5: warning: value 'name' was never used") + parser.feed("** BUILD SUCCEEDED **") + + let result = parser.finish() + + XCTAssertEqual(result.status, "success") + XCTAssertEqual(result.summary.warnings, 1) + XCTAssertEqual(result.warnings.count, 1) + XCTAssertEqual(result.warnings[0].file, "App.swift") + XCTAssertEqual(result.warnings[0].line, 12) + XCTAssertEqual(result.warnings[0].message, "value 'name' was never used") + } + + func testIncrementalFeedDiscoversTestedTargetForCoverage() { + var parser = StreamingOutputParser(discoverTestedTarget: true) + + parser.feed("Test Suite 'VideoGoTests.xctest' started at 2026-08-06 20:00:00.000.") + + XCTAssertEqual(parser.testedTarget, "VideoGo") + } + + func testCountOnlyWarningsPreservesExactSummaryWithoutRetainingDetails() { + var parser = StreamingOutputParser(retainWarnings: false) + let duplicate = "App.swift:4:2: warning: unused value" + + parser.feed(duplicate) + parser.feed(duplicate) + parser.feed("Other.swift:8:1: warning: deprecated API") + parser.feed("** BUILD SUCCEEDED **") + + let result = parser.finish() + + XCTAssertEqual(result.summary.warnings, 2) + XCTAssertTrue(result.warnings.isEmpty) + } + + func testCountOnlyWarningIdentityHandlesOptionalFieldsNULAndCanonicalUnicode() { + func warningCount(_ lines: [String]) -> Int { + var parser = StreamingOutputParser(retainWarnings: false) + for line in lines { parser.feed(line) } + return parser.finish().summary.warnings + } + + XCTAssertEqual( + warningCount(["warning: same", ": warning: same"]), + 2, + "nil and empty files must remain distinct" + ) + XCTAssertEqual( + warningCount([ + "File.swift: warning: same", + "File.swift:0: warning: same", + ]), + 2, + "nil and zero line numbers must remain distinct" + ) + XCTAssertEqual( + warningCount([ + "x:1: warning: u\0l2\0mv", + "x\0l1\0mu:2: warning: v", + ]), + 2, + "embedded NULs must not collide with key separators" + ) + XCTAssertEqual( + warningCount([ + "é.swift:1: warning: same", + "e\u{301}.swift:1: warning: same", + ]), + 1, + "canonically equivalent Swift strings must deduplicate" + ) + } + + func testOmittingBuildInfoKeepsDiagnosticOnBuildPhaseLine() { + var parser = StreamingOutputParser(printWarnings: true, printBuildInfo: false) + + parser.feed( + "CompileSwiftSources /tmp/Foo.swift:1:2: warning: diagnostic on phase line " + + "(in target 'MyApp' from project 'MyProject')" + ) + parser.feed("** BUILD SUCCEEDED **") + + let result = parser.finish() + + XCTAssertEqual(result.status, "success") + XCTAssertEqual(result.summary.warnings, 1) + XCTAssertEqual( + result.warnings.first?.message, + "diagnostic on phase line (in target 'MyApp' from project 'MyProject')" + ) + XCTAssertNil(result.buildInfo) + } + + func testFinishDrainsBufferedRecordedIssueAtEOF() { + var parser = StreamingOutputParser() + parser.feed( + "✘ Test \"rendersCard()\" recorded an issue at CardTests.swift:42:1: Expectation failed" + ) + + let result = parser.finish() + + XCTAssertEqual(result.status, "failed") + XCTAssertEqual(result.summary.failedTests, 1) + XCTAssertEqual(result.failedTests.first?.test, "rendersCard()") + XCTAssertEqual(result.failedTests.first?.file, "CardTests.swift") + XCTAssertEqual(result.failedTests.first?.line, 42) + } + + func testFinishDrainsQueuedCrashEventAtEOF() { + var parser = StreamingOutputParser() + parser.feed("Test Case '-[CardTests testCrash]' started.") + parser.feed("Card.swift:9:1: Fatal error: unexpected nil") + + let result = parser.finish() + + XCTAssertEqual(result.summary.errors, 1) + XCTAssertEqual(result.summary.failedTests, 1) + XCTAssertEqual(result.failedTests.first?.test, "-[CardTests testCrash]") + } + + func testTargetDiscoveryIsDisabledByDefault() { + var parser = StreamingOutputParser() + parser.feed("Test Suite 'VideoGoTests.xctest' started at 2026-08-06 20:00:00.000.") + + XCTAssertNil(parser.testedTarget) + } + + func testFinishIsIdempotent() throws { + var parser = StreamingOutputParser(warningsAsErrors: true) + parser.feed("App.swift:4:2: warning: unused value") + parser.feed("** BUILD SUCCEEDED **") + + let first = parser.finish() + let second = parser.finish() + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + XCTAssertEqual(try encoder.encode(first), try encoder.encode(second)) + } + + func testCompleteInputParserDoesNotLeakStateAcrossCalls() { + let parser = OutputParser() + + let failed = parser.parse(input: "First.swift:1:1: error: broken\n** BUILD FAILED **") + let succeeded = parser.parse(input: "** BUILD SUCCEEDED **") + + XCTAssertEqual(failed.status, "failed") + XCTAssertEqual(failed.summary.errors, 1) + XCTAssertEqual(succeeded.status, "success") + XCTAssertEqual(succeeded.summary.errors, 0) + } +} diff --git a/Tests/XCSiftCoreTests/XcbeautifyTests.swift b/Tests/XCSiftCoreTests/XcbeautifyTests.swift index 0c9dae7..ef77136 100644 --- a/Tests/XCSiftCoreTests/XcbeautifyTests.swift +++ b/Tests/XCSiftCoreTests/XcbeautifyTests.swift @@ -286,6 +286,19 @@ final class XcbeautifyAutoDetectTests: XCTestCase { XCTAssertTrue(parser.didEmitXcbeautifyHint) } + + func testAutoDetectHintRequiresAnExactMarkerAtTheStart() { + let parser = OutputParser() + let input = """ + [x] leading whitespace + [xy] near miss + ⚡️ emoji near miss + """ + + _ = parser.parse(input: input, xcbeautify: false) + + XCTAssertFalse(parser.didEmitXcbeautifyHint) + } } // MARK: - xcbeautify Diagnostic Parsing Tests diff --git a/Tests/xcsiftTests/StreamingLineReaderTests.swift b/Tests/xcsiftTests/StreamingLineReaderTests.swift new file mode 100644 index 0000000..e92c03f --- /dev/null +++ b/Tests/xcsiftTests/StreamingLineReaderTests.swift @@ -0,0 +1,171 @@ +import Foundation +import XCTest + +@testable import xcsift + +final class StreamingLineReaderTests: XCTestCase { + func testEmitsCompleteLinesBeforeReadingTheNextChunk() throws { + let log = EventLog() + var source = ChunkSource( + chunks: [ + Data("App.swift:4:2: war".utf8), + Data("ning: unused value\n** BUILD SUC".utf8), + Data("CEEDED **".utf8), + ], + log: log + ) + var lines: [String] = [] + var reader = StreamingLineReader(chunkSize: 64) + + let scan = try reader.consume(from: &source) { line in + log.entries.append("line:\(line)") + lines.append(line) + } + + XCTAssertEqual( + lines, + [ + "App.swift:4:2: warning: unused value", + "** BUILD SUCCEEDED **", + ] + ) + XCTAssertEqual( + log.entries, + [ + "read:0", + "read:1", + "line:App.swift:4:2: warning: unused value", + "read:2", + "read:3", + "line:** BUILD SUCCEEDED **", + ] + ) + XCTAssertTrue(scan.containsNonWhitespace) + } + + func testPOSIXSourceReadsPipedInput() throws { + let pipe = Pipe() + pipe.fileHandleForWriting.write(Data("first line\nsecond line".utf8)) + try pipe.fileHandleForWriting.close() + var source = POSIXInputSource( + fileDescriptor: pipe.fileHandleForReading.fileDescriptor + ) + var reader = StreamingLineReader(chunkSize: 4) + var lines: [String] = [] + + let scan = try reader.consume(from: &source) { lines.append($0) } + + XCTAssertEqual(lines, ["first line", "second line"]) + XCTAssertTrue(scan.containsNonWhitespace) + } + + func testOversizedLineIsDiscardedBeforeItsBytesAccumulate() throws { + var source = ChunkSource( + chunks: [ + Data(repeating: 0x41, count: 100), + Data("\n** BUILD SUCCEEDED **".utf8), + ], + log: EventLog() + ) + var reader = StreamingLineReader(chunkSize: 128, maximumLineBytes: 32) + var lines: [String] = [] + + let scan = try reader.consume(from: &source) { lines.append($0) } + + XCTAssertEqual(lines, ["", "** BUILD SUCCEEDED **"]) + XCTAssertEqual(scan.oversizedLinesDropped, 1) + XCTAssertLessThanOrEqual(scan.maximumBufferedBytes, 32) + XCTAssertTrue(scan.containsNonWhitespace) + } + + func testPreservesUTF8ScalarsSplitAcrossSingleByteChunks() throws { + let input = "警告🙂\n" + var source = ChunkSource( + chunks: input.utf8.map { Data([$0]) }, + log: EventLog() + ) + var reader = StreamingLineReader(chunkSize: 1) + var lines: [String] = [] + + let scan = try reader.consume(from: &source) { lines.append($0) } + + XCTAssertEqual(lines, ["警告🙂", ""]) + XCTAssertTrue(scan.containsNonWhitespace) + } + + func testPreservesEmptyLinesAndUnterminatedFinalLine() throws { + var source = ChunkSource( + chunks: [Data("\n\nlast line".utf8)], + log: EventLog() + ) + var reader = StreamingLineReader() + var lines: [String] = [] + + _ = try reader.consume(from: &source) { lines.append($0) } + + XCTAssertEqual(lines, ["", "", "last line"]) + } + + func testPreservesCarriageReturnsFromCRLFInput() throws { + var source = ChunkSource( + chunks: [Data("first\r\nsecond\r\n".utf8)], + log: EventLog() + ) + var reader = StreamingLineReader() + var lines: [String] = [] + + _ = try reader.consume(from: &source) { lines.append($0) } + + XCTAssertEqual(lines, ["first\r", "second\r", ""]) + } + + func testSingleChunkLinesPreserveBufferAndOversizeSemantics() throws { + var source = ChunkSource( + chunks: [Data("1234\n\n12345\né\n".utf8)], + log: EventLog() + ) + var reader = StreamingLineReader(chunkSize: 64, maximumLineBytes: 4) + var lines: [String] = [] + + let scan = try reader.consume(from: &source) { lines.append($0) } + + XCTAssertEqual(lines, ["1234", "", "", "é", ""]) + XCTAssertEqual(scan.oversizedLinesDropped, 1) + XCTAssertEqual(scan.maximumBufferedBytes, 4) + XCTAssertTrue(scan.containsNonWhitespace) + } + + func testUnicodeWhitespaceDoesNotCountAsInputContent() throws { + var source = ChunkSource( + chunks: [Data("\u{2003}\n\t".utf8)], + log: EventLog() + ) + var reader = StreamingLineReader() + + let scan = try reader.consume(from: &source) { _ in } + + XCTAssertFalse(scan.containsNonWhitespace) + } +} + +private final class EventLog { + var entries: [String] = [] +} + +private struct ChunkSource: InputChunkSource { + let chunks: [Data] + let log: EventLog + private var index = 0 + + init(chunks: [Data], log: EventLog) { + self.chunks = chunks + self.log = log + } + + mutating func read(upToCount _: Int) throws -> Data? { + log.entries.append("read:\(index)") + defer { index += 1 } + guard index < chunks.count else { return nil } + return chunks[index] + } +}