├── Makefile ├── LICENSE ├── Sources ├── SwiftCompilationTimingParserFramework │ ├── ParsedTiming.swift │ └── SwiftCompilationTimingParser.swift └── SwiftCompilationTimingParser │ └── SwiftCompilationTimingParser.swift ├── Package.resolved ├── Package.swift ├── README.md └── Tests └── SwiftCompilationTimingParserTests └── SwiftCompilationTimingParserTests.swift /Makefile: -------------------------------------------------------------------------------- 1 | CURRENT_DIR := $(shell pwd) 2 | .PHONY: list tests build build_and_run 3 | 4 | list: 5 | @echo "\n===========================" 6 | @echo "== Swift Scripting ==" 7 | @echo "===========================" 8 | @echo "== Commands list ==" 9 | @echo "===========================" 10 | @echo "build" 11 | @echo "build_and_run" 12 | @echo "tests" 13 | 14 | %: 15 | @: 16 | 17 | args = `arg="$(filter-out $@,$(MAKECMDGOALS))" && echo $${arg:-${1}}` 18 | 19 | 20 | tests: 21 | @echo "Starting tests..." 22 | @swift test 23 | 24 | build: 25 | @swift build --configuration release 26 | @cp -f .build/release/SwiftCompilationTimingParser ./SwiftCompilationTimingParser 27 | 28 | build_and_run: 29 | @make build 30 | @./SwiftCompilationTimingParser $(args) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Qonto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/SwiftCompilationTimingParserFramework/ParsedTiming.swift: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2023 Qonto 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | import Foundation 26 | 27 | open class ParsedTiming: Codable { 28 | public let ms: Float 29 | public let location: String 30 | public let symbol: String 31 | 32 | public init(ms: Float, location: String, symbol: String) { 33 | self.ms = ms 34 | self.location = location 35 | self.symbol = symbol 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "cryptoswift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 7 | "state" : { 8 | "revision" : "e2bc81be54d71d566a52ca17c3983d141c30aa70", 9 | "version" : "1.3.3" 10 | } 11 | }, 12 | { 13 | "identity" : "gzipswift", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/1024jp/GzipSwift", 16 | "state" : { 17 | "revision" : "7a7f17761c76a932662ab77028a4329f67d645a4", 18 | "version" : "5.2.0" 19 | } 20 | }, 21 | { 22 | "identity" : "pathkit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/kylef/PathKit", 25 | "state" : { 26 | "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", 27 | "version" : "1.0.1" 28 | } 29 | }, 30 | { 31 | "identity" : "spectre", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/kylef/Spectre.git", 34 | "state" : { 35 | "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", 36 | "version" : "0.10.1" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-argument-parser", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-argument-parser", 43 | "state" : { 44 | "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", 45 | "version" : "1.2.3" 46 | } 47 | }, 48 | { 49 | "identity" : "xclogparser", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/MobileNativeFoundation/XCLogParser", 52 | "state" : { 53 | "revision" : "1abc5b96080da8f678b77d11c0a93cdcb614642b", 54 | "version" : "0.2.36" 55 | } 56 | } 57 | ], 58 | "version" : 2 59 | } 60 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | import PackageDescription 4 | let package = Package( 5 | name: "SwiftCompilationTimingParser", 6 | platforms: [ 7 | .macOS(.v13) 8 | ], 9 | products: [ 10 | .executable(name: "SwiftCompilationTimingParser", targets: ["SwiftCompilationTimingParser"]), 11 | .library(name: "SwiftCompilationTimingParserFramework", targets: ["SwiftCompilationTimingParserFramework"]) 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "1.0.0")), 15 | .package(url: "https://github.com/MobileNativeFoundation/XCLogParser", exact: "0.2.39"), 16 | ], 17 | targets: [ 18 | .executableTarget( 19 | name: "SwiftCompilationTimingParser", 20 | dependencies: [ 21 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 22 | .product(name: "XCLogParser", package: "XCLogParser"), 23 | "SwiftCompilationTimingParserFramework" 24 | ], 25 | path: "Sources/SwiftCompilationTimingParser", 26 | swiftSettings: [.define("DEBUG", .when(configuration: .debug))]), 27 | .target( 28 | name: "SwiftCompilationTimingParserFramework", 29 | dependencies: [ 30 | .product(name: "XCLogParser", package: "XCLogParser"), 31 | ], 32 | path: "Sources/SwiftCompilationTimingParserFramework" 33 | ), 34 | .testTarget( 35 | name: "SwiftCompilationTimingParserTests", 36 | dependencies: [ 37 | "SwiftCompilationTimingParser", 38 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 39 | .product(name: "XCLogParser", package: "XCLogParser"), 40 | ] 41 | ), 42 | ] 43 | ) 44 | 45 | for target in package.targets { 46 | target.swiftSettings = target.swiftSettings ?? [] 47 | target.swiftSettings?.append( 48 | .unsafeFlags([ 49 | "-enable-bare-slash-regex", 50 | ]) 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /Sources/SwiftCompilationTimingParser/SwiftCompilationTimingParser.swift: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2023 Qonto 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | import ArgumentParser 26 | import Foundation 27 | import SwiftCompilationTimingParserFramework 28 | 29 | @main 30 | struct SwiftCompilationTimingParser: AsyncParsableCommand { 31 | enum CodingKeys: CodingKey { 32 | case rootPath 33 | case derivedDataPath 34 | case targetName 35 | case threshold 36 | case shouldIncludeInvalidLoc 37 | case filteredPath 38 | case outputPath 39 | case xcactivityLogOutputPath 40 | case xcodebuildLogPath 41 | case shouldEnableGrouping 42 | case prettyOutputFormating 43 | } 44 | 45 | @Option(name: .customLong("root-path"), help: "Absolute path to the project's root folder. Used for filtering purpose to cut out the username and other non-relevant paths when logs are parsed") 46 | var rootPath: String 47 | 48 | @Option(name: .customLong("derived-data-path"), help: "Absolute path to DerivedData, where xcactivitylog will be searched") 49 | var derivedDataPath: String? 50 | 51 | @Option(name: .customLong("xcodebuild-log-path"), help: "Absolute path to a file with xcodebuild log output. If specified, `--derived-data-path` argument is ignored") 52 | var xcodebuildLogPath: String? 53 | 54 | @Option(name: .customLong("filtered-path"), help: "(Optional) Path which should be included during log filtering. If specified, this path is the only path that will appear in the results, other results will be omitted") 55 | var filteredPath: String? 56 | 57 | @Option(name: .customLong("target-name"), help: "Name of the target to be used for finding logs") 58 | var targetName: String 59 | 60 | @Option(name: .customLong("threshold"), help: "Threshold to be used for filtering symbol compilation duration") 61 | var threshold: Float 62 | 63 | @Option(name: .customLong("output-path"), help: "Path where the generated contents of parsed compilation duration will be stored") 64 | var outputPath: String 65 | 66 | @Option(name: .customLong("xcactivitylog-output-path"), help: "Path where the parsed xcactivitylog' JSON will be stored") 67 | var xcactivityLogOutputPath: String? 68 | 69 | @Flag(name: .customLong("include-invalid-loc"), help: "(Optional) If specified, symbols without a specific location () will be included in the results") 70 | var shouldIncludeInvalidLoc = false 71 | 72 | @Flag(name: .customLong("enable-grouping"), help: "(Optional) If specified, symbols are grouped by location and symbol before being written to `--output-path`") 73 | var shouldEnableGrouping = false 74 | 75 | @Flag(name: .customLong("pretty-output"), help: "(Optional) If specified, the output JSON will be formatted with indentation and line breaks for improved readability") 76 | var prettyOutputFormating = false 77 | 78 | func run() async throws { 79 | let configuration = SwiftCompilationTimingParserFramework.SwiftCompilationTimingParser.Configuration(xcactivityLogOutputPath: xcactivityLogOutputPath, outputPath: outputPath, threshold: threshold, targetName: targetName, filteredPath: filteredPath, xcodebuildLogPath: xcodebuildLogPath, derivedDataPath: derivedDataPath, rootPath: rootPath, prettyOutputFormating: prettyOutputFormating) 80 | try await SwiftCompilationTimingParserFramework.SwiftCompilationTimingParser(configuration: configuration).run() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftCompilationTimingParser 2 | 3 | Whenever there is a need to automate swift compilation duration reporting, there are many ways of doing that. However, it is unclear how to achieve the best format for easier navigation in the generated reports. 4 | 5 | `SwiftCompilationTimingParser` makes it easy to parse `xcactivitylog` logs or raw `xcodebuild` output, gathering timing information reported by Swift Frontend, and outputing it as a JSON file with a straightforward format. 6 | 7 | This project is able to do the following: 8 | 9 | 1. Parse given `xcactivitylog` (using `--derived-data-path`) or `xcodebuild` raw output log 10 | 2. Filter compilation timing in the provided log file based on the given `--threshold` and `--filtered-path` (optional). 11 | 3. Generate `JSON` file with the interesting slow compiling symbols 12 | 13 | ## The following `xcodebuild` log is expected 14 | 15 | To generate the expected `xcodebuild` log (for `--xcodebuild-log-path` flag), the following `xcodebuild` invocation must be made: 16 | 17 | ```bash 18 | xcodebuild -workspace Project.xcworkspace \ 19 | -scheme SchemaName \ 20 | -sdk iphonesimulator \ 21 | ONLY_ACTIVE_ARCH=YES \ 22 | OTHER_SWIFT_FLAGS="-Xfrontend -debug-time-expression-type-checking \ 23 | -Xfrontend -debug-time-function-bodies" \ 24 | -destination 'platform=iOS Simulator,id=insert_your_simulator_id_here' clean build clean \ 25 | | tee xcodebuild.log 26 | ``` 27 | 28 | Then, specify the path to `xcodebuild.log` file generated after this command finishes to `SwiftCompilationTimingParser`'s `--xcodebuild-log-path`. 29 | 30 | > Note: running `xcodebuild ... clean build clean` is mandatory to achieve consistent results, but `build` can be also replaced by `test` in order to include test files into the generated report. Related issue: https://github.com/MobileNativeFoundation/XCLogParser/issues/139 31 | 32 | ## The following `xcactivitylog` is expected 33 | 34 | In case `--derived-data-path` is specified instead of `--xcodebuild-log-path`, the following flags need to be set in project's `OTHER_SWIFT_FLAGS`: 35 | 36 | ```bash 37 | -Xfrontend -debug-time-expression-type-checking 38 | -Xfrontend -debug-time-function-bodies 39 | ``` 40 | 41 | ## The following arguments are accepted 42 | 43 | ```bash 44 | --root-path "path_to_the_root_of_the_project_whose_log_is_parsed" 45 | --enable-grouping 46 | --target-name "target_name_if_xcactivitylog_is_used" 47 | --output-path "path_to_json_file_which_would_contain_parsed_symbols" 48 | --xcodebuild-log-path "path_to_xcodebuild_raw_log_output" 49 | --threshold 0.4 50 | ``` 51 | 52 | ## Generated JSON format 53 | 54 | ```json 55 | [ 56 | { 57 | "symbol" : "instance method increase(_:count:file:line:)", 58 | "ms" : 2.119999885559082, 59 | "location" : "/MyProject/Counter.swift:192:17" 60 | }, 61 | { 62 | "symbol" : "instance method perform(_:)", 63 | "ms" : 0.55000001192092896, 64 | "location" : "/MyProject/Counter.swift:115:17" 65 | }, 66 | ... 67 | ] 68 | ``` 69 | 70 | ## Usage 71 | 72 | ```bash 73 | USAGE: swift-compilation-timing-parser --root-path [--derived-data-path ] [--xcodebuild-log-path ] [--filtered-path ] --target-name --threshold --output-path [--xcactivitylog-output-path ] [--include-invalid-loc] [--enable-grouping] 74 | 75 | OPTIONS: 76 | --root-path Absolute path to the project's root folder. Used for 77 | filtering purpose to cut out the username and other 78 | non-relevant paths when logs are parsed 79 | --derived-data-path 80 | Absolute path to DerivedData, where xcactivitylog 81 | will be searched 82 | --xcodebuild-log-path 83 | Absolute path to a file with xcodebuild log output. 84 | If specified, `--derived-data-path` argument is 85 | ignored 86 | --filtered-path 87 | (Optional) Path which should be included during log 88 | filtering. If specified, this path is the only path 89 | that will appear in the results, other results will 90 | be omitted 91 | --target-name 92 | Name of the target to be used for finding logs 93 | --threshold Threshold to be used for filtering symbol compilation 94 | duration 95 | --output-path 96 | Path where the generated contents of parsed 97 | compilation duration will be stored 98 | --xcactivitylog-output-path 99 | Path where the parsed xcactivitylog' JSON will be 100 | stored 101 | --include-invalid-loc (Optional) If specified, symbols without a specific 102 | location () will be included in the 103 | results 104 | --enable-grouping (Optional) If specified, symbols are grouped by 105 | location and symbol before being written to 106 | `--output-path` 107 | ``` -------------------------------------------------------------------------------- /Tests/SwiftCompilationTimingParserTests/SwiftCompilationTimingParserTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2023 Qonto 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | @testable import SwiftCompilationTimingParser 26 | import XCTest 27 | final class SwiftCompilationTimingParserTests: XCTestCase { 28 | func test_whenNoInputProvided_thenShouldThrowAnError() async throws { 29 | // GIVEN 30 | let command = try XCTUnwrap( 31 | SwiftCompilationTimingParser.parseAsRoot([ 32 | "--root-path", 33 | "", 34 | "--target-name", 35 | "", 36 | "--threshold", 37 | "0", 38 | "--output-path", 39 | "" 40 | ]) as? SwiftCompilationTimingParser 41 | ) 42 | // WHEN 43 | var caughtError: Error? 44 | do { 45 | try await command.run() 46 | } catch { 47 | caughtError = error 48 | } 49 | // THEN 50 | XCTAssertNotNil(caughtError) 51 | } 52 | 53 | func test_whenOnlyDerivedDataPathIsSet_thenShouldThrowFileNotFoundError() async throws { 54 | // GIVEN 55 | let command = try XCTUnwrap( 56 | SwiftCompilationTimingParser.parseAsRoot([ 57 | "--derived-data-path", 58 | "", 59 | "--root-path", 60 | "", 61 | "--target-name", 62 | "", 63 | "--threshold", 64 | "0", 65 | "--output-path", 66 | "" 67 | ]) as? SwiftCompilationTimingParser 68 | ) 69 | // WHEN 70 | var caughtError: NSError? 71 | do { 72 | try await command.run() 73 | } catch let error as NSError { 74 | caughtError = error 75 | } 76 | // THEN 77 | XCTAssertEqual(caughtError?.domain, "NSCocoaErrorDomain") 78 | XCTAssertEqual(caughtError?.code, 260) 79 | } 80 | 81 | func test_whenOnlyXcodebuildLogPathIsSet_thenShouldThrowFileNotFoundError() async throws { 82 | // GIVEN 83 | let command = try XCTUnwrap( 84 | SwiftCompilationTimingParser.parseAsRoot([ 85 | "--xcodebuild-log-path", 86 | "", 87 | "--root-path", 88 | "", 89 | "--target-name", 90 | "", 91 | "--threshold", 92 | "0", 93 | "--output-path", 94 | "" 95 | ]) as? SwiftCompilationTimingParser 96 | ) 97 | // WHEN 98 | var caughtError: NSError? 99 | do { 100 | try await command.run() 101 | } catch let error as NSError { 102 | caughtError = error 103 | } 104 | // THEN 105 | XCTAssertEqual(caughtError?.domain, "NSCocoaErrorDomain") 106 | XCTAssertEqual(caughtError?.code, 256) 107 | } 108 | 109 | func test_whenWhenGroupingSet_thenShouldEnableGroupingIsTrue() async throws { 110 | // GIVEN 111 | let command = try XCTUnwrap( 112 | SwiftCompilationTimingParser.parseAsRoot([ 113 | "--root-path", 114 | "", 115 | "--target-name", 116 | "", 117 | "--threshold", 118 | "0", 119 | "--output-path", 120 | "", 121 | "--enable-grouping" 122 | ]) as? SwiftCompilationTimingParser 123 | ) 124 | // WHEN 125 | // THEN 126 | XCTAssertTrue(command.shouldEnableGrouping) 127 | } 128 | 129 | func test_whenWhenIncludeInvalidLocSet_thenShouldIncludeInvalidLocIsTrue() async throws { 130 | // GIVEN 131 | let command = try XCTUnwrap( 132 | SwiftCompilationTimingParser.parseAsRoot([ 133 | "--root-path", 134 | "", 135 | "--target-name", 136 | "", 137 | "--threshold", 138 | "0", 139 | "--output-path", 140 | "", 141 | "--include-invalid-loc" 142 | ]) as? SwiftCompilationTimingParser 143 | ) 144 | // WHEN 145 | // THEN 146 | XCTAssertTrue(command.shouldIncludeInvalidLoc) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/SwiftCompilationTimingParserFramework/SwiftCompilationTimingParser.swift: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2023 Qonto 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | import Foundation 26 | import RegexBuilder 27 | import XCLogParser 28 | 29 | public class SwiftCompilationTimingParser { 30 | public enum Error: LocalizedError { 31 | case missingLogPath 32 | } 33 | 34 | enum ValidPrefixes: String, CaseIterable { 35 | case CompileSwift 36 | case SwiftCompile 37 | case SwiftEmitModule 38 | } 39 | 40 | enum InvalidPrefixes: String { 41 | case Cleaning 42 | } 43 | 44 | public struct Configuration { 45 | let shouldEnableGrouping = false 46 | let shouldIncludeInvalidLoc = false 47 | let xcactivityLogOutputPath: String? 48 | let outputPath: String 49 | let threshold: Float 50 | let targetName: String 51 | let filteredPath: String? 52 | let xcodebuildLogPath: String? 53 | let derivedDataPath: String? 54 | let rootPath: String 55 | let prettyOutputFormating: Bool 56 | 57 | public init(xcactivityLogOutputPath: String? = nil, outputPath: String, threshold: Float, targetName: String, filteredPath: String? = nil, xcodebuildLogPath: String? = nil, derivedDataPath: String? = nil, rootPath: String, prettyOutputFormating: Bool) { 58 | self.xcactivityLogOutputPath = xcactivityLogOutputPath 59 | self.outputPath = outputPath 60 | self.threshold = threshold 61 | self.targetName = targetName 62 | self.filteredPath = filteredPath 63 | self.xcodebuildLogPath = xcodebuildLogPath 64 | self.derivedDataPath = derivedDataPath 65 | self.rootPath = rootPath 66 | self.prettyOutputFormating = prettyOutputFormating 67 | } 68 | } 69 | 70 | private var configuration: Configuration 71 | 72 | public init(configuration: SwiftCompilationTimingParser.Configuration) throws { 73 | self.configuration = configuration 74 | } 75 | 76 | public func run() async throws { 77 | var parsedTimings: [ParsedTiming] 78 | if let xcodebuildLogPath = configuration.xcodebuildLogPath, configuration.derivedDataPath == nil { 79 | let fileURL = URL(filePath: xcodebuildLogPath) 80 | let data = try Data(contentsOf: fileURL) 81 | parsedTimings = try processRawLog(log: data) 82 | } else if let derivedDataPath = configuration.derivedDataPath { 83 | var bestActivityLog: XCLogParser.IDEActivityLog? 84 | var ignoredActivityLogs: [String] = [] 85 | repeat { 86 | let activityLog = try await findActivityLogs(derivedDataPath: derivedDataPath) 87 | if !activityLog.mainSection.signature.hasPrefix(InvalidPrefixes.Cleaning.rawValue) { 88 | bestActivityLog = activityLog 89 | break 90 | } else { 91 | if ignoredActivityLogs.contains(activityLog.mainSection.uniqueIdentifier) { 92 | // exit loop if iterations have started over, i.e. no "good log" file was found 93 | break 94 | } 95 | ignoredActivityLogs.append(activityLog.mainSection.uniqueIdentifier) 96 | let uuid = activityLog.mainSection.uniqueIdentifier 97 | let filename = "\(uuid).xcactivitylog" 98 | let path = "\(derivedDataPath)/\(configuration.targetName)/Logs/Build/\(filename)" 99 | // Set modification date of the file to -3 months to avoid deleting the file and let LogFinder pick 100 | // the newest most recent log 101 | try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(-7.889399e+6)], ofItemAtPath: path) 102 | } 103 | } while bestActivityLog == nil 104 | if let bestActivityLog { 105 | parsedTimings = try await parseTimings(activityLog: bestActivityLog) 106 | try saveActivityLogIfNeeded(bestActivityLog) 107 | } else { 108 | return 109 | } 110 | } else { throw Error.missingLogPath } 111 | if configuration.shouldEnableGrouping { 112 | parsedTimings.group() 113 | } 114 | try saveFiles(parsedTimings: parsedTimings) 115 | } 116 | 117 | private func findActivityLogs(derivedDataPath: String) async throws -> XCLogParser.IDEActivityLog { 118 | let logPath = try XCLogParser.LogFinder().getLatestLogForProjectFolder(configuration.targetName, inDerivedData: URL(filePath: derivedDataPath)) 119 | return try XCLogParser.ActivityParser().parseActivityLogInURL(URL(filePath: logPath), redacted: false, withoutBuildSpecificInformation: false) 120 | } 121 | 122 | private func parseTimings(activityLog: XCLogParser.IDEActivityLog) async throws -> [ParsedTiming] { 123 | var subSections = activityLog.mainSection.subSections 124 | subSections.append(contentsOf: subSections.flatMap { $0.subSections }) 125 | return try await processSubSections(subSections: subSections) 126 | } 127 | 128 | private func saveFiles(parsedTimings: [ParsedTiming]) throws { 129 | let jsonEncoder = JSONEncoder() 130 | if configuration.prettyOutputFormating { 131 | jsonEncoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] 132 | } 133 | let data = try jsonEncoder.encode(parsedTimings) 134 | try data.write(to: URL(filePath: configuration.outputPath)) 135 | } 136 | 137 | private func saveActivityLogIfNeeded(_ activityLog: XCLogParser.IDEActivityLog) throws { 138 | guard let xcactivityLogOutputPath = configuration.xcactivityLogOutputPath else { return } 139 | let output = XCLogParser.FileOutput(path: xcactivityLogOutputPath) 140 | let logReporter = JsonReporter() 141 | let buildParser = ParserBuildSteps( 142 | machineName: nil, 143 | omitWarningsDetails: false, 144 | omitNotesDetails: true, 145 | truncLargeIssues: false 146 | ) 147 | let buildSteps = try buildParser.parse(activityLog: activityLog) 148 | try logReporter.report(build: buildSteps, output: output, rootOutput: "") 149 | } 150 | 151 | private func processRawLog(log: Data) throws -> [ParsedTiming] { 152 | let regex = try createRegex( 153 | rootPath: configuration.rootPath, 154 | filteredPath: configuration.filteredPath, 155 | shouldIncludeInvalidLoc: configuration.shouldIncludeInvalidLoc 156 | ) 157 | // The fastest way to split a large file by lines according to https://forums.swift.org/t/difficulties-with-efficient-large-file-parsing/23660/4 158 | return try log.withUnsafeBytes { 159 | let dataSlices = $0.split(separator: UInt8(ascii: "\n")) 160 | let lines = dataSlices.map { Substring(decoding: UnsafeRawBufferPointer(rebasing: $0), as: UTF8.self) } 161 | return try regex.extractTimingIfPresent(text: lines, threshold: configuration.threshold) ?? [] 162 | } 163 | } 164 | 165 | private func processSubSections(subSections: [XCLogParser.IDEActivityLogSection]) async throws -> [ParsedTiming] { 166 | let regex = try createRegex( 167 | rootPath: configuration.rootPath, 168 | filteredPath: configuration.filteredPath, 169 | shouldIncludeInvalidLoc: configuration.shouldIncludeInvalidLoc 170 | ) 171 | return try await withThrowingTaskGroup(of: [ParsedTiming]?.self) { group in 172 | for section in subSections { 173 | group.addTask { 174 | try section.extractingTimingIfPresent( 175 | regex: regex, 176 | threshold: self.configuration.threshold 177 | ) 178 | } 179 | } 180 | var extractedTimings: [ParsedTiming] = [] 181 | for try await result in group where result != nil { 182 | extractedTimings.append(contentsOf: result!) 183 | } 184 | return extractedTimings 185 | } 186 | } 187 | 188 | /* 189 | Searching for strings of similar format, containing '/r' and `/t` sequences: 190 | - 0.12ms /Users/vagrant/git/File1.swift:208:120 191 | - 0.33ms /Users/vagrant/git/File2:208:9 192 | - 7.29ms /Users/vagrant/git/File3:193:17 instance method verify(_:count:file:line:) 193 | - 0.33ms static method combine(_:input:output:) 194 | */ 195 | private func createRegex(rootPath: String, filteredPath: String?, shouldIncludeInvalidLoc: Bool) throws -> Regex<(Substring, Substring, Substring, Substring)> { 196 | let trailingRegex: Regex<(Substring, Substring, Substring)> 197 | if shouldIncludeInvalidLoc { 198 | if let filteredPath { 199 | trailingRegex = try Regex("(.*\(filteredPath).*?:\\d+:\\d+|\\Q\\E)\\t(.+)") 200 | } else { 201 | trailingRegex = Regex { 202 | /(.*?:\d+:\d+|\Q\E)\t(.+)/ 203 | } 204 | } 205 | } else { 206 | if let filteredPath { 207 | trailingRegex = try Regex("(.*\(filteredPath).*?:\\d+:\\d+)\\t(.+)") 208 | } else { 209 | trailingRegex = Regex { 210 | /(.*?:\d+:\d+)\t(.+)/ 211 | } 212 | } 213 | } 214 | 215 | return Regex { 216 | /(\d+\.\d+?)ms\t.*/ 217 | rootPath 218 | trailingRegex 219 | } 220 | } 221 | } 222 | 223 | private extension XCLogParser.IDEActivityLogSection { 224 | func extractingTimingIfPresent(regex: Regex<(Substring, Substring, Substring, Substring)>, threshold: Float) throws -> [ParsedTiming]? { 225 | guard 226 | SwiftCompilationTimingParser.ValidPrefixes.allCases.first(where: { signature.hasPrefix($0.rawValue) }) != nil, 227 | !text.isEmpty 228 | else { return nil } 229 | 230 | let lines = text.split(separator: "\r") 231 | 232 | return try regex.extractTimingIfPresent(text: lines, threshold: threshold) 233 | } 234 | } 235 | 236 | private extension Regex where Output == (Substring, Substring, Substring, Substring) { 237 | func extractTimingIfPresent(text: [Substring], threshold: Float) throws -> [ParsedTiming]? { 238 | var parsed: [ParsedTiming] = [] 239 | for line in text { 240 | guard let match = try wholeMatch(in: line) else { continue } 241 | guard let ms = Float(match.1), ms >= threshold else { continue } 242 | 243 | parsed.append(ParsedTiming(ms: ms, location: String(match.2), symbol: String(match.3))) 244 | } 245 | return parsed.isEmpty ? nil : parsed 246 | } 247 | } 248 | 249 | private extension Array where Element == ParsedTiming { 250 | mutating func group() { 251 | var groups: [String : ParsedTiming] = [:] 252 | for timing in self { 253 | var toAdd = timing 254 | let key = timing.location + timing.symbol 255 | if let existing = groups[key] { 256 | toAdd = ParsedTiming(ms: existing.ms + timing.ms, location: timing.location, symbol: timing.symbol) 257 | } 258 | groups[key] = toAdd 259 | } 260 | self = Array(groups.values) 261 | } 262 | } 263 | --------------------------------------------------------------------------------