diff --git a/tools/calculate_output_groups/BUILD b/tools/calculate_output_groups/BUILD index d30a3f3be..1cf0f89bd 100644 --- a/tools/calculate_output_groups/BUILD +++ b/tools/calculate_output_groups/BUILD @@ -18,7 +18,7 @@ load( # fix that for us. macos_command_line_application( name = "calculate_output_groups", - minimum_os_version = "12.0", + minimum_os_version = "13.0", visibility = ["//visibility:public"], deps = [":calculate_output_groups.library"], ) @@ -46,7 +46,7 @@ apple_universal_binary( "x86_64", "arm64", ], - minimum_os_version = "12.0", + minimum_os_version = "13.0", platform_type = "macos", visibility = ["//visibility:public"], ) diff --git a/tools/calculate_output_groups/CalculateOutputGroups.swift b/tools/calculate_output_groups/CalculateOutputGroups.swift index faa910d9a..47f6cd179 100644 --- a/tools/calculate_output_groups/CalculateOutputGroups.swift +++ b/tools/calculate_output_groups/CalculateOutputGroups.swift @@ -1,5 +1,6 @@ import ArgumentParser import Darwin +import Foundation import ToolCommon @main diff --git a/tools/calculate_output_groups/Errors.swift b/tools/calculate_output_groups/Errors.swift new file mode 100644 index 000000000..43259eb90 --- /dev/null +++ b/tools/calculate_output_groups/Errors.swift @@ -0,0 +1,32 @@ +import ToolCommon + +extension UsageError { + static func buildMarker(_ path: String) -> Self { + .init(message: """ +error: Build marker (\(path)) doesn't exist. If you manually cleared Derived \ +Data, you need to close and re-open the project for the file to be created \ +again. Using the "Clean Build Folder" command instead (⇧ ⌘ K) won't trigger \ +this error. If this error still happens after re-opening the project, please \ +file a bug report here: \ +https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md +""") + } + + static func pifCache(_ path: String) -> Self { + .init(message: """ +error: PIFCache (\(path)) doesn't exist. If you manually cleared Derived \ +Data, you need to close and re-open the project for the PIFCache to be created \ +again. Using the "Clean Build Folder" command instead (⇧ ⌘ K) won't trigger \ +this error. If this error still happens after re-opening the project, please \ +file a bug report here: \ +https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md +""") + } + + static func buildRequest(_ path: String) -> Self { + .init(message: """ +error: Couldn't find a build-request.json file inside \(path)". Please file a bug \ +report here: https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md +""") + } +} diff --git a/tools/calculate_output_groups/Models.swift b/tools/calculate_output_groups/Models.swift new file mode 100644 index 000000000..422d88266 --- /dev/null +++ b/tools/calculate_output_groups/Models.swift @@ -0,0 +1,79 @@ +enum PIF { + struct Project: Decodable { + let targets: [String] + } + + struct Target: Decodable { + struct BuildConfiguration: Decodable { + let name: String + let buildSettings: [String: String] + } + + let guid: String + let buildConfigurations: [BuildConfiguration] + } +} + +struct BuildRequest: Decodable { + let command: String = "build" // TODO: support other commands (e.g. "buildFiles") + let configurationName: String + let configuredTargets: [String] + let platform: String + + enum Root: CodingKey { + case configuredTargets + case parameters + + enum ConfiguredTargets: CodingKey { + case guid + } + enum Parameters: CodingKey { + case activeRunDestination + case configurationName + + enum ActiveRunDestination: CodingKey { + case platform + } + } + } + + init(from decoder: Decoder) throws { + let root = try decoder.container(keyedBy: Root.self) + let parameters = try root.nestedContainer(keyedBy: Root.Parameters.self, forKey: .parameters) + + // configurationName + self.configurationName = try parameters.decode(String.self, forKey: .configurationName) + + // configuredTargets + var configuredTargets = try root.nestedUnkeyedContainer(forKey: .configuredTargets) + var decodedTargets = [String]() + while !configuredTargets.isAtEnd { + let target = try configuredTargets.nestedContainer(keyedBy: Root.ConfiguredTargets.self) + decodedTargets.append(try target.decode(String.self, forKey: .guid)) + } + self.configuredTargets = decodedTargets + + // platform + let activeRunDestination = try parameters.nestedContainer(keyedBy: Root.Parameters.ActiveRunDestination.self, forKey: .activeRunDestination) + self.platform = try activeRunDestination.decode(String.self, forKey: .platform) + } +} + +enum Output { + typealias Map = [String: Target] + + struct Target: Codable { + struct Config: Codable { + struct Settings: Codable { + let base: [String] + var platforms: [String: Optional<[String]>] + } + + let build: Settings? + let buildFiles: Settings? + } + + let label: String + let configs: [String: Config] + } +} diff --git a/tools/calculate_output_groups/OutputGroupsCalculator.swift b/tools/calculate_output_groups/OutputGroupsCalculator.swift index 04075ea00..d3ce09ef5 100644 --- a/tools/calculate_output_groups/OutputGroupsCalculator.swift +++ b/tools/calculate_output_groups/OutputGroupsCalculator.swift @@ -4,123 +4,242 @@ import ZippyJSON struct OutputGroupsCalculator { func calculateOutputGroups(arguments: Arguments) async throws { - let pifCache = arguments.baseObjRoot - .appendingPathComponent("XCBuildData/PIFCache") + let pifCache = arguments.baseObjRoot.appendingPathComponent("XCBuildData/PIFCache") let projectCache = pifCache.appendingPathComponent("project") let targetCache = pifCache.appendingPathComponent("target") - let fileManager = FileManager.default + guard let markerDate = arguments.buildMarkerFile.modificationDate else { + throw UsageError.buildMarker(arguments.buildMarkerFile.path) + } - guard fileManager.fileExists(atPath: projectCache.path) && - fileManager.fileExists(atPath: targetCache.path) + let fileManager = FileManager.default + guard + fileManager.fileExists(atPath: projectCache.path), + fileManager.fileExists(atPath: targetCache.path) else { - throw UsageError(message: """ -error: PIFCache (\(pifCache)) doesn't exist. If you manually cleared Derived \ -Data, you need to close and re-open the project for the PIFCache to be created \ -again. Using the "Clean Build Folder" command instead (⇧ ⌘ K) won't trigger \ -this error. If this error still happens after re-opening the project, please \ -file a bug report here: \ -https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md -""") + throw UsageError.pifCache(pifCache.path) + } + + async let buildRequest = loadBuildRequestFile( + inPath: arguments.baseObjRoot.appendingPathComponent("XCBuildData"), + since: markerDate + ) + async let targetMap = loadTargetMap( + fromBase: arguments.baseObjRoot, + projectCache: projectCache, + targetCache: targetCache + ) + + let output = try await outputGroups( + buildRequest: buildRequest, + targets: targetMap, + prefixes: arguments.outputGroupPrefixes + ) + print(output) + } + + private func loadBuildRequestFile(inPath path: URL, since: Date) async throws -> BuildRequest { + @Sendable func findBuildRequestURL() -> URL? { + path.newestDescendent(recursive: true, matching: { url in + guard + url.path.hasSuffix(".xcbuilddata/build-request.json"), + let date = url.modificationDate + else { return false } + return date >= since + }) + } + + if let url = findBuildRequestURL() { + return try url.decode(BuildRequest.self) + } + + // If the file was not immediately found, kick off a process to wait for the file to be created (or time out). + let findTask = Task { + while true { + try Task.checkCancellation() + try await Task.sleep(for: .seconds(1)) + if let buildRequestURL = findBuildRequestURL() { + return buildRequestURL + } + } + } + let timeoutTask = Task { + try await Task.sleep(for: .seconds(10)) + findTask.cancel() } - let projectURL = try Self.findProjectURL(in: projectCache) - let project = try Self.decodeProject(at: projectURL) - let targets = - try await Self.decodeTargets(project.targets, in: targetCache) + do { + let result = try await findTask.value + timeoutTask.cancel() + return try result.decode(BuildRequest.self) + } catch { + throw UsageError.buildRequest(path.path) + } + } - dump(targets) + private func loadTargetMap( + fromBase baseObjRoot: URL, + projectCache: URL, + targetCache: URL + ) async throws -> Output.Map { + let projectURL = try findProjectURL(in: projectCache) + let guidPayloadDir = baseObjRoot.appendingPathComponent("guid_payload") + try FileManager.default.createDirectory(at: guidPayloadDir, withIntermediateDirectories: true) + let guidPayloadFile = guidPayloadDir.appendingPathComponent(projectURL.lastPathComponent+"_v3.json") + let targets: Output.Map + do { + targets = try guidPayloadFile.decode(Output.Map.self) + } catch { + let project = try projectURL.decode(PIF.Project.self) + targets = try await decodeTargets(project.targets, in: targetCache) + let data = try JSONEncoder().encode(targets) + try data.write(to: guidPayloadFile) + } + return targets } - static func findProjectURL(in projectCache: URL) throws -> URL { - let projectPIFsEnumerator = FileManager.default.enumerator( - at: projectCache, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [ - .skipsHiddenFiles, - .skipsPackageDescendants, - .skipsSubdirectoryDescendants, - ] - )! - - var newestProjectPIF: URL? - var newestProjectPIFDate = Date.distantPast - for case let projectPIF as URL in projectPIFsEnumerator { - guard let resourceValues = try? projectPIF.resourceValues( - forKeys: [.contentModificationDateKey] - ), let modificationDate = resourceValues.contentModificationDate - else { - continue + private func outputGroups( + buildRequest: BuildRequest, + targets: Output.Map, + prefixes: [String] + ) throws -> String { + var lines: [String] = [] + + for guid in buildRequest.configuredTargets { + guard + let target = targets[guid], + let config = target.configs[buildRequest.configurationName] + else { continue } + + var settings: Output.Target.Config.Settings? + switch buildRequest.command { + case "build": + settings = config.build + case "buildFiles": + settings = config.buildFiles + default: + break + } + guard let settings else { + throw PreconditionError(message: "Settings not found for target/command combination: \(guid) / \(buildRequest.command)") } - // TODO: The modification date is in the filename, should we use - // that instead? - if modificationDate > newestProjectPIFDate { - newestProjectPIF = projectPIF - newestProjectPIFDate = modificationDate + for platform in allPlatformsToSearch(buildRequest.platform) { + guard let platform = settings.platforms[platform] else { continue } + for prefix in prefixes { + for id in platform ?? settings.base { + lines.append("\(target.label)\n\(prefix) \(id)") + } + } } } - guard let projectPIF = newestProjectPIF else { - throw UsageError(message: """ -error: Couldn't find a Project PIF at "\(projectCache)". Please file a bug \ -report here: https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md -""") - } + return lines.joined(separator: "\n") + } - return projectPIF + // MARK: Helpers + + private func allPlatformsToSearch(_ platform: String) -> [String] { + if platform == "macosx" || platform.contains("simulator") { + return ["iphonesimulator", "appletvsimulator", "watchsimulator", "macosx"] + } else { + return ["iphoneos", "appletvos", "watchos", "macosx"] + } } - static func decodeProject(at url: URL) throws -> ProjectPIF { - let decoder = ZippyJSONDecoder() - return try decoder.decode(ProjectPIF.self, from: Data(contentsOf: url)) + private func findProjectURL(in projectCache: URL) throws -> URL { + guard let projectPIF = projectCache.newestDescendent() else { + throw UsageError.pifCache(projectCache.path) + } + return projectPIF } - static func decodeTargets( + private func decodeTargets( _ targets: [String], in targetCache: URL - ) async throws -> [TargetPIF] { - return try await withThrowingTaskGroup( - of: TargetPIF.self, - returning: [TargetPIF].self + ) async throws -> Output.Map { + try await withThrowingTaskGroup( + of: PIF.Target.self, + returning: Output.Map.self ) { group in + let decoder = ZippyJSONDecoder() for target in targets { group.addTask { - let url = - targetCache.appendingPathComponent("\(target)-json") - let decoder = ZippyJSONDecoder() - return try decoder - .decode(TargetPIF.self, from: Data(contentsOf: url)) + let url = targetCache.appendingPathComponent("\(target)-json") + return try decoder.decode(PIF.Target.self, from: Data(contentsOf: url)) } } - - var targetPIFs: [TargetPIF] = [] - for try await target in group { - targetPIFs.append(target) + return try await group.reduce(into: Output.Map()) { map, target in + map[target.guid] = target.output } - - return targetPIFs } } } -struct ProjectPIF: Decodable { - let targets: [String] +extension PIF.Target { + var output: Output.Target? { + guard let label = buildConfigurations.lazy.compactMap(\.label).first else { return nil } + return .init( + label: label, + configs: Dictionary(uniqueKeysWithValues: zip( + buildConfigurations.map(\.name), + buildConfigurations.map(\.output) + )) + ) + } } - -struct TargetPIF: Decodable { - struct BuildConfiguration: Decodable { - let name: String - let buildSettings: [String: String] +extension PIF.Target.BuildConfiguration { + var label: String? { + buildSettings["BAZEL_LABEL"] } - let guid: String - let buildConfigurations: [BuildConfiguration] -} + var output: Output.Target.Config { + var build: Output.Target.Config.Settings? + if let value = buildSettings["BAZEL_TARGET_ID"] { + build = .init(base: [value], platforms: [:]) + } + var buildFiles: Output.Target.Config.Settings? + if let value = buildSettings["BAZEL_COMPILE_TARGET_IDS"] { + buildFiles = .init(base: compileTargetIds(value), platforms: [:]) + } + if build != nil || buildFiles != nil { + for (key, value) in buildSettings { + if build != nil, key.starts(with: "BAZEL_TARGET_ID[sdk=") { + let platform = String(key.dropFirst(20).dropLast(2)) + if value == "$(BAZEL_TARGET_ID)" { + // This value indicates that the provided platform inherits from the base build setting. Store nil for later processing. + build?.platforms[platform] = nil + } else { + build?.platforms[platform] = [value] + } + } + if buildFiles != nil, key.starts(with: "BAZEL_COMPILE_TARGET_IDS[sdk=") { + let platform = String(key.dropFirst(29).dropLast(2)) + if value == "$(BAZEL_COMPILE_TARGET_IDS)" { + // This value indicates that the provided platform inherits from the base build setting. Store nil for later processing. + buildFiles?.platforms[platform] = nil + } else { + buildFiles?.platforms[platform] = compileTargetIds(value) + } + } + } + } -struct Target { - let label: String + return .init(build: build, buildFiles: buildFiles) + } - // Maps Platform Name -> [Target ID] - let targetIds: [String: [String]] + private func compileTargetIds(_ value: String) -> [String] { + var seenSpace = false + // value is a space-separated list of space-separated pairs. split into an array of pairs. + return value.split(whereSeparator: { + guard $0 == " " else { return false } + if seenSpace { + seenSpace = false + return true + } else { + seenSpace = true + } + return false + }).map(String.init) + } } diff --git a/tools/calculate_output_groups/URL+Extensions.swift b/tools/calculate_output_groups/URL+Extensions.swift new file mode 100644 index 000000000..cd35229e5 --- /dev/null +++ b/tools/calculate_output_groups/URL+Extensions.swift @@ -0,0 +1,30 @@ +import Foundation +import ZippyJSON + +extension URL { + func decode(_ type: T.Type) throws -> T { + try ZippyJSONDecoder().decode(T.self, from: Data(contentsOf: self)) + } + + var modificationDate: Date? { + try? resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + } + + func newestDescendent(recursive: Bool = false, matching: (URL)->Bool={ _ in true }) -> URL? { + let options: FileManager.DirectoryEnumerationOptions = recursive ? [] : [.skipsPackageDescendants, .skipsSubdirectoryDescendants] + let enumerator = FileManager.default.enumerator( + at: self, + includingPropertiesForKeys: [.contentModificationDateKey], + options: options.union(.skipsHiddenFiles) + )! + + return enumerator.compactMap({ $0 as? URL }).filter(matching).max { + guard + let first = try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, + let second = try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + else { return false } + + return first < second + } + } +}