Skip to content

Commit

Permalink
XcodeLogParser performance improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
bmxav committed Sep 10, 2024
1 parent 4775e85 commit 9973999
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 103 deletions.
4 changes: 2 additions & 2 deletions Sources/GenIR/Extensions/Process+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ extension Process {
try? stderrHandle.close()

return .init(
stdout: String(decoding: stdout, as: UTF8.self),
stderr: String(decoding: stderr, as: UTF8.self),
stdout: String(data: stdout, encoding: .utf8),
stderr: String(data: stderr, encoding: .utf8),
code: process.terminationStatus
)
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/GenIR/Extensions/String+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,10 @@ extension [String] {
return nil
}
}

extension StringProtocol {
/// Trims leading and trailing whitespace characters
func trimmed() -> String {
return trimmingCharacters(in: .whitespacesAndNewlines)
}
}
211 changes: 110 additions & 101 deletions Sources/GenIR/XcodeLogParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import LogHandlers

/// An XcodeLogParser extracts targets and their compiler commands from a given Xcode build log
class XcodeLogParser {

/// The Xcode build log contents
private let log: [String]
/// Any CLI Settings found in the build log
/// The current line offset in the log
private var offset: Int = 0
/// Any CLI settings found in the build log
private(set) var settings: [String: String] = [:]
/// The path to the Xcode build cache
private(set) var buildCachePath: URL!
Expand All @@ -33,9 +34,8 @@ class XcodeLogParser {
}

/// Start parsing the build log
/// - Parameter targets: The global list of targets
func parse() throws {
parseBuildLog(log)
parseBuildLog()

if targetCommands.isEmpty {
logger.debug("Found no targets in log")
Expand Down Expand Up @@ -67,106 +67,137 @@ class XcodeLogParser {
}
}

/// Parses an array representing the contents of an Xcode build log
/// - Parameters:
/// - lines: contents of the Xcode build log lines
// swiftlint:disable:next cyclomatic_complexity
private func parseBuildLog(_ lines: [String]) {
var currentTarget: String?
/// Parse the lines from the build log
func parseBuildLog() {
var seenTargets = Set<String>()

for (index, line) in lines.enumerated() {
let line = line.trimmingCharacters(in: .whitespacesAndNewlines)

if line.contains("Build settings from command line") {
// Every line until an empty line will contain a build setting from the CLI arguments
guard let nextEmptyLine = lines.nextIndex(of: "", after: index) else { continue }

settings = lines[index.advanced(by: 1)..<nextEmptyLine]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.map { $0.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespacesAndNewlines)} }
.filter { $0.count == 2 }
.map { ($0[0], $0[1]) }
.reduce(into: [String: String]()) { $0[$1.0] = $1.1 }
}
while let line = consumeLine() {
if line.hasPrefix("Build description path: ") {
buildCachePath = buildDescriptionPath(from: line)
} else if line.hasPrefix("Build settings from command line:") {
settings = parseBuildSettings()
} else {
// Attempt to find a build task on this line that we are interested in.
let task = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)[0]

switch task {
case "CompileC", "SwiftDriver", "CompileSwiftSources":
guard let target = target(from: line) else {
continue
}

if line.contains("Build description path: ") {
guard let startIndex = line.firstIndex(of: ":") else { continue }
if seenTargets.insert(target).inserted {
logger.debug("Found target: \(target)")
}

let stripped = line[line.index(after: startIndex)..<line.endIndex].trimmingCharacters(in: .whitespacesAndNewlines)
var cachePath = String(stripped).fileURL
let compilerCommands = parseCompilerCommands()

if cachePath.pathComponents.contains("DerivedData") {
// We want the 'project' folder which is the 'Project-randomcrap' folder inside of DerivedData.
// Build description path is inside this folder, but depending on the build - it can be a variable number of folders up
while cachePath.deletingLastPathComponent().lastPathComponent != "DerivedData" {
cachePath.deleteLastPathComponent()
}
} else {
// This build location is outside of the DerivedData directory - we want to go up to the folder _after_ the Build directory
while cachePath.lastPathComponent != "Build" {
cachePath.deleteLastPathComponent()
compilerCommands.forEach {
logger.debug("Found \($0.compiler.rawValue) compiler command for target: \(target)")
}

cachePath.deleteLastPathComponent()
targetCommands[target, default: []].append(contentsOf: compilerCommands)
default:
continue
}

buildCachePath = cachePath
}
}
}

if let target = target(from: line), currentTarget != target {
if seenTargets.insert(target).inserted {
logger.debug("Found target: \(target)")
}
/// Consume the next line from the log file and return it if we have not reached the end
private func consumeLine() -> String? {
guard offset + 1 < log.endIndex else { return nil }

currentTarget = target
}
defer { offset += 1 }
return log[offset]
}

guard let currentTarget else {
continue
/// Parse build settings key-value pairs
private func parseBuildSettings() -> [String: String] {
var settings = [String: String]()

// Build settings end with a blank line
while let line = consumeLine()?.trimmed(), !line.isEmpty {
let pair = line.split(separator: "=", maxSplits: 1).map { $0.trimmed() }
if pair.count < 2 {
settings[pair[0]] = ""
} else {
settings[pair[0]] = pair[1]
}
}

guard
let compilerCommand = compilerCommand(from: line),
isPartOfCompilerCommand(lines, index)
else {
continue
}
return settings
}

/// Parse the build description path from the provided line
/// - Parameter from: the line that should contain the build description path
private func buildDescriptionPath(from line: String) -> URL? {
guard line.hasPrefix("Build description path:"), let startIndex = line.firstIndex(of: ":") else {
return nil
}

var cachePath = String(line[line.index(after: startIndex)..<line.endIndex]).trimmed().fileURL

logger.debug("Found \(compilerCommand.compiler.rawValue) compiler command for target: \(currentTarget)")
if cachePath.pathComponents.contains("DerivedData") {
// We want the 'project' folder which is the 'Project-randomcrap' folder inside of DerivedData.
// Build description path is inside this folder, but depending on the build - it can be a variable number of folders up
while cachePath.deletingLastPathComponent().lastPathComponent != "DerivedData" {
cachePath.deleteLastPathComponent()
}
} else {
// This build location is outside of the DerivedData directory - we want to go up to the folder _after_ the Build directory
while cachePath.lastPathComponent != "Build" {
cachePath.deleteLastPathComponent()
}

targetCommands[currentTarget, default: [CompilerCommand]()].append(compilerCommand)
cachePath.deleteLastPathComponent()
}

return cachePath
}

/// Is the index provided part of a compiler command block
/// - Parameters:
/// - lines: all the lines in the build log
/// - index: the index of the line to search from
/// - Returns: true if it's determined that the index is part of compiler command block
private func isPartOfCompilerCommand(_ lines: [String], _ index: Int) -> Bool {
var result = false
var offset = lines.index(index, offsetBy: -2)

// Check the line starts with either 'CompileC', 'SwiftDriver', or 'CompileSwiftSources' to ensure we only pick up compilation commands
while lines.indices.contains(offset) {
let previousLine = lines[offset].trimmingCharacters(in: .whitespacesAndNewlines)
offset -= 1

if previousLine.isEmpty {
// hit the top of the block, exit loop
/// Parsecompiler commands from the current block
private func parseCompilerCommands() -> [CompilerCommand] {
var commands: [CompilerCommand] = []

while let line = consumeLine() {
// Assume we have reached the end of this build task's block when we encounter an unindented line.
guard line.hasPrefix(" ") else {
break
}

if previousLine.starts(with: "CompileC")
|| previousLine.starts(with: "SwiftDriver")
|| previousLine.starts(with: "CompileSwiftSources") {
result = true
break
guard let compilerCommand = parseCompilerCommand(from: line) else {
continue
}

commands.append(compilerCommand)
}

return commands
}

/// Parses a `CompilerCommand` from the given line if one exists
/// - Parameter from: the line which may contain a compiler command
private func parseCompilerCommand(from line: String) -> CompilerCommand? {
var commandLine = line

if let index = line.firstIndexWithEscapes(of: "/"), index != line.startIndex {
commandLine = String(line[index..<line.endIndex])
}

// Ignore preprocessing of assembly files
if commandLine.contains("-x assembler-with-cpp") {
return nil
}

// Note: the spaces here so we don't match subpaths
if commandLine.contains("/swiftc ") {
return .init(command: commandLine, compiler: .swiftc)
} else if commandLine.contains("/clang ") {
return .init(command: commandLine, compiler: .clang)
}

return result
return nil
}

/// Returns the target from the given line
Expand All @@ -190,26 +221,4 @@ class XcodeLogParser {

return nil
}

/// Returns the compiler command from a line, if one exists
/// - Parameter line: the line to parse
/// - Returns: the compiler command if one was successfully parsed
private func compilerCommand(from line: String) -> CompilerCommand? {
var stripped = line
if let index = stripped.firstIndexWithEscapes(of: "/"), index != stripped.startIndex {
stripped = String(stripped[index..<stripped.endIndex])
}

// Ignore preprocessing of assembly files
if stripped.contains("-x assembler-with-cpp") { return nil }

// Note: the spaces here are so we don't match subpaths
if stripped.contains("/swiftc ") {
return .init(command: stripped, compiler: .swiftc)
} else if stripped.contains("/clang ") {
return .init(command: stripped, compiler: .clang)
}

return nil
}
}

0 comments on commit 9973999

Please sign in to comment.