diff --git a/Sources/XCLogParser/parser/BuildStep.swift b/Sources/XCLogParser/parser/BuildStep.swift index 4c082f52..d97a1b17 100644 --- a/Sources/XCLogParser/parser/BuildStep.swift +++ b/Sources/XCLogParser/parser/BuildStep.swift @@ -98,6 +98,11 @@ public enum DetailStepType: String, Encodable { return .cCompilation case Prefix("CompileSwift "): return .swiftCompilation + // The same task under the name the integrated driver uses, for a single file and for a batch + // of them. Both spellings show up in one log: targets that compile whole module keep + // emitting `CompileSwift`. + case Prefix("SwiftCompile "): + return .swiftCompilation case Prefix("Ld "): return .linker case Prefix("PhaseScriptExecution "): @@ -122,6 +127,10 @@ public enum DetailStepType: String, Encodable { return .XIBCompilation case Prefix("CompileSwiftSources "): return .swiftAggregatedCompilation + // The driver job that replaced `CompileSwiftSources`. The trailing space matters: without it + // the prefix also swallows `SwiftDriverJobDiscovery`, which is not a compilation step. + case Prefix("SwiftDriver "): + return .swiftAggregatedCompilation case Prefix("PrecompileSwiftBridgingHeader "): return .precompileBridgingHeader case Prefix("ValidateEmbeddedBinary "): diff --git a/Sources/XCLogParser/parser/IDEActivityLogSection+Parsing.swift b/Sources/XCLogParser/parser/IDEActivityLogSection+Parsing.swift index 189a1eb7..328241f4 100644 --- a/Sources/XCLogParser/parser/IDEActivityLogSection+Parsing.swift +++ b/Sources/XCLogParser/parser/IDEActivityLogSection+Parsing.swift @@ -86,7 +86,10 @@ extension IDEActivityLogSection { public func getSwiftIndividualSteps(buildStep: BuildStep, parentCommandDetailDesc: String, currentIndex: inout Int) -> [BuildStep]? { - let pattern = #"^CompileSwift\s\w+\s\w+\s.+\.swift\s"# + // Both spellings of the task, which appear side by side in a single log. Without the + // integrated driver's one the guard stops recognizing a command that already names the + // files, and the steps synthesized below duplicate the subSections the log provides. + let pattern = #"^(?:CompileSwift|SwiftCompile)\s\w+\s\w+\s.+\.swift\s"# guard commandDetailDesc.range(of: pattern, options: .regularExpression) == nil else { return nil } diff --git a/Sources/XCLogParser/parser/SwiftCompilerParser.swift b/Sources/XCLogParser/parser/SwiftCompilerParser.swift index b74e6b05..606dad2f 100644 --- a/Sources/XCLogParser/parser/SwiftCompilerParser.swift +++ b/Sources/XCLogParser/parser/SwiftCompilerParser.swift @@ -52,8 +52,11 @@ public class SwiftCompilerParser { DispatchQueue.concurrentPerform(iterations: commands.count) { index in let (rawFunctionTimes, commandDesc) = commands[index] + // Xcode 26 and later put the flags on the SwiftDriver section, not on the + // SwiftCompile section that holds the output, so also accept output that looks like times let hasCompilerFlag = functionTimes.hasCompilerFlag(commandDesc: commandDesc) || typeCheckTimes.hasCompilerFlag(commandDesc: commandDesc) + || rawFunctionTimes.contains("ms\t") guard hasCompilerFlag && rawFunctionTimes.isEmpty == false else { return diff --git a/Sources/XCLogParser/reporter/HtmlReporter.swift b/Sources/XCLogParser/reporter/HtmlReporter.swift index c3b59250..3a7ffd9c 100644 --- a/Sources/XCLogParser/reporter/HtmlReporter.swift +++ b/Sources/XCLogParser/reporter/HtmlReporter.swift @@ -224,11 +224,14 @@ public struct HtmlReporter: LogReporter { swiftTypeCheckTimes.append(contentsOf: typeChecks) } else { swiftSteps.append(step) - if let functions = step.swiftFunctionTimes { - swiftFunctionTimes.append(contentsOf: functions) - } - if let typeChecks = step.swiftTypeCheckTimes { - swiftTypeCheckTimes.append(contentsOf: typeChecks) + // from Xcode 14 on the step is a batch and the times are on its per file substeps + for candidate in [step] + step.subSteps { + if let functions = candidate.swiftFunctionTimes { + swiftFunctionTimes.append(contentsOf: functions) + } + if let typeChecks = candidate.swiftTypeCheckTimes { + swiftTypeCheckTimes.append(contentsOf: typeChecks) + } } } } diff --git a/Tests/XCLogParserTests/DetailStepTypeTests.swift b/Tests/XCLogParserTests/DetailStepTypeTests.swift new file mode 100644 index 00000000..6f01cb1d --- /dev/null +++ b/Tests/XCLogParserTests/DetailStepTypeTests.swift @@ -0,0 +1,70 @@ +// Copyright (c) 2019 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import XCTest +@testable import XCLogParser + +/// Signatures in this file are taken from an Xcode 26.5 build log, with paths and module names +/// replaced. A single log carries both spellings of the Swift compilation tasks, so both have to +/// be recognized. +class DetailStepTypeTests: XCTestCase { + + func testSwiftCompileOfASingleFileIsASwiftCompilation() { + let signature = "SwiftCompile normal arm64 /path/to/Foo/Bar.swift " + + "(in target 'Foo' from project 'Foo')" + + XCTAssertEqual(.swiftCompilation, DetailStepType.getDetailType(signature: signature)) + } + + func testSwiftCompileOfABatchIsASwiftCompilation() { + let signature = "SwiftCompile normal arm64 Compiling\\ Bar.swift,\\ Baz.swift " + + "(in target 'Foo' from project 'Foo')" + + XCTAssertEqual(.swiftCompilation, DetailStepType.getDetailType(signature: signature)) + } + + func testCompileSwiftIsStillASwiftCompilation() { + let signature = "CompileSwift normal arm64 /path/to/Foo/Bar.swift" + + XCTAssertEqual(.swiftCompilation, DetailStepType.getDetailType(signature: signature)) + } + + func testSwiftDriverIsAnAggregatedCompilation() { + let signature = "SwiftDriver Foo normal arm64 com.apple.xcode.tools.swift.compiler " + + "(in target 'Foo' from project 'Foo')" + + XCTAssertEqual(.swiftAggregatedCompilation, DetailStepType.getDetailType(signature: signature)) + } + + func testCompileSwiftSourcesIsStillAnAggregatedCompilation() { + let signature = "CompileSwiftSources normal arm64 com.apple.xcode.tools.swift.compiler" + + XCTAssertEqual(.swiftAggregatedCompilation, DetailStepType.getDetailType(signature: signature)) + } + + /// `SwiftDriverJobDiscovery` shares its first eleven characters with `SwiftDriver`, and it is + /// not a compilation step: a prefix without the trailing space would swallow it. + func testSwiftDriverJobDiscoveryIsNotAnAggregatedCompilation() { + let signature = "SwiftDriverJobDiscovery normal arm64 Emitting module for Foo " + + "(in target 'Foo' from project 'Foo')" + + XCTAssertEqual(.other, DetailStepType.getDetailType(signature: signature)) + } + +} diff --git a/Tests/XCLogParserTests/HtmlReporterSwiftTimesTests.swift b/Tests/XCLogParserTests/HtmlReporterSwiftTimesTests.swift new file mode 100644 index 00000000..c197384b --- /dev/null +++ b/Tests/XCLogParserTests/HtmlReporterSwiftTimesTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import XCLogParser + +class HtmlReporterSwiftTimesTests: XCTestCase { + + /// From Xcode 14 on a SwiftCompile step is a batch and the per file times are on its substeps. + func testFunctionTimesOfBatchSubstepsReachTheIndexPage() throws { + var file = makeFakeBuildStep(title: "Compile MyView.swift", type: .detail, + detailStepType: .swiftCompilation, + startTimestamp: 0, fetchedFromCache: false) + file.swiftFunctionTimes = [SwiftFunctionTime(file: "file:///myapp/MyView.swift", durationMS: 4.96, + startingLine: 11, startingColumn: 14, + signature: "initializer init(frame:)", occurrences: 1)] + let batch = makeFakeBuildStep(title: "Compiling MyView.swift, Other.swift", type: .detail, + detailStepType: .swiftCompilation, + startTimestamp: 0, fetchedFromCache: false) + .with(subSteps: [file]) + let target = makeFakeBuildStep(title: "Build target Foo", type: .target, detailStepType: .none, + startTimestamp: 0, fetchedFromCache: false) + .with(subSteps: [batch]) + let build = makeFakeBuildStep(title: "Build", type: .main, detailStepType: .none, + startTimestamp: 0, fetchedFromCache: false) + .with(subSteps: [target]) + let dir = try TestUtils.createRandomTestDir() + + try HtmlReporter().report(build: build, output: FileOutput(path: dir.path), rootOutput: dir.path) + + let topFiles = try String(contentsOf: dir.appendingPathComponent("js/top_files.js")) + let topFunctions = topFiles.components(separatedBy: "const topSwiftFunctions = ")[1] + .components(separatedBy: "const ")[0] + XCTAssertTrue(topFunctions.contains("initializer init(frame:)")) + } +} diff --git a/Tests/XCLogParserTests/SwiftCompilerParserTests.swift b/Tests/XCLogParserTests/SwiftCompilerParserTests.swift index 686a1170..55c9240f 100644 --- a/Tests/XCLogParserTests/SwiftCompilerParserTests.swift +++ b/Tests/XCLogParserTests/SwiftCompilerParserTests.swift @@ -67,6 +67,19 @@ class SwiftCompilerParserTests: XCTestCase { XCTAssertEqual(2, initializer.occurrences, file: file, line: line) } + /// Xcode 26 and later keep the swift-frontend flags on the SwiftDriver section, so the + /// SwiftCompile section that holds the timings never names the flag in its command. + func testParseSwiftFunctionTimesWithoutTheFlagInTheCommand() { + let section = getFakeSwiftcSection( + text: "4.96ms\t/Users/user/myapp/MyView.swift:11:14\tinitializer init(frame:)\r", + commandDescription: "SwiftCompile normal arm64 Compiling\\ MyView.swift /Users/user/myapp/MyView.swift") + parser.addLogSection(section) + + parser.parse() + + XCTAssertEqual(1, parser.findFunctionTimesForFilePath("file:///Users/user/myapp/MyView.swift")?.count) + } + func testParseSwiftTypeCheckTimes() throws { try runTestParseSwiftTypeCheckTimes(rawFile: "project/CreatorHeaderViewModel", escapedFile: "project/CreatorHeaderViewModel") diff --git a/Tests/XCLogParserTests/SwiftIndividualStepsTests.swift b/Tests/XCLogParserTests/SwiftIndividualStepsTests.swift new file mode 100644 index 00000000..42a56a31 --- /dev/null +++ b/Tests/XCLogParserTests/SwiftIndividualStepsTests.swift @@ -0,0 +1,87 @@ +// Copyright (c) 2019 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import XCTest +@testable import XCLogParser + +class SwiftIndividualStepsTests: XCTestCase { + + /// From Xcode 14 on, a batch compilation names its files in the command and the log carries a + /// subSection per file. Synthesizing steps here would report every file twice. + func testNoStepsAreSynthesizedWhenTheCommandNamesTheFiles() { + let command = "SwiftCompile normal arm64 Compiling\\ Bar.swift,\\ Baz.swift " + + "/path/to/Foo/Bar.swift /path/to/Foo/Baz.swift (in target 'Foo' from project 'Foo')" + let section = makeSection(commandDetailDesc: command) + var index = 0 + + let steps = section.getSwiftIndividualSteps(buildStep: makeStep(), + parentCommandDetailDesc: "", + currentIndex: &index) + + XCTAssertNil(steps) + } + + /// A whole module command lists the files without compiling them as separate sections, so the + /// per-file steps have to be derived from the command itself. + func testStepsAreSynthesizedForAWholeModuleCommand() { + let command = "/usr/bin/swiftc -module-name Foo -whole-module-optimization " + + "/path/to/Foo/Bar.swift /path/to/Foo/Baz.swift" + let section = makeSection(commandDetailDesc: command) + var index = 0 + + let steps = section.getSwiftIndividualSteps(buildStep: makeStep(), + parentCommandDetailDesc: "", + currentIndex: &index) + + XCTAssertEqual(["Compile /path/to/Foo/Bar.swift", "Compile /path/to/Foo/Baz.swift"], + steps?.map { $0.title }) + } + + private func makeStep() -> BuildStep { + makeFakeBuildStep(title: "Compiling Bar.swift, Baz.swift", + type: .detail, + detailStepType: .swiftCompilation, + startTimestamp: 0, + fetchedFromCache: false) + } + + private func makeSection(commandDetailDesc: String) -> IDEActivityLogSection { + IDEActivityLogSection(sectionType: 1, + domainType: "", + title: "Compiling Bar.swift, Baz.swift", + signature: "SwiftCompile normal arm64", + timeStartedRecording: 0, + timeStoppedRecording: 0, + subSections: [], + text: "", + messages: [], + wasCancelled: false, + isQuiet: false, + wasFetchedFromCache: false, + subtitle: "", + location: DVTDocumentLocation(documentURLString: "", timestamp: 0), + commandDetailDesc: commandDetailDesc, + uniqueIdentifier: "", + localizedResultString: "", + xcbuildSignature: "", + attachments: [], + unknown: 0) + } + +} diff --git a/Tests/XCLogParserTests/XCTestManifests.swift b/Tests/XCLogParserTests/XCTestManifests.swift index e18faedd..b60a74f3 100644 --- a/Tests/XCLogParserTests/XCTestManifests.swift +++ b/Tests/XCLogParserTests/XCTestManifests.swift @@ -28,6 +28,15 @@ extension ChromeTracerOutputTests { ] } +extension HtmlReporterSwiftTimesTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__HtmlReporterSwiftTimesTests = [ + ("testFunctionTimesOfBatchSubstepsReachTheIndexPage", testFunctionTimesOfBatchSubstepsReachTheIndexPage), + ] +} + extension IssuesReporterTests { // DO NOT MODIFY: This is autogenerated, use: // `swift test --generate-linuxmain` @@ -138,6 +147,7 @@ extension SwiftCompilerParserTests { // to regenerate. static let __allTests__SwiftCompilerParserTests = [ ("testParseSwiftFunctionTimes", testParseSwiftFunctionTimes), + ("testParseSwiftFunctionTimesWithoutTheFlagInTheCommand", testParseSwiftFunctionTimesWithoutTheFlagInTheCommand), ("testParseSwiftTypeCheckTimes", testParseSwiftTypeCheckTimes), ] } @@ -146,6 +156,7 @@ public func __allTests() -> [XCTestCaseEntry] { return [ testCase(ActivityParserTests.__allTests__ActivityParserTests), testCase(ChromeTracerOutputTests.__allTests__ChromeTracerOutputTests), + testCase(HtmlReporterSwiftTimesTests.__allTests__HtmlReporterSwiftTimesTests), testCase(IssuesReporterTests.__allTests__IssuesReporterTests), testCase(LexRedactorTests.__allTests__LexRedactorTests), testCase(LexerTests.__allTests__LexerTests),