diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f071396..e9c226f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.2.0' # Specify the desired Xcode version + xcode-version: '16.4.0' # Specify the desired Xcode version - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 40915b2..15a43bf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ output/ /*.xcodeproj xcuserdata/ DerivedData/ -.swiftpm/config/registries.json +.swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc .swiftpm diff --git a/Documentation/releasing_an_update.md b/Documentation/releasing_an_update.md index e8d1be8..1dffb35 100644 --- a/Documentation/releasing_an_update.md +++ b/Documentation/releasing_an_update.md @@ -9,20 +9,14 @@ When you have an update for `gen-ir`, there's a couple things that need to happe As mentioned in the [Branching Model](branching_model.md), features should be merged into the `develop` branch. You should never merge a feature directly to `main`. -To release a new version of `gen-ir`, create a release branch and open a merge request from the release branch (see the [Branching Model](branching_model.md)) to `main` at the commit point you're wanting to release and to `develop`. Allow any automated check, peer reviews, and - when approved - merge the request. +To release a new version of `gen-ir`, create a release branch and open a merge request from the release branch (see the [Branching Model](branching_model.md)) to `main`. Allow any automated check, peer reviews, and - when approved - merge the request. Release version descriptions mm.nn.pp stand for a 2 digit major version, 2 digit minor version, and 2 digit patch version. For example, `0.3.11` is a valid version. Then, on your local machine: - Change to `main` and pull the changes - `git checkout main && git pull` - Create the new tag for the release: - - `git tag -a 1.0.0 -m "Gen IR version: 1.0.0` - - `git push --tags` -- Change to `develop` and pull the changes - - `git checkout develop && git pull` -- Recreate the new `develop` tag for the release: - - `git tag -d develop && git push --delete origin develop` - - `git tag -a develop -m "Gen IR Develop version: ` + - `git tag -a mm.nn.pp -m "Gen IR version: mm.nn.pp"` - `git push --tags` Then, in the GitHub UI: @@ -30,18 +24,11 @@ Then, in the GitHub UI: - Go to the [Releases](https://github.com/veracode/gen-ir/releases) page - Click `Draft a new release` - Set the title to the version name -- From the drop down list, choose your newly created tag +- From the drop down list, choose your newly created tag from the main branch - Click the `Generate release notes` button to create a change log - Ensure `Set as the latest release` is checked - Click the `Publish` button -- Click `Draft a new release` -- Set the title to `develop` -- From the drop down list, choose your newly created `develop` tag -- Click the `Generate release notes` button to create a change log -- Ensure `Set as pre-release` is checked -- Click the `Publish` button - A release has been made, congratulations. However there's additional steps for distributing the release via `brew`. ## Distributing a release diff --git a/PIF/.gitignore b/PIF/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/PIF/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Sources/DependencyGraph/DependencyGraph.swift b/Sources/DependencyGraph/DependencyGraph.swift index 25296fd..02766df 100644 --- a/Sources/DependencyGraph/DependencyGraph.swift +++ b/Sources/DependencyGraph/DependencyGraph.swift @@ -38,7 +38,7 @@ public class DependencyGraph { /// - Returns: the chain of nodes, starting with the 'bottom' of the dependency subgraph public func chain(for value: Value) -> [Node] { guard let node = findNode(for: value) else { - logger.debug("Couldn't find node for value: \(value.valueName)") + GenIRLogger.logger.debug("Couldn't find node for value: \(value.valueName)") return [] } @@ -49,26 +49,26 @@ public class DependencyGraph { /// - Parameter node: the node whose children to search through /// - Returns: an array of nodes ordered by a depth-first search approach private func depthFirstSearch(startingAt node: Node) -> [Node] { - logger.debug("----\nSearching for: \(node.value.valueName)") + GenIRLogger.logger.debug("----\nSearching for: \(node.value.valueName)") var visited = Set() var chain = [Node]() /// Visits node dependencies and adds them to the chain from the bottom up /// - Parameter node: the node to search through func depthFirst(node: Node) { - logger.debug("inserting node: \(node.value.valueName)") + GenIRLogger.logger.debug("inserting node: \(node.value.valueName)") visited.insert(node) for edge in node.edges where edge.relationship == .dependency { if visited.insert(edge.to).inserted { - logger.debug("edge to: \(edge.to)") + GenIRLogger.logger.debug("edge to: \(edge.to)") depthFirst(node: edge.to) } else { - logger.debug("edge already in visited: \(visited)") + GenIRLogger.logger.debug("edge already in visited: \(visited)") } } - logger.debug("appending to chain: \(node.value.valueName)") + GenIRLogger.logger.debug("appending to chain: \(node.value.valueName)") chain.append(node) } diff --git a/Sources/DependencyGraph/DependencyGraphBuilder.swift b/Sources/DependencyGraph/DependencyGraphBuilder.swift index 74ee422..a0e9fad 100644 --- a/Sources/DependencyGraph/DependencyGraphBuilder.swift +++ b/Sources/DependencyGraph/DependencyGraphBuilder.swift @@ -47,7 +47,7 @@ public class DependencyGraphBuilder 1 { - logger.error( + GenIRLogger.logger.error( """ Found more than one possible folders matching 'debug' or 'veracode' configurations: \(filtered). Please ensure you build from a clean state. """ diff --git a/Sources/GenIR/CompilerCommandRunner.swift b/Sources/GenIR/CompilerCommandRunner.swift index fc52254..10ddbbd 100644 --- a/Sources/GenIR/CompilerCommandRunner.swift +++ b/Sources/GenIR/CompilerCommandRunner.swift @@ -54,24 +54,24 @@ struct CompilerCommandRunner { let totalCommands = commands .map { $0.value.count } .reduce(0, +) - logger.info("Total commands to run: \(totalCommands)") + GenIRLogger.logger.info("Total commands to run: \(totalCommands)") var totalModulesRun = 0 for target in targets { // Continue to the next target if no commands are found for the current target guard let targetCommands = commands[TargetKey(projectName: target.projectName, targetName: target.name)] else { - logger.debug("No commands found for target: \(target.name) in project: \(target.projectName)") + GenIRLogger.logger.debug("No commands found for target: \(target.name) in project: \(target.projectName)") continue } - logger.info("Operating on target: \(target.name). Total modules processed: \(totalModulesRun)") + GenIRLogger.logger.info("Operating on target: \(target.name). Total modules processed: \(totalModulesRun)") totalModulesRun += try run(commands: targetCommands, for: target.productName, at: output) } let uniqueModules = Set(try fileManager.files(at: output, withSuffix: ".bc")).count - logger.info("Finished compiling all targets. Unique modules: \(uniqueModules)") + GenIRLogger.logger.info("Finished compiling all targets. Unique modules: \(uniqueModules)") } /// Runs all commands for a given target @@ -85,12 +85,12 @@ struct CompilerCommandRunner { let targetDirectory = directory.appendingPathComponent(name) try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) - logger.debug("Created target directory: \(targetDirectory)") + GenIRLogger.logger.debug("Created target directory: \(targetDirectory)") var targetModulesRun = 0 for (index, command) in commands.enumerated() { - logger.info( + GenIRLogger.logger.info( """ \(dryRun ? "Dry run of" : "Running") command (\(command.compiler.rawValue)) \(index + 1) of \(commands.count). \ Target modules processed: \(targetModulesRun) @@ -106,7 +106,7 @@ struct CompilerCommandRunner { do { result = try Process.runShell(executable, arguments: arguments, runInDirectory: directory) } catch { - logger.error( + GenIRLogger.logger.error( """ Couldn't create process for executable: \(executable) with arguments: \(arguments.joined(separator: " ")). \ This is likely a bug in parsing the build log. Please raise it as an issue. @@ -122,7 +122,7 @@ struct CompilerCommandRunner { continue } } - logger.error( + GenIRLogger.logger.error( """ Command finished: - code: \(result.code) @@ -141,7 +141,7 @@ struct CompilerCommandRunner { switch command.compiler { case .swiftc: guard let outputFileMap = try getOutputFileMap(from: arguments) else { - logger.error("Failed to find OutputFileMap for command \(command.command) ") + GenIRLogger.logger.error("Failed to find OutputFileMap for command \(command.command) ") break } @@ -151,7 +151,7 @@ struct CompilerCommandRunner { } if clangAdditionalModules == 0 && swiftAdditionalModules == 0 { - logger.error( + GenIRLogger.logger.error( """ No modules were produced from compiler, potential failure. Results: \n\n \ executable: \(executable)\n\n \ @@ -290,8 +290,8 @@ extension CompilerCommandRunner { let path = arguments[index + 1].fileURL guard fileManager.fileExists(atPath: path.filePath) else { - logger.error("Found an OutputFileMap, but it doesn't exist on disk? Please report this issue.") - logger.debug("OutputFileMap path: \(path)") + GenIRLogger.logger.error("Found an OutputFileMap, but it doesn't exist on disk? Please report this issue.") + GenIRLogger.logger.debug("OutputFileMap path: \(path)") return nil } diff --git a/Sources/GenIR/DebugData.swift b/Sources/GenIR/DebugData.swift new file mode 100644 index 0000000..0fa371b --- /dev/null +++ b/Sources/GenIR/DebugData.swift @@ -0,0 +1,206 @@ +import Foundation +import ArgumentParser // To use ValidationError +import LogHandlers +import Logging + +/// +/// This file contains the DebugData struct, which is responsible for capturing debug data during the execution of the program. +/// It includes methods for initializing the capture path and logging relevant information. The data is captured in a sub-directory +/// of the xcarchive and therefore will be included with the submission to the Veracode Platform. +/// +/// The struct is initialized with the xcarchive and a flag indicating whether debug data is to be captured. +/// The directory structure will be: +/// - xcarchive +/// - debug-data +/// - Gen-IR log output file. +/// - xcodebuild log which was input to Gen-IR +/// - PIF cache directory +/// - xcode-select ouput +/// - xcodebuild --version output +/// - swift --version output +/// - env | grep DEVELOPER_DIR output +/// - data.zip +/// +struct DebugData: Decodable { + + let capturePath: URL + let logDataPath: URL + + init (xcodeArchivePath: URL) throws { + + // Setup the capture path to hold the debug data. This path is a sub-directory of the xcarchive. + capturePath = xcodeArchivePath.appendingPathComponent("debug-data", isDirectory: true) + + // Make sure the directory to hold debug data exists and is empty + if !FileManager.default.directoryExists(at: capturePath) { + // It doesn't exist, so create it + try FileManager.default.createDirectory(at: capturePath, withIntermediateDirectories: true) + } + + // Create a subdirectory for the logs and add a file log handler to write the log there. + let zipLogPath = capturePath.appendingPathComponent("log") + try FileManager.default.createDirectory(at: zipLogPath, withIntermediateDirectories: true) + + logDataPath = zipLogPath.appendingPathComponent("genir-capture.log", isDirectory: false) + } + + /// + /// Tell the user what information will be captured. + /// + public func displayCaptureInfo() { + let captureInfo = Logger.Message( + """ + \n + \u{001B}[1m The Gen-IR capture option is enabled.\u{001B}[0m + The following data will be added to the xcarchive and sent to Veracode: + - Gen-IR log output file + - xcodebuild log which was input to Gen-IR + - PIF cache directory and its contents + - The location of the developer directory (e.g. /Applications/Xcode.app/Contents/Developer) + This is obtained via the xcode-select -p command and from the value of the DEVELOPER_DIR environment variable. + No other environment variables are captured. + - The xcodebuild version + - The swift-version + \n + """) + GenIRLogger.logger.info(captureInfo) + GenIRLogger.logger.info("Debug data will be captured to: \(capturePath)") + } + + /// + /// Capture the execution context: + /// This includes the xcodebuild log, the configured developer directory, the xcodebuild version, and the swift version. + /// + public func captureExecutionContext(logPath: URL) throws { + + // Capture the xcodebuild log + try FileManager.default.copyItem(at: logPath, to: capturePath.appendingPathComponent("xcodebuild.log")) + + // Capture the configured developer directory + let developerDir = try execShellCommand(command: "xcode-select", args: ["-p"]) + + // Capture a possible override of the developer directory + let developerDirOverride = ProcessInfo.processInfo.environment["DEVELOPER_DIR"] ?? "Not set" + + // Capture the xcodebuild version + let xcodeBuildVersion = try execShellCommand(command: "xcodebuild", args: ["-version"]) + + // Capture the swift version + let swiftVersion = try execShellCommand(command: "swift", args: ["-version"]) + + do { + let versionsUrl = capturePath.appendingPathComponent("versions.txt") + FileManager.default.createFile(atPath: versionsUrl.path, contents: nil) + let versionsFile = try FileHandle(forWritingTo: versionsUrl) + versionsFile.write(Data(""" + DEVELOPER_DIR: \(developerDir)\n + DEVELOPER_DIR_OVERRIDE: \(developerDirOverride)\n + XCODEBUILD_VERSION: \(xcodeBuildVersion)\n + SWIFT_VERSION: \(swiftVersion)\n + """.utf8)) + versionsFile.closeFile() + } catch { + GenIRLogger.logger.error("Debug data capture Error \(error) occurred creating the versions.txt file while capturing debug data.") + } + GenIRLogger.logger.info("Debug data capture execution context data captured.") + } + + /// + /// Capture the PIF cache: + /// This is a copy of the PIF cache from the location specified in the xcarchive. + /// The location is determined by the PIFCache.pifCachePath(in:) method. + /// + public func capturePIFCache(pifLocation: URL) throws { + + // Capture the PIF cache + let savedPif = capturePath.appendingPathComponent("pif-data") + do { + // Perform the copy operation and skip broken symlinks + try copyDirectorySkippingBrokenSymlinks(from: pifLocation, to: savedPif) + } catch { + GenIRLogger.logger.error("Debug data capture of PIF Cache error: \(error.localizedDescription)") + } + GenIRLogger.logger.info("Debug data capture PIF cache data captured.") + } + + /// + /// Do any final data captures and log the completion message. + /// + public func captureComplete(xcarchive: URL) throws { + GenIRLogger.logger.info("Debug data capture complete.") + } + + /// + /// Copy a directory and skip broken symlinks. + /// This is used to copy the PIF cache from the location based on the build cache path parsed from the xcode build log. + /// The location is determined by the PIFCache.pifCachePath(in:) method. + /// + private func copyDirectorySkippingBrokenSymlinks(from sourceURL: URL, to destinationURL: URL) throws { + + let fileManager = FileManager.default + let contents = try fileManager.contentsOfDirectory(at: sourceURL, includingPropertiesForKeys: [.isSymbolicLinkKey], options: []) + let sourceBaseName = sourceURL.path.hasSuffix("/") ? sourceURL.path : sourceURL.path + "/" + + for item in contents { + let resourceValues = try item.resourceValues(forKeys: [.isSymbolicLinkKey]) + + if resourceValues.isSymbolicLink == true { + // Check if the symlink target exists + let targetPath = try fileManager.destinationOfSymbolicLink(atPath: item.path) + if !fileManager.fileExists(atPath: targetPath) { + GenIRLogger.logger.info("Skipping broken symlink while copying PIFCache: \(item.path)") + continue + } + } + // Find the relative path of the item + let relativePart = item.path.replacingOccurrences(of: sourceBaseName, with: "") + + // Define destination path + let destinationItemURL = destinationURL.appendingPathComponent(relativePart) + let parentDestinationURL = destinationItemURL.deletingLastPathComponent() + do { + // Make sure the destination directory exists + try fileManager.createDirectory(at: parentDestinationURL, withIntermediateDirectories: true) + // Copy item + try fileManager.copyItem(at: item, to: destinationItemURL) + } catch { + GenIRLogger.logger.error("Error while copying a PIFCache item \(item) : \(error.localizedDescription)") + continue + } + } + } + + /// + /// Given a command string and it's arguments, invoke a shell to execute the command and return the command output. + /// + private func execShellCommand(command: String, args: [String]) throws -> String { + let result: Process.ReturnValue + do { + result = try Process.runShell(command, arguments: args, runInDirectory: FileManager.default.currentDirectoryPath.fileURL) + } catch { + GenIRLogger.logger.error( + """ + Debug data capture couldn't create process for command: \(command) with arguments: \(args.joined(separator: " ")). \ + Output will not be captured. + """ + ) + return "" + } + + if result.code != 0 { + GenIRLogger.logger.error( + """ + Debug data capture command finished with non-zero exit code. Output will not be captured. + - code: \(result.code) + - command: \(command) \(args.joined(separator: " ")) + - stdout: \(String(describing: result.stdout)) + - stderr: \(String(describing: result.stderr)) + """ + ) + + return "" + } + + return result.stdout ?? "" + } +} diff --git a/Sources/GenIR/GenIR.swift b/Sources/GenIR/GenIR.swift index d57dad2..ab3ea70 100644 --- a/Sources/GenIR/GenIR.swift +++ b/Sources/GenIR/GenIR.swift @@ -8,34 +8,58 @@ import LogHandlers /// The name of the program let programName = CommandLine.arguments.first! +struct DeprecatedOptions: ParsableArguments { + + /// Path to xcodeproj or xcworkspace file. This is hidden. Validation will notifiy the user if it is used. + @Option(help: ArgumentHelp("DEPRECATED: This Option is deprecated and will go away in a future version.", visibility: .hidden)) + var projectPath: URL? +} + +struct DebuggingOptions: ParsableArguments { + + @Option(help: ArgumentHelp("Path to PIF cache. Use this in place of what is in the Xcode build log", visibility: .hidden)) + var pifCachePath: URL? + + @Option(help: ArgumentHelp("Specifiy a logging level. The --debug flag will override this", visibility: .hidden)) + var logLevel: LogLevelArgument? + + @Flag(help: ArgumentHelp("If true, add captured debug data to the xcarchive.", visibility: .hidden)) + var capture: Bool = false +} + /// Command to emit LLVM IR from an Xcode build log @main struct IREmitterCommand: ParsableCommand { + + // The following is formatted to about 100 characters per line static let configuration = CommandConfiguration( commandName: "", abstract: "Consumes an Xcode build log, and outputs LLVM IR, in the bitstream format, to the folder specified", discussion: """ - This can either be done via a file, or via stdin. You may have to redirect stderr to stdin before piping it to this \ + This can either be done via a file, or via stdin. You may have to redirect stderr to stdin before \npiping it to this \ tool. - This tool requires a full Xcode build log in order to capture all files in the project. If this is not provided, you may notice \ + This tool requires a full Xcode build log in order to capture all files in the project. If this is not \nprovided, you may notice \ that not all modules are emitted. To ensure this, run `xcodebuild clean` first before you `xcodebuild build` command. Example with build log: - $ xcodebuild clean && xcodebuild build -project MyProject.xcodeproj -configuration Debug -scheme MyScheme \ - DEBUG_INFOMATION_FORMAT=dwarf-with-dsym ENABLE_BITCODE=NO > log.txt + $ xcodebuild clean && xcodebuild build -project MyProject.xcodeproj \\\n\t\t-configuration Debug \\\n\t\t-scheme MyScheme \ + \\\n\t\tDEBUG_INFOMATION_FORMAT=dwarf-with-dsym \\\n\t\tENABLE_BITCODE=NO \\\n\t\t> log.txt $ \(programName) log.txt x.xcarchive Example with pipe: - $ xcodebuild clean && xcodebuild build -project MyProject.xcodeproj -configuration Debug -scheme MyScheme \ - DEBUG_INFOMATION_FORMAT=dwarf-with-dsym ENABLE_BITCODE=NO 2>&1 | \(programName) - x.xcarchive + $ xcodebuild clean && xcodebuild build -project MyProject.xcodeproj \\\n\t\t-configuration Debug \\\n\t\t-scheme MyScheme \ + \\\n\t\tDEBUG_INFOMATION_FORMAT=dwarf-with-dsym \\\n\t\tENABLE_BITCODE=NO \\\n\t\t2>&1 | \(programName) - x.xcarchive """, version: "v\(Versions.version)" ) + /// Optional variable to capture debug data, if requested. + var debugData: DebugData? + /// Path to an Xcode build log, or `-` if build log should be read from stdin @Argument(help: "Path to a full Xcode build log. If `-` is provided, stdin will be read") var logPath: String @@ -44,10 +68,6 @@ let programName = CommandLine.arguments.first! @Argument(help: "Path to the xcarchive associated with the build log") var xcarchivePath: URL - /// Path to xcodeproj or xcworkspace file - @Option(help: "DEPRECATED: This Option is deprecated and will go away in a future version.") - var projectPath: URL? - /// Enables enhanced debug logging @Flag(help: "Enables debug level logging") var debug = false @@ -56,30 +76,39 @@ let programName = CommandLine.arguments.first! @Flag(help: "Reduces log noise by suppressing xcodebuild output when reading from stdin") var quieter = false - @Flag(help: "Runs the tool without outputting IR to disk (i.e. leaving out the compiler command runner stage)") + @Flag(help: "Runs the tool without writing IR to disk (i.e. skips running compiler commands)") var dryRun = false @Flag(help: "Output the dependency graph as .dot files to the output directory - debug only") var dumpDependencyGraph = false - @Option(help: ArgumentHelp("Path to PIF cache. Use this in place of what is in the Xcode build log", visibility: .hidden)) - var pifCachePath: URL? + // Drop this in release 0.6 or greater + @OptionGroup var deprecatedOptions: DeprecatedOptions - @Option(help: ArgumentHelp("Specifiy a logging level. The --debug flag will override this", visibility: .hidden)) - var logLevel: LogLevelArgument? + // These options are hidden and will not be shown in the help text + @OptionGroup var debuggingOptions: DebuggingOptions mutating func validate() throws { - // This will run before run() so set this here - if debug { - logger.logLevel = .debug - } else { - logger.logLevel = logLevel?.level ?? .info + + try validateXcarchive() + + // Check if requested to capture debug data + // Logging is set to the default by the first reference to GenIRLogger.logger. When captureing debug data we also add a log handler + // to capture the log output to a file. + if debuggingOptions.capture { + debugData = try DebugData(xcodeArchivePath: xcarchivePath) + } - if projectPath != nil { - logger.warning("--project-path has been deprecated and will go away in a future version. Please remove it from your invocation.") + // Initialize the logger + try initLogging() + + if deprecatedOptions.projectPath != nil { + GenIRLogger.logger.warning("\u{001B}[1m --project-path has been deprecated and will go away in a future version. \nPlease remove it from your invocation.\u{001B}[0m") } + } + mutating private func validateXcarchive() throws { // Version 0.2.x and below allowed the output folder to be any arbitrary folder. // Docs said to use 'IR' inside an xcarchive. For backwards compatibility, if we have an xcarchive path with an IR // folder, remove the IR portion @@ -94,40 +123,96 @@ let programName = CommandLine.arguments.first! if !FileManager.default.directoryExists(at: xcarchivePath) { throw ValidationError("Archive path doesn't exist: \(xcarchivePath.filePath)") } + + // Clean outpuf folders in the archive + let irPath = xcarchivePath.appendingPathComponent("IR") + if FileManager.default.directoryExists(at: irPath) { + do { + try FileManager.default.removeItem(at: irPath) + } catch { + GenIRLogger.logger.warning("Failed to remove existing IR folder at \(irPath.filePath): \(error)") + } + } + + let debugDataPath = xcarchivePath.appendingPathComponent("debug-data") + if FileManager.default.directoryExists(at: debugDataPath) { + do { + try FileManager.default.removeItem(at: debugDataPath) + } catch { + GenIRLogger.logger.warning("Failed to remove existing debug-data folder at \(debugDataPath.filePath): \(error)") + } + } + } + + mutating private func initLogging() throws { + // First, determine the requested log level + var logLevel: Logger.Level + if debug { + // If debug is set, we use the debug log level + logLevel = .debug + } else if let logLevelArg = debuggingOptions.logLevel?.level { + // If a log level was specified, use that + logLevel = logLevelArg + } else { + // Default to info level if no debug or log level is specified + logLevel = .info + } + + // Default handler is stdout + if debuggingOptions.capture { + // If capture is enabled, we create a file log handler to capture the log output + var captureLogHandler = FileLogHandler(filePath: debugData!.logDataPath) + var stdioLogHandler = StdIOStreamLogHandler() + captureLogHandler.logLevel = GenIRLogger.logger.logLevel + stdioLogHandler.logLevel = GenIRLogger.logger.logLevel + GenIRLogger.logger = Logger(label: "Gen-IR") { _ in MultiplexLogHandler([stdioLogHandler, captureLogHandler]) } + } else { + // Otherwise, we use the default StdIOStreamLogHandler + GenIRLogger.logger = Logger(label: "Gen-IR") { _ in StdIOStreamLogHandler() } + } + GenIRLogger.logger.logLevel = logLevel } mutating func run() throws { try run( log: logPath, archive: xcarchivePath, - level: logger.logLevel, + level: GenIRLogger.logger.logLevel, dryRun: dryRun, dumpDependencyGraph: dumpDependencyGraph, - pifCachePath: pifCachePath + pifCachePath: debuggingOptions.pifCachePath, + capture: debuggingOptions.capture ) } + // swiftlint:disable:next function_body_length mutating func run( log: String, archive: URL, - level: Logger.Level, - dryRun: Bool, - dumpDependencyGraph: Bool, - pifCachePath: URL? = nil + level: Logger.Level = .info, + dryRun: Bool = false, + dumpDependencyGraph: Bool = false, + pifCachePath: URL? = nil, + capture: Bool = false ) throws { - logger.logLevel = level - logger.info( - """ - - Gen-IR v\(IREmitterCommand.configuration.version) - log: \(log) - archive: \(archive.filePath) - level: \(level) - dryRun: \(dryRun) - dumpDependencyGraph: \(dumpDependencyGraph) - """) - let output = archive.appendingPathComponent("IR") + // Perform initial capture. + debugData?.displayCaptureInfo() + try debugData?.captureExecutionContext(logPath: log.fileURL) + + GenIRLogger.logger.info( + """ + + Gen-IR v\(IREmitterCommand.configuration.version) + log: \(log) + archive: \(archive.filePath) + level: \(level) + dryRun: \(dryRun) + dumpDependencyGraph: \(dumpDependencyGraph) + + """) + + let output = archive.appendingPathComponent("IR") let log = try logParser(for: log) try log.parse() @@ -135,13 +220,14 @@ let programName = CommandLine.arguments.first! guard let pifCachePath = pifCachePath ?? log.buildCachePath else { throw ValidationError("PIF cache path not found in log!") } - logger.debug("PIF location is: \(pifCachePath)") let pifCache = try PIFCache(buildCache: pifCachePath) + GenIRLogger.logger.debug("PIF location is: \(pifCache.pifCachePath)") + try debugData?.capturePIFCache(pifLocation: pifCache.pifCachePath) let targets = pifCache.projects.flatMap { project in project.targets.compactMap { Target(from: $0, in: project) } }.filter { !$0.isTest } - logger.debug("Project non-test targets: \(targets.count)") + GenIRLogger.logger.debug("Project non-test targets: \(targets.count)") let targetCommands = log.commandLog.reduce(into: [TargetKey: [CompilerCommand]]()) { commands, entry in commands[entry.target, default: []].append(entry.command) @@ -158,14 +244,14 @@ let programName = CommandLine.arguments.first! let tempDirectory = try FileManager.default.temporaryDirectory(named: "gen-ir-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: tempDirectory) } - logger.info("Using temp directory as working directory: \(tempDirectory)") + GenIRLogger.logger.info("Using temp directory as working directory: \(tempDirectory)") let runner = CompilerCommandRunner( output: tempDirectory, buildCacheManipulator: buildCacheManipulator, dryRun: dryRun ) - logger.debug("Targets to run: \(targets.count)") + GenIRLogger.logger.debug("Targets to run: \(targets.count)") try runner.run(targets: targets, commands: targetCommands) let postprocessor = try OutputPostprocessor( @@ -175,7 +261,8 @@ let programName = CommandLine.arguments.first! ) try postprocessor.process() - logger.info("\n\n** Gen-IR SUCCEEDED **\n\n") + GenIRLogger.logger.info("\n\n** Gen-IR SUCCEEDED **\n\n") + try debugData?.captureComplete(xcarchive: archive) } private func buildDependencyGraph(targets: [Target], pifCache: PIFCache, output: URL, dumpGraph: Bool) -> DependencyGraph { @@ -194,7 +281,7 @@ let programName = CommandLine.arguments.first! .filePath ) } catch { - logger.error("toDot error: \(error)") + GenIRLogger.logger.error("toDot error: \(error)") } } return graph diff --git a/Sources/GenIR/OutputPostprocessor.swift b/Sources/GenIR/OutputPostprocessor.swift index 03e4210..2569ee8 100644 --- a/Sources/GenIR/OutputPostprocessor.swift +++ b/Sources/GenIR/OutputPostprocessor.swift @@ -69,10 +69,10 @@ class OutputPostprocessor { // folder. Otherwise we will create an empty directory and that will contain the artifacts // of the dependency chain. if manager.directoryExists(at: buildDirectory) { - logger.debug("Copying \(node.value.guid) with name \(node.value.productName)") + GenIRLogger.logger.debug("Copying \(node.value.guid) with name \(node.value.productName)") try manager.copyItem(at: buildDirectory, to: irDirectory) } else { - logger.debug("Creating build directory for \(node.value.guid) with name \(node.value.productName)") + GenIRLogger.logger.debug("Creating build directory for \(node.value.guid) with name \(node.value.productName)") try manager.createDirectory(at: irDirectory, withIntermediateDirectories: false) } @@ -88,7 +88,7 @@ class OutputPostprocessor { } for node in graph.chain(for: target) { - logger.debug("Processing Node: \(node.valueName)") + GenIRLogger.logger.debug("Processing Node: \(node.valueName)") // Do not copy dynamic dependencies guard dynamicDependencyToPath[node.value.productName] == nil else { continue } @@ -100,7 +100,7 @@ class OutputPostprocessor { do { try copyContentsOfDirectoryMergingDifferingFiles(at: buildDirectory, to: irDirectory) } catch { - logger.debug("Copy error: \(error)") + GenIRLogger.logger.debug("Copy error: \(error)") } } } @@ -125,7 +125,7 @@ class OutputPostprocessor { // Avoid overwriting existing files with the same name. let uniqueDestinationURL = manager.uniqueFilename(directory: destination.deletingLastPathComponent(), filename: source.lastPathComponent) - logger.debug("Copying source \(source) to destination: \(uniqueDestinationURL)") + GenIRLogger.logger.debug("Copying source \(source) to destination: \(uniqueDestinationURL)") try manager.copyItem(at: source, to: uniqueDestinationURL) } } @@ -135,7 +135,7 @@ class OutputPostprocessor { /// - Returns: a mapping of filename to filepath for dynamic objects in the provided path private func dynamicDependencies(in xcarchive: URL) -> [String: URL] { let searchPath = baseSearchPath(startingAt: xcarchive) - logger.debug("Using search path for dynamic dependencies: \(searchPath)") + GenIRLogger.logger.debug("Using search path for dynamic dependencies: \(searchPath)") let dynamicDependencyExtensions = ["framework", "appex", "app"] @@ -170,7 +170,7 @@ class OutputPostprocessor { } if contents.count > 1 { - logger.debug("Expected one folder at: \(path). Found \(contents.count). Selecting \(contents.first!)") + GenIRLogger.logger.debug("Expected one folder at: \(path). Found \(contents.count). Selecting \(contents.first!)") } return contents.first! @@ -182,7 +182,7 @@ class OutputPostprocessor { } } - logger.debug("Couldn't determine the base search path for the xcarchive, using: \(productsPath)") + GenIRLogger.logger.debug("Couldn't determine the base search path for the xcarchive, using: \(productsPath)") return productsPath } } diff --git a/Sources/GenIR/PIFCache.swift b/Sources/GenIR/PIFCache.swift index de9fcf9..536dba1 100644 --- a/Sources/GenIR/PIFCache.swift +++ b/Sources/GenIR/PIFCache.swift @@ -8,7 +8,7 @@ import LogHandlers /// This class is used in conjunction with `PIFDependencyProvider` to enable building dependency relationships between the various targets class PIFCache { /// The path to the PIF Cache - private let pifCachePath: URL + public let pifCachePath: URL /// The most recent `PIF.Workspace` in the cache private let workspace: PIF.Workspace @@ -35,7 +35,7 @@ class PIFCache { pifCachePath = try Self.pifCachePath(in: buildCache) do { - let cache = try PIFCacheParser(cachePath: pifCachePath, logger: logger) + let cache = try PIFCacheParser(cachePath: pifCachePath, logger: GenIRLogger.logger) workspace = cache.workspace } catch { throw Error.pifError(error) @@ -45,7 +45,7 @@ class PIFCache { guidToTargets = targets.reduce(into: [PIF.GUID: PIF.BaseTarget]()) { partial, target in partial[target.guid] = target } - logger.debug("Project targets: \(targets.count) reduced to \(guidToTargets.count)") + GenIRLogger.logger.debug("Project targets: \(targets.count) reduced to \(guidToTargets.count)") } func target(guid: PIF.GUID) -> PIF.BaseTarget? { @@ -98,7 +98,7 @@ class PIFCache { case let group as PIF.Group: resolveChildren(starting: group.children) default: - logger.debug("Unhandled reference type: \(child)") + GenIRLogger.logger.debug("Unhandled reference type: \(child)") } } } @@ -205,19 +205,19 @@ struct PIFDependencyProvider: DependencyProviding { if productUnderlyingTargets.isEmpty && !productTargetDependencies.isEmpty { // We likely have a stub target here (i.e. a precompiled framework) // see https://github.com/apple/swift-package-manager/issues/6069 for more - logger.debug("Resolving Swift Package (\(productName) - \(packageProductGUID)) resulted in no targets. Possible stub target in: \(productTargetDependencies)") + GenIRLogger.logger.debug("Resolving Swift Package (\(productName) - \(packageProductGUID)) resulted in no targets. Possible stub target in: \(productTargetDependencies)") return nil } else if productUnderlyingTargets.isEmpty && productTargetDependencies.isEmpty { - logger.debug("Resolving Swift Package (\(productName) - \(packageProductGUID)) resulted in no targets. Likely a prebuilt dependency") + GenIRLogger.logger.debug("Resolving Swift Package (\(productName) - \(packageProductGUID)) resulted in no targets. Likely a prebuilt dependency") return nil } guard productTargetDependencies.count == 1, let target = productTargetDependencies.first else { - logger.debug("Expecting one matching package target - found \(productTargetDependencies.count): \(productTargetDependencies). Returning first match if it exists") + GenIRLogger.logger.debug("Expecting one matching package target - found \(productTargetDependencies.count): \(productTargetDependencies). Returning first match if it exists") return productTargetDependencies.first?.targetGUID } - logger.debug("\(packageProductGUID) resolves to \(target.targetGUID)") + GenIRLogger.logger.debug("\(packageProductGUID) resolves to \(target.targetGUID)") return target.targetGUID } diff --git a/Sources/GenIR/Versions.swift b/Sources/GenIR/Versions.swift index c30da4b..0d64883 100644 --- a/Sources/GenIR/Versions.swift +++ b/Sources/GenIR/Versions.swift @@ -4,9 +4,14 @@ // // Created by Thomas Hedderwick on 12/09/2022. // - +// History: +// 2025-nn-nn - 0.5.4 -- Update release doc; warn multiple builds; capture debug data +// 2025-04-18 - 0.5.3 -- PIF Tracing; log unique compiler commands +// 2025-04-09 - 0.5.2 -- PIF sort workspace; log instead of throw +// 2024-09-17 - 0.5.1 +// 2024-09-16 - 0.5.0 -- Process based on the PIF cache instead of project files. import Foundation enum Versions { - static let version = "0.5.3" + static let version = "0.5.4" } diff --git a/Sources/GenIR/XcodeLogParser.swift b/Sources/GenIR/XcodeLogParser.swift index 38c4622..d3b8600 100644 --- a/Sources/GenIR/XcodeLogParser.swift +++ b/Sources/GenIR/XcodeLogParser.swift @@ -25,6 +25,9 @@ class XcodeLogParser { private(set) var buildCachePath: URL! private(set) var commandLog: [CommandEntry] = [] + // Gen-IR designed to parse one build at a time. + internal var buildCount: Int = 0 + enum Error: Swift.Error { case noCommandsFound(String) case noTargetsFound(String) @@ -42,7 +45,7 @@ class XcodeLogParser { parseBuildLog() if commandLog.isEmpty { - logger.debug("Found no targets in log") + GenIRLogger.logger.debug("Found no targets in log") throw Error.noTargetsFound( """ @@ -52,7 +55,7 @@ class XcodeLogParser { } if commandLog.count == 0 { - logger.debug("Found no commands in log") + GenIRLogger.logger.debug("Found no commands in log") throw Error.noCommandsFound( """ @@ -64,6 +67,10 @@ class XcodeLogParser { if buildCachePath == nil { throw Error.noBuildCachePathFound("No build cache was found from the build log. Please report this as a bug.") } + + if buildCount > 1 { + GenIRLogger.logger.warning("\n\t ---- Multiple builds found in the log file, Gen-IR is designed to only parse one build at a time. ----\n") + } } /// Parse the lines from the build log @@ -76,6 +83,8 @@ class XcodeLogParser { buildCachePath = buildDescriptionPath(from: line) } else if line.hasPrefix("Build settings from command line:") { settings = parseBuildSettings() + } else if line.hasPrefix("** ARCHIVE SUCCEEDED **") { + buildCount += 1 } 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] @@ -87,7 +96,7 @@ class XcodeLogParser { } if seenTargets.insert(target).inserted { - logger.debug("Found target: \(target)") + GenIRLogger.logger.debug("Found target: \(target)") } let commands = parseCompilerCommands(target: target) @@ -96,7 +105,7 @@ class XcodeLogParser { let commandKey = "\($0.command.compiler.rawValue)-\(target)" if (commandsToLog.insert(commandKey)).inserted { // Log the compiler along with the target - logger.debug("Found \($0.command.compiler.rawValue) compiler command for target: \(target)") + GenIRLogger.logger.debug("Found \($0.command.compiler.rawValue) compiler command for target: \(target)") } } @@ -111,7 +120,7 @@ class XcodeLogParser { /// 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 } + guard offset < log.endIndex else { return nil } defer { offset += 1 } return log[offset] diff --git a/Sources/LogHandlers/.gitignore b/Sources/LogHandlers/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/Sources/LogHandlers/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Sources/LogHandlers/Sources/LogHandlers/FileLogHandler.swift b/Sources/LogHandlers/Sources/LogHandlers/FileLogHandler.swift new file mode 100644 index 0000000..b3cf7f2 --- /dev/null +++ b/Sources/LogHandlers/Sources/LogHandlers/FileLogHandler.swift @@ -0,0 +1,33 @@ +// +// FileLogHandler.swift +// + +import Foundation +import Logging + +public struct FileLogHandler: GenIRLogHandler { + + private let fileStream: GenIrIoTextStream + + public var metadata: Logging.Logger.Metadata = [:] + public var logLevel: Logging.Logger.Level = .info + + public init(filePath: URL) { + fileStream = GenIrIoTextStream(file: fopen(filePath.path, "w")) + } + + // periphery:ignore:parameters count + // swiftlint:disable:next function_parameter_count + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + let lineInfo = lineInfo(for: level, file: file, function: function, line: line) + fileStream.write("\(timestamp)\(lineInfo)\(levelPrefix)\(message)\n") + } +} diff --git a/Sources/LogHandlers/Sources/LogHandlers/GenIRLoggingDefinition.swift b/Sources/LogHandlers/Sources/LogHandlers/GenIRLoggingDefinition.swift new file mode 100644 index 0000000..84309f3 --- /dev/null +++ b/Sources/LogHandlers/Sources/LogHandlers/GenIRLoggingDefinition.swift @@ -0,0 +1,119 @@ +import Foundation +import Logging + +/// All module logger (yes because swift-log hasn't really solved for setting level globally without passing an instance around everywhere) +public class GenIRLogger { + private static var internalLogger: Logger? + + /// The logger for the GenIR module. This is a singleton instance that can be used to log messages throughout the module. + public static var logger: Logger { + get { + return internalLogger ?? Logger(label: "Gen-IR") + } + set {internalLogger = newValue } + } +} + +/// A protocol that defines a logging handler for GenIR. +public protocol GenIRLogHandler: LogHandler { + + var levelPrefix: String { get } + + var timestamp: String { get } + + func lineInfo(for level: Logger.Level, file: String, function: String, line: UInt) -> String + + // swiftlint:disable:next function_parameter_count + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) +} + +extension GenIRLogHandler { + + /// Add, change, or remove metadata for this handler. Metadata is a dictionary of key-value pairs which can be + /// used to add additional information to log messages. + public subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? { + get { metadata[key] } + set(newValue) { metadata[key] = newValue } + } + + /// Convert a log level to a string prefix for the log message. + public var levelPrefix: String { + switch logLevel { + case .trace: + return "[TRACE] " + case .debug: + return "[DEBUG] " + case .info: + return "" + case .notice, .warning: + return "[~] " + case .error: + return "[!] " + case .critical: + return "[!!!] " + } + } + + /// Add a timestamp to the log message based on the log level. + public var timestamp: String { + switch logLevel { + case .trace, .debug, .notice, .warning, .error, .critical: + return "\(Date.now) " + case .info: + return "" + } + } + + /// Add line information to the log message based on the log level. + public func lineInfo(for level: Logger.Level, file: String, function: String, line: UInt) -> String { + switch level { + case .trace, .debug, .notice, .warning, .error, .critical: + return "[\(file):\(line) \(function)] " + case .info: + return "" + } + } + + // swiftlint:disable function_parameter_count + /// Log a message with the specified log level, message, metadata, source, file, function, and line number. + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + let lineInfo = lineInfo(for: level, file: file, function: function, line: line) + print("\(timestamp)\(lineInfo)\(levelPrefix)\(message)\n") + } + // swiftlint:enable function_parameter_count +} + +/// A custom text output stream that writes to a file. +struct GenIrIoTextStream: TextOutputStream { + + let file: UnsafeMutablePointer + + func write(_ string: String) { + var string = string + string.makeContiguousUTF8() + string.utf8.withContiguousStorageIfAvailable { bytes in + flockfile(file) + defer { funlockfile(file) } + + fwrite(bytes.baseAddress!, 1, bytes.count, file) + + fflush(file) + } + } +} diff --git a/Sources/LogHandlers/Sources/LogHandlers/StdoutLogHandler.swift b/Sources/LogHandlers/Sources/LogHandlers/StdoutLogHandler.swift index 1cd36d3..c11b2c0 100644 --- a/Sources/LogHandlers/Sources/LogHandlers/StdoutLogHandler.swift +++ b/Sources/LogHandlers/Sources/LogHandlers/StdoutLogHandler.swift @@ -8,38 +8,8 @@ import Foundation import Logging -/// All module logger (yes because swift-log hasn't really solved for setting level globally without passing an instance around everywhere) -public var logger = Logger(label: "LogHandler", factory: StdIOStreamLogHandler.init) - -struct StdIOTextStream: TextOutputStream { - static let stdout = StdIOTextStream(file: Darwin.stdout) - static let stderr = StdIOTextStream(file: Darwin.stderr) - - let file: UnsafeMutablePointer - - func write(_ string: String) { - var string = string - string.makeContiguousUTF8() - string.utf8.withContiguousStorageIfAvailable { bytes in - flockfile(file) - defer { funlockfile(file) } - - fwrite(bytes.baseAddress!, 1, bytes.count, file) - - fflush(file) - } - } -} - -public struct StdIOStreamLogHandler: LogHandler { - internal typealias SendableTextOutputStream = TextOutputStream & Sendable - - private let stdout = StdIOTextStream.stdout - - public subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? { - get { metadata[key] } - set(newValue) { metadata[key] = newValue } - } +public struct StdIOStreamLogHandler: GenIRLogHandler { + let stdout = GenIrIoTextStream(file: Darwin.stdout) public var metadata: Logging.Logger.Metadata = [:] public var logLevel: Logging.Logger.Level = .info @@ -60,42 +30,6 @@ public struct StdIOStreamLogHandler: LogHandler { line: UInt ) { let lineInfo = lineInfo(for: level, file: file, function: function, line: line) - stdout.write("\(timestamp)\(lineInfo)\(levelPrefix)\(message)\n") } - - private var levelPrefix: String { - switch logLevel { - case .trace: - return "[TRACE] " - case .debug: - return "[DEBUG] " - case .info: - return "" - case .notice, .warning: - return "[~] " - case .error: - return "[!] " - case .critical: - return "[!!!] " - } - } - - private var timestamp: String { - switch logLevel { - case .trace, .debug, .notice, .warning, .error, .critical: - return "\(Date.now) " - case .info: - return "" - } - } - - private func lineInfo(for level: Logger.Level, file: String, function: String, line: UInt) -> String { - switch level { - case .trace, .debug, .notice, .warning, .error, .critical: - return "[\(file):\(line) \(function)] " - case .info: - return "" - } - } } diff --git a/TestAssets/PIFCaches/SPMTest/MyBinaryDependency/.gitignore b/TestAssets/PIFCaches/SPMTest/MyBinaryDependency/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/TestAssets/PIFCaches/SPMTest/MyBinaryDependency/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/TestAssets/PIFCaches/SPMTest/MyCommonLibrary/.gitignore b/TestAssets/PIFCaches/SPMTest/MyCommonLibrary/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/TestAssets/PIFCaches/SPMTest/MyCommonLibrary/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/TestAssets/PIFCaches/SPMTest/MyLibrary/.gitignore b/TestAssets/PIFCaches/SPMTest/MyLibrary/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/TestAssets/PIFCaches/SPMTest/MyLibrary/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/TestAssets/PIFCaches/SPMTest/MyTransitiveLibrary/.gitignore b/TestAssets/PIFCaches/SPMTest/MyTransitiveLibrary/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/TestAssets/PIFCaches/SPMTest/MyTransitiveLibrary/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Tests/GenIRTests/XcodeLogParserTests.swift b/Tests/GenIRTests/XcodeLogParserTests.swift new file mode 100644 index 0000000..6be2fac --- /dev/null +++ b/Tests/GenIRTests/XcodeLogParserTests.swift @@ -0,0 +1,25 @@ +import XCTest +import Foundation +import Testing +@testable import gen_ir + +@Test +func testWarnOnMultipleBuilds() throws { + let logContent: [String] = [ + "Build description path: /Users/ur/Library/Developer/Xcode/DrvdData/Exper1-etmk/Build/Intermediates.noindex/ArchiveIntermediates/Experimental1/IntermediatePath/XCBuildData/65828f.xcbuilddata", + "", + "SwiftDriver Experimental1 normal arm64 com.apple.xcode.tools.swift.compiler (in target 'Experimental1' from project 'Experimental1')", + " cd /p", + " builtin-SwiftDriver -- /swiftc build command", + "", + "** ARCHIVE SUCCEEDED **", + " ", + "Build description path: /Users/ur/Library/Developer/Xcode/DrvdData/Exper1-etmk/Build/Intermediates.noindex/ArchiveIntermediates/Experimental1/IntermediatePath/XCBuildData/65828f.xcbuilddata", + "", + "** ARCHIVE SUCCEEDED **" + ] + let logParser = XcodeLogParser(log: logContent) + try logParser.parse() + #expect(logParser.commandLog.count == 1) + #expect(logParser.buildCount == 2) +}