From 5203548d3d06adeb674b2f2c95381fe4f0bdf201 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 11 Apr 2026 15:17:46 +0800 Subject: [PATCH 001/173] Add xcode2 target summary output --- Package.swift | 14 + Sources/Bazelize/Command.swift | 57 +- Sources/Bazelize/Entry.swift | 8 - Sources/XCode/Setting/DeviceFamily.swift | 7 - .../Xcode2/Loader/XCode+ProjectLoader.swift | 691 ++++++++++++++++++ Sources/Xcode2/Model/File/XCode+File.swift | 13 + Sources/Xcode2/Model/File/XCode+Files.swift | 10 + .../Xcode2/Model/Phase/XCode+BuildPhase.swift | 13 + .../Model/Phase/XCode+BuildPhaseFile.swift | 9 + .../Phase/XCode+CopyFilesDestination.swift | 7 + .../Xcode2/Model/Project/XCode+Project.swift | 17 + .../Model/SwiftPM/XCode+LocalPackage.swift | 6 + .../XCode+PackageProductDependency.swift | 6 + .../Xcode2/Model/SwiftPM/XCode+Packages.swift | 6 + .../Model/SwiftPM/XCode+RemotePackage.swift | 7 + .../Xcode2/Model/Target/XCode+CodeSign.swift | 7 + .../Model/Target/XCode+Dependencies.swift | 8 + .../Xcode2/Model/Target/XCode+Target.swift | 12 + .../Model/Target/XCode+TargetMetadata.swift | 9 + Sources/Xcode2/Support/XCode+JSONValue.swift | 59 ++ Sources/Xcode2/TargetSummaryFormatter.swift | 125 ++++ Sources/Xcode2/XCode.swift | 1 + .../TargetSummaryFormatterTests.swift | 96 +++ 23 files changed, 1172 insertions(+), 16 deletions(-) delete mode 100644 Sources/Bazelize/Entry.swift create mode 100644 Sources/Xcode2/Loader/XCode+ProjectLoader.swift create mode 100644 Sources/Xcode2/Model/File/XCode+File.swift create mode 100644 Sources/Xcode2/Model/File/XCode+Files.swift create mode 100644 Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift create mode 100644 Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift create mode 100644 Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift create mode 100644 Sources/Xcode2/Model/Project/XCode+Project.swift create mode 100644 Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift create mode 100644 Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift create mode 100644 Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift create mode 100644 Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift create mode 100644 Sources/Xcode2/Model/Target/XCode+CodeSign.swift create mode 100644 Sources/Xcode2/Model/Target/XCode+Dependencies.swift create mode 100644 Sources/Xcode2/Model/Target/XCode+Target.swift create mode 100644 Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift create mode 100644 Sources/Xcode2/Support/XCode+JSONValue.swift create mode 100644 Sources/Xcode2/TargetSummaryFormatter.swift create mode 100644 Sources/Xcode2/XCode.swift create mode 100644 Tests/XCode2Tests/TargetSummaryFormatterTests.swift diff --git a/Package.swift b/Package.swift index 78da1a8..a12c112 100644 --- a/Package.swift +++ b/Package.swift @@ -39,6 +39,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), "PathKit", "BazelizeKit", + "XCode2", ]), .target( @@ -83,6 +84,19 @@ let package = Package( .product(name: "XcodeProj", package: "XcodeProj"), .product(name: "SwiftPMDataModel-auto", package: "swift-package-manager"), ]), + .target( + name: "XCode2", + dependencies: [ + "PathKit", + "AnyCodable", + + .product(name: "XcodeProj", package: "XcodeProj"), + ], + path: "Sources/XCode2"), + .testTarget( + name: "XCode2Tests", + dependencies: ["XCode2"] + ), .testTarget( name: "XCodeTests", dependencies: ["XCode"] diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index f379aeb..0acbe24 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -9,12 +9,27 @@ import ArgumentParser import BazelizeKit import Foundation import PathKit +import XCode2 +@main struct Command: AsyncParsableCommand { static var configuration = CommandConfiguration( commandName: "bazelize", abstract: "A cli tool turn your xcode project to bazel.", - version: version) + version: version, + subcommands: [ + GenerateCommand.self, + XCode2Command.self, + ], + defaultSubcommand: GenerateCommand.self + ) +} + +struct GenerateCommand: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "generate", + abstract: "Generate Bazel files from an Xcode project." + ) @Option(name: [.customLong("project", withSingleDash: false)], help: "PATH/TO/YOUR.xcodeproj") var project: String @@ -47,3 +62,43 @@ struct Command: AsyncParsableCommand { } } } + +struct XCode2Command: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "xcode2", + abstract: "Dump an Xcode project structure as JSON or print one target summary." + ) + + @Option(name: [.customLong("project", withSingleDash: false)], help: "PATH/TO/YOUR.xcodeproj") + var project: String + + @Option(name: [.short], help: "Preferred config name used by project parsing") + var config: String? + + @Option(name: [.customLong("print-target", withSingleDash: false)], help: "Print a human-readable summary for a single target") + var printTarget: String? + + func run() async throws { + let path = Path.current + project + let dump = try XCode.Project.load(path: path, preferConfig: config) + + if let printTarget { + guard let target = dump.targets.first(where: { $0.name == printTarget }) else { + throw ValidationError("Target '\(printTarget)' not found.") + } + + print(XCode.TargetSummaryFormatter.format(project: dump, target: target)) + return + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let data = try encoder.encode(dump) + guard let json = String(data: data, encoding: .utf8) else { + throw ValidationError("Failed to encode JSON output.") + } + + print(json) + } +} diff --git a/Sources/Bazelize/Entry.swift b/Sources/Bazelize/Entry.swift deleted file mode 100644 index e431c93..0000000 --- a/Sources/Bazelize/Entry.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -@main -enum Main { - static func main() async throws { - await Command.main() - } -} diff --git a/Sources/XCode/Setting/DeviceFamily.swift b/Sources/XCode/Setting/DeviceFamily.swift index d402cb2..ef11214 100644 --- a/Sources/XCode/Setting/DeviceFamily.swift +++ b/Sources/XCode/Setting/DeviceFamily.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Yume on 2022/7/1. -// - import Foundation // MARK: - SupportedPlatform diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift new file mode 100644 index 0000000..84c0fd7 --- /dev/null +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -0,0 +1,691 @@ +// +// XCode+ProjectLoader.swift +// +// +// Created by Yume on 2026/3/29. +// + +import Foundation +import PathKit +import XcodeProj + +final class ProjectLoader { + private let xcodeProj: XcodeProj + private let native: PBXProj + private let path: Path + fileprivate let preferConfig: String? + + init(path: Path, preferConfig: String?) throws { + self.path = path + self.preferConfig = preferConfig + xcodeProj = try XcodeProj(path: path) + native = xcodeProj.pbxproj + } + + var rootProject: PBXProject? { + native.rootObject + } + + var workspacePath: Path { + path.parent() + } + + func model() throws -> XCode.Project { + XCode.Project( + name: rootProject?.name ?? path.lastComponentWithoutExtension, + workspacePath: workspacePath.string, + projectPath: path.string, + preferConfig: preferConfig, + configs: defaultConfigList?.configs ?? [:], + packages: .init( + remote: remotePackages, + local: localPackages + ), + targets: targets.map(\.model) + ) + } + + private lazy var allFiles: [PBXFileElement] = { + (try? native.rootGroup()?.flatten()) ?? [] + }() + + private lazy var targets: [TargetLoader] = { + native.nativeTargets.map { + TargetLoader( + native: $0, + project: self, + defaultConfigList: defaultConfigList + ) + } + }() + + private lazy var defaultConfigList: ConfigListLoader? = { + let all = Set(native.configurationLists.map { ConfigListLoader(native: $0) }) + let targetLists = native.nativeTargets.map { + ConfigListLoader(native: $0.buildConfigurationList) + } + + return all.subtracting(targetLists).first + }() + + private var remotePackages: [XCode.RemotePackage] { + (rootProject?.remotePackages ?? []).map { package in + .init( + name: package.name, + repositoryURL: package.repositoryURL, + requirement: package.versionRequirement?.stringValue + ) + } + } + + private var localPackages: [XCode.LocalPackage] { + (rootProject?.localPackages ?? []).map { package in + .init( + name: package.name, + relativePath: package.relativePath + ) + } + } + + fileprivate func packageFiles(targetName: String) -> [FileLoader] { + allFiles + .compactMap { FileLoader(native: $0, project: self) } + .filter { file in + file.packageName == targetName + } + } + + func transformToLabel(_ relativePath: String?) -> String? { + guard let path = relativePath else { return nil } + + let commentedLabel = "# \(path)" + guard let package = path.split(separator: "/").first.map(String.init) else { + return commentedLabel + } + guard let restPath = path.delete(prefix: package + "/") else { + return commentedLabel + } + + if targets.map(\.name).contains(package) { + return "//\(package):\(restPath)" + } else { + return "//:\(package)/\(restPath)" + } + } +} + +private struct TargetLoader { + let native: PBXNativeTarget + unowned let project: ProjectLoader + let configList: ConfigListLoader + let mergedConfig: [String: [String: XCode.JSONValue]] + + init(native: PBXNativeTarget, project: ProjectLoader, defaultConfigList: ConfigListLoader?) { + self.native = native + self.project = project + configList = ConfigListLoader(native: native.buildConfigurationList) + mergedConfig = configList.merge(defaultConfigList) + } + + var name: String { native.name } + + var model: XCode.Target { + let buildPhases = native.buildPhases.map(XCode.BuildPhase.init) + let synchronizedFiles = synchronizedGroupFiles + + let sourceFiles = unique( + fileModels(from: sourceBuildFiles, buildPhase: .sources) + + synchronizedFiles.filter { file in + file.category == .source + }.map(\.file) + ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let headerFiles = unique( + fileModels(from: headerBuildFiles, buildPhase: .headers) + + packageHeaders + + synchronizedFiles.filter { file in + file.category == .header + }.map(\.file) + ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let resourceFiles = unique( + fileModels(from: resourceBuildFiles, buildPhase: .resources) + + synchronizedFiles.filter { file in + file.category == .resource + }.map(\.file) + ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let frameworkFiles = fileModels(from: frameworkBuildFiles, buildPhase: .frameworks) + let copyFiles = fileModels(from: copyBuildFiles, buildPhase: .copyFiles) + + let knownPaths = Set( + (sourceFiles + headerFiles + resourceFiles + frameworkFiles + copyFiles) + .compactMap(\.path) + ) + + let otherFiles = project.packageFiles(targetName: name) + .filter { file in + guard let path = file.relativePath else { return false } + return !knownPaths.contains(path) + } + .map { $0.file(buildPhase: nil, compilerFlags: nil, attributes: []) } + + synchronizedFiles.filter { file in + file.category == .other && !knownPaths.contains(file.file.path ?? "") + }.map(\.file) + + return XCode.Target( + name: name, + productName: native.productName, + productType: native.productType?.rawValue, + configs: mergedConfig, + metadata: metadata, + buildPhases: buildPhases, + files: .init( + sources: sourceFiles, + headers: headerFiles, + resources: resourceFiles, + frameworks: frameworkFiles, + copyFiles: copyFiles, + others: unique(otherFiles) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + ), + dependencies: dependencies + ) + } + + private var metadata: XCode.TargetMetadata { + let settings = selectedConfig ?? [:] + + return .init( + bundleID: settings["PRODUCT_BUNDLE_IDENTIFIER"]?.stringValue, + moduleName: settings["PRODUCT_MODULE_NAME"]?.stringValue ?? settings["PRODUCT_NAME"]?.stringValue, + infoPlist: settings["INFOPLIST_FILE"]?.stringValue, + deploymentTargets: deploymentTargets(from: settings), + codeSign: .init( + developmentTeam: settings["DEVELOPMENT_TEAM"]?.stringValue, + codeSignStyle: settings["CODE_SIGN_STYLE"]?.stringValue, + codeSignIdentity: settings["CODE_SIGN_IDENTITY"]?.stringValue + ) + ) + } + + private var dependencies: XCode.Dependencies { + let frameworkNames = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard !wrapped.isSDKFramework else { return nil } + return wrapped.name + } + + let sdkFrameworks = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.isSDKFramework else { return nil } + return wrapped.frameworkName + } + + let packageProducts = (native.packageProductDependencies ?? []).map { dependency in + XCode.PackageProductDependency( + productName: dependency.productName, + package: dependency.package?.repositoryURL + ) + } + + let targetDependencies = native.dependencies.compactMap { dependency in + dependency.target?.name ?? dependency.name + } + + return .init( + targets: Set(targetDependencies).sorted(), + packageProducts: unique(packageProducts) { "\($0.productName)|\($0.package ?? "")" }, + frameworks: Set(frameworkNames.compactMap { $0 }).sorted(), + sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted() + ) + } + + private var selectedConfig: [String: XCode.JSONValue]? { + if let prefer = project.preferConfig, let hit = mergedConfig[prefer] { + return hit + } + return mergedConfig + .sorted { $0.key < $1.key } + .map(\.value) + .first + } + + private var packageHeaders: [XCode.File] { + project.packageFiles(targetName: name) + .filter { file in + guard let type = file.fileType else { return false } + return type == "sourcecode.c.h" || type == "sourcecode.cpp.h" + } + .map { $0.file(buildPhase: BuildPhase.headers.rawValue, compilerFlags: nil, attributes: []) } + } + + private var sourceBuildFiles: [PBXBuildFile] { + (try? native.sourcesBuildPhase()?.files) ?? [] + } + + private var headerBuildFiles: [PBXBuildFile] { + native.buildPhases + .compactMap { $0 as? PBXHeadersBuildPhase } + .compactMap(\.files) + .flatMap { $0 } + } + + private var resourceBuildFiles: [PBXBuildFile] { + (try? native.resourcesBuildPhase()?.files) ?? [] + } + + private var frameworkBuildFiles: [PBXBuildFile] { + (try? native.frameworksBuildPhase()?.files) ?? [] + } + + private var copyBuildFiles: [PBXBuildFile] { + native.buildPhases + .compactMap { $0 as? PBXCopyFilesBuildPhase } + .compactMap(\.files) + .flatMap { $0 } + } + + private var synchronizedGroupFiles: [SynchronizedFile] { + (native.fileSystemSynchronizedGroups ?? []).flatMap { group in + synchronizedFiles(in: group) + } + } + + private func synchronizedFiles(in group: PBXFileSystemSynchronizedRootGroup) -> [SynchronizedFile] { + guard let relativeRoot = group.path else { return [] } + let root = project.workspacePath + relativeRoot + guard root.exists else { return [] } + + let excluded = synchronizedExcludedPaths(group) + let compilerFlags = synchronizedCompilerFlags(group) + + return (try? root.recursiveChildren())? + .filter(\.isFile) + .compactMap { file in + let relative = file.string.delete(prefix: project.workspacePath.string + "/") + guard let relative else { return nil } + + let pathInGroup = relative.delete(prefix: relativeRoot + "/") ?? "" + guard !excluded.contains(pathInGroup), !excluded.contains(relative) else { + return nil + } + + return SynchronizedFile( + path: relative, + fullPath: file.string, + compilerFlags: compilerFlags[pathInGroup] ?? compilerFlags[relative] + ) + } ?? [] + } + + private func synchronizedExcludedPaths(_ group: PBXFileSystemSynchronizedRootGroup) -> Set { + let buildExceptions = (group.exceptions ?? []).compactMap { + $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet + }.filter { exception in + exception.target?.name == name + } + + let membershipExceptions = buildExceptions + .compactMap(\.membershipExceptions) + .flatMap { $0 } + + return Set(membershipExceptions) + } + + private func synchronizedCompilerFlags(_ group: PBXFileSystemSynchronizedRootGroup) -> [String: String] { + let buildExceptions = (group.exceptions ?? []).compactMap { + $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet + }.filter { exception in + exception.target?.name == name + } + + return buildExceptions + .compactMap(\.additionalCompilerFlagsByRelativePath) + .reduce(into: [:]) { result, next in + result.merge(next) { first, _ in first } + } + } + + private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [XCode.File] { + buildFiles.compactMap { buildFile in + guard let file = buildFile.file else { return nil } + return FileLoader(native: file, project: project).file( + buildPhase: buildPhase.rawValue, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? [] + ) + } + } + + private func deploymentTargets(from settings: [String: XCode.JSONValue]) -> [String: String] { + [ + "iOS": settings["IPHONEOS_DEPLOYMENT_TARGET"]?.stringValue, + "macOS": settings["MACOSX_DEPLOYMENT_TARGET"]?.stringValue, + "tvOS": settings["TVOS_DEPLOYMENT_TARGET"]?.stringValue, + "watchOS": settings["WATCHOS_DEPLOYMENT_TARGET"]?.stringValue, + "driverKit": settings["DRIVERKIT_DEPLOYMENT_TARGET"]?.stringValue, + ].compactMapValues { $0 } + } +} + +private struct ConfigListLoader: Hashable { + let native: XCConfigurationList? + + var configs: [String: [String: XCode.JSONValue]] { + (native?.buildConfigurations ?? []).map { config in + ( + config.name, + config.buildSettings.mapValues(XCode.JSONValue.normalize) + ) + }.toDictionary() + } + + func merge(_ defaultConfig: ConfigListLoader?) -> [String: [String: XCode.JSONValue]] { + guard let defaultConfig else { + return configs + } + + let defaults = defaultConfig.configs + return configs.map { name, current in + let merged = current.merging(defaults[name] ?? [:]) { first, _ in + first + } + return ( + name, + merged + ) + }.toDictionary() + } + + static func == (lhs: ConfigListLoader, rhs: ConfigListLoader) -> Bool { + lhs.native?.uuid == rhs.native?.uuid + } + + func hash(into hasher: inout Hasher) { + hasher.combine(native?.uuid) + } +} + +private struct FileLoader { + let native: PBXFileElement + unowned let project: ProjectLoader + + var name: String? { + native.name ?? native.path + } + + var label: String? { + project.transformToLabel(relativePath) + } + + var packageName: String? { + relativePath?.split(separator: "/").first.map(String.init) + } + + var relativePath: String? { + let root = project.workspacePath.string + guard let fullPath else { return nil } + guard fullPath.hasPrefix(root + "/") else { return nil } + return fullPath.delete(prefix: root + "/") + } + + var fullPath: String? { + try? native.fullPath(sourceRoot: project.workspacePath.string) + } + + var fileType: String? { + ref?.lastKnownFileType ?? ref?.explicitFileType + } + + var sourceTree: String { + native.sourceTree?.description ?? "" + } + + var frameworkName: String? { + name?.replacingOccurrences(of: ".framework", with: "") + .replacingOccurrences(of: ".xcframework", with: "") + } + + var isSDKFramework: Bool { + sourceTree == PBXSourceTree.sdkRoot.description || + sourceTree == PBXSourceTree.developerDir.description + } + + private var ref: PBXFileReference? { + native as? PBXFileReference + } + + func file(buildPhase: String?, compilerFlags: String?, attributes: [String]) -> XCode.File { + .init( + name: name, + path: relativePath ?? native.path, + fullPath: fullPath, + label: label, + fileType: fileType, + sourceTree: sourceTree, + buildPhase: buildPhase, + compilerFlags: compilerFlags, + attributes: attributes + ) + } +} + +private struct SynchronizedFile { + enum Category { + case source + case header + case resource + case other + } + + let path: String + let fullPath: String + let compilerFlags: String? + + var name: String { + Path(path).lastComponent + } + + var fileType: String? { + switch Path(path).extension?.lowercased() { + case "swift": return "sourcecode.swift" + case "m": return "sourcecode.c.objc" + case "mm": return "sourcecode.cpp.objcpp" + case "c": return "sourcecode.c.c" + case "cc", "cp", "cpp", "cxx": return "sourcecode.cpp.cpp" + case "h": return "sourcecode.c.h" + case "hh", "hpp", "hxx": return "sourcecode.cpp.h" + case "metal": return "sourcecode.metal" + case "xib": return "file.xib" + case "storyboard": return "file.storyboard" + case "xcassets": return "folder.assetcatalog" + case "strings": return "text.plist.strings" + case "stringsdict": return "text.plist.stringsdict" + case "plist": return "text.plist.xml" + case "xcframework": return "wrapper.xcframework" + case "framework": return "wrapper.framework" + default: return nil + } + } + + var category: Category { + switch fileType { + case "sourcecode.swift", + "sourcecode.c.objc", + "sourcecode.cpp.objcpp", + "sourcecode.c.c", + "sourcecode.cpp.cpp", + "sourcecode.metal": + return .source + case "sourcecode.c.h", + "sourcecode.cpp.h": + return .header + case "file.xib", + "file.storyboard", + "folder.assetcatalog", + "text.plist.strings", + "text.plist.stringsdict", + "text.plist.xml": + return .resource + default: + return .other + } + } + + var file: XCode.File { + .init( + name: name, + path: path, + fullPath: fullPath, + label: nil, + fileType: fileType, + sourceTree: "", + buildPhase: buildPhase, + compilerFlags: compilerFlags, + attributes: [] + ) + } + + private var buildPhase: String? { + switch category { + case .source: return BuildPhase.sources.rawValue + case .header: return BuildPhase.headers.rawValue + case .resource: return BuildPhase.resources.rawValue + case .other: return nil + } + } +} + +private extension XCode.BuildPhase { + init(phase: PBXBuildPhase) { + let destination: XCode.CopyFilesDestination? + if let copyPhase = phase as? PBXCopyFilesBuildPhase { + destination = .init( + path: copyPhase.dstPath, + subfolder: copyPhase.dstSubfolder?.rawValue, + subfolderSpec: copyPhase.dstSubfolderSpec?.rawValue + ) + } else { + destination = nil + } + + self.init( + type: phase.buildPhase.rawValue, + name: phase.name(), + files: (phase.files ?? []).compactMap { buildFile in + XCode.BuildPhaseFile( + name: (buildFile.file as? PBXFileReference)?.name ?? + (buildFile.file as? PBXFileReference)?.path ?? + buildFile.product?.productName, + path: buildFile.file?.path, + fileType: (buildFile.file as? PBXFileReference)?.lastKnownFileType, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? [] + ) + }, + inputPaths: (phase as? PBXShellScriptBuildPhase)?.inputPaths ?? [], + outputPaths: (phase as? PBXShellScriptBuildPhase)?.outputPaths ?? [], + inputFileListPaths: phase.inputFileListPaths ?? [], + outputFileListPaths: phase.outputFileListPaths ?? [], + shellScript: (phase as? PBXShellScriptBuildPhase)?.shellScript, + destination: destination + ) + } +} + +private extension XCRemoteSwiftPackageReference.VersionRequirement { + var stringValue: String { + switch self { + case .upToNextMajorVersion(let version): + return "upToNextMajorVersion(\(version))" + case .upToNextMinorVersion(let version): + return "upToNextMinorVersion(\(version))" + case .range(let from, let to): + return "range(\(from)...\(to))" + case .exact(let version): + return "exact(\(version))" + case .branch(let branch): + return "branch(\(branch))" + case .revision(let revision): + return "revision(\(revision))" + } + } +} + +private extension XCode.JSONValue { + var stringValue: String? { + if case let .string(value) = self { + return value + } + return nil + } + + static func normalize(_ input: Any) -> Self { + switch input { + case let value as Self: + return value + case let value as String: + return .string(value) + case let value as Bool: + return .bool(value) + case let value as Int: + return .int(value) + case let value as Double: + return .double(value) + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + return .bool(value.boolValue) + } + if floor(value.doubleValue) == value.doubleValue { + return .int(value.intValue) + } + return .double(value.doubleValue) + case let value as [String: Any]: + return .object(value.mapValues(Self.normalize)) + case let value as [Any]: + return .array(value.map(Self.normalize)) + default: + return .string(String(describing: input)) + } + } +} + +private extension PBXFileElement { + func flatten() throws -> [PBXFileElement] { + if let group = self as? PBXGroup { + return group.children.flatMap { (try? $0.flatten()) ?? [] } + } + + if let ref = self as? PBXFileReference { + return [ref] + } + + return [] + } +} + +private extension Sequence { + func toDictionary() -> [K: V] where Element == (K, V) { + Dictionary(uniqueKeysWithValues: self) + } +} + +private func unique(_ values: [T], key: (T) -> String) -> [T] { + var result: [T] = [] + var seen = Set() + + for value in values { + let id = key(value) + if seen.insert(id).inserted { + result.append(value) + } + } + + return result +} + +private extension String { + func delete(prefix: String) -> String? { + guard hasPrefix(prefix) else { return nil } + return String(dropFirst(prefix.count)) + } +} diff --git a/Sources/Xcode2/Model/File/XCode+File.swift b/Sources/Xcode2/Model/File/XCode+File.swift new file mode 100644 index 0000000..0e2894c --- /dev/null +++ b/Sources/Xcode2/Model/File/XCode+File.swift @@ -0,0 +1,13 @@ +public extension XCode { + struct File: Codable { + public let name: String? + public let path: String? + public let fullPath: String? + public let label: String? + public let fileType: String? + public let sourceTree: String + public let buildPhase: String? + public let compilerFlags: String? + public let attributes: [String] + } +} diff --git a/Sources/Xcode2/Model/File/XCode+Files.swift b/Sources/Xcode2/Model/File/XCode+Files.swift new file mode 100644 index 0000000..3d91806 --- /dev/null +++ b/Sources/Xcode2/Model/File/XCode+Files.swift @@ -0,0 +1,10 @@ +public extension XCode { + struct Files: Codable { + public let sources: [File] + public let headers: [File] + public let resources: [File] + public let frameworks: [File] + public let copyFiles: [File] + public let others: [File] + } +} diff --git a/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift b/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift new file mode 100644 index 0000000..1850f13 --- /dev/null +++ b/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift @@ -0,0 +1,13 @@ +public extension XCode { + struct BuildPhase: Codable { + public let type: String + public let name: String? + public let files: [BuildPhaseFile] + public let inputPaths: [String] + public let outputPaths: [String] + public let inputFileListPaths: [String] + public let outputFileListPaths: [String] + public let shellScript: String? + public let destination: CopyFilesDestination? + } +} diff --git a/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift b/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift new file mode 100644 index 0000000..fef4ce4 --- /dev/null +++ b/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift @@ -0,0 +1,9 @@ +public extension XCode { + struct BuildPhaseFile: Codable { + public let name: String? + public let path: String? + public let fileType: String? + public let compilerFlags: String? + public let attributes: [String] + } +} diff --git a/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift b/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift new file mode 100644 index 0000000..97affed --- /dev/null +++ b/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift @@ -0,0 +1,7 @@ +public extension XCode { + struct CopyFilesDestination: Codable { + public let path: String? + public let subfolder: String? + public let subfolderSpec: UInt? + } +} diff --git a/Sources/Xcode2/Model/Project/XCode+Project.swift b/Sources/Xcode2/Model/Project/XCode+Project.swift new file mode 100644 index 0000000..de70798 --- /dev/null +++ b/Sources/Xcode2/Model/Project/XCode+Project.swift @@ -0,0 +1,17 @@ +import PathKit + +public extension XCode { + struct Project: Codable { + public let name: String + public let workspacePath: String + public let projectPath: String + public let preferConfig: String? + public let configs: [String: [String: JSONValue]] + public let packages: Packages + public let targets: [Target] + + public static func load(path: Path, preferConfig: String?) throws -> Self { + try ProjectLoader(path: path, preferConfig: preferConfig).model() + } + } +} diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift b/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift new file mode 100644 index 0000000..c30c438 --- /dev/null +++ b/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift @@ -0,0 +1,6 @@ +public extension XCode { + struct LocalPackage: Codable { + public let name: String? + public let relativePath: String + } +} diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift b/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift new file mode 100644 index 0000000..ddf4c1c --- /dev/null +++ b/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift @@ -0,0 +1,6 @@ +public extension XCode { + struct PackageProductDependency: Codable { + public let productName: String + public let package: String? + } +} diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift b/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift new file mode 100644 index 0000000..73fd210 --- /dev/null +++ b/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift @@ -0,0 +1,6 @@ +public extension XCode { + struct Packages: Codable { + public let remote: [RemotePackage] + public let local: [LocalPackage] + } +} diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift b/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift new file mode 100644 index 0000000..e2d0f0e --- /dev/null +++ b/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift @@ -0,0 +1,7 @@ +public extension XCode { + struct RemotePackage: Codable { + public let name: String? + public let repositoryURL: String? + public let requirement: String? + } +} diff --git a/Sources/Xcode2/Model/Target/XCode+CodeSign.swift b/Sources/Xcode2/Model/Target/XCode+CodeSign.swift new file mode 100644 index 0000000..38583fc --- /dev/null +++ b/Sources/Xcode2/Model/Target/XCode+CodeSign.swift @@ -0,0 +1,7 @@ +public extension XCode { + struct CodeSign: Codable { + public let developmentTeam: String? + public let codeSignStyle: String? + public let codeSignIdentity: String? + } +} diff --git a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift new file mode 100644 index 0000000..e18c68e --- /dev/null +++ b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift @@ -0,0 +1,8 @@ +public extension XCode { + struct Dependencies: Codable { + public let targets: [String] + public let packageProducts: [PackageProductDependency] + public let frameworks: [String] + public let sdkFrameworks: [String] + } +} diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift new file mode 100644 index 0000000..6a32224 --- /dev/null +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -0,0 +1,12 @@ +public extension XCode { + struct Target: Codable { + public let name: String + public let productName: String? + public let productType: String? + public let configs: [String: [String: JSONValue]] + public let metadata: TargetMetadata + public let buildPhases: [BuildPhase] + public let files: Files + public let dependencies: Dependencies + } +} diff --git a/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift b/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift new file mode 100644 index 0000000..4378cd8 --- /dev/null +++ b/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift @@ -0,0 +1,9 @@ +public extension XCode { + struct TargetMetadata: Codable { + public let bundleID: String? + public let moduleName: String? + public let infoPlist: String? + public let deploymentTargets: [String: String] + public let codeSign: CodeSign + } +} diff --git a/Sources/Xcode2/Support/XCode+JSONValue.swift b/Sources/Xcode2/Support/XCode+JSONValue.swift new file mode 100644 index 0000000..bff800a --- /dev/null +++ b/Sources/Xcode2/Support/XCode+JSONValue.swift @@ -0,0 +1,59 @@ +import Foundation + +public extension XCode { + enum JSONValue: Codable { + case string(String) + case bool(Bool) + case int(Int) + case double(Double) + case array([JSONValue]) + case object([String: JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .string(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + } +} diff --git a/Sources/Xcode2/TargetSummaryFormatter.swift b/Sources/Xcode2/TargetSummaryFormatter.swift new file mode 100644 index 0000000..e1452fb --- /dev/null +++ b/Sources/Xcode2/TargetSummaryFormatter.swift @@ -0,0 +1,125 @@ +import Foundation + +public extension XCode { + enum TargetSummaryFormatter { + public static func format(project: XCode.Project, target: XCode.Target) -> String { + var lines: [String] = [] + + lines.append("Target: \(target.name)") + lines.append("Type: \(target.productType ?? "")") + + if let productName = target.productName { + lines.append("Product Name: \(productName)") + } + + lines.append("") + lines.append("Metadata:") + appendValue(target.metadata.bundleID, label: "Bundle ID", to: &lines) + appendValue(target.metadata.moduleName, label: "Module Name", to: &lines) + appendValue(target.metadata.infoPlist, label: "Info.plist", to: &lines) + + if !target.metadata.deploymentTargets.isEmpty { + lines.append(" Deployment Targets:") + for key in target.metadata.deploymentTargets.keys.sorted() { + guard let value = target.metadata.deploymentTargets[key] else { continue } + lines.append(" \(key): \(value)") + } + } + + appendValue(target.metadata.codeSign.codeSignStyle, label: "Code Sign Style", to: &lines) + appendValue(target.metadata.codeSign.developmentTeam, label: "Development Team", to: &lines) + appendValue(target.metadata.codeSign.codeSignIdentity, label: "Code Sign Identity", to: &lines) + + lines.append("") + lines.append("Files:") + appendFiles(target.files.sources, title: "Sources", to: &lines) + appendFiles(target.files.headers, title: "Headers", to: &lines) + appendFiles(target.files.resources, title: "Resources", to: &lines) + appendFiles(target.files.frameworks, title: "Frameworks", to: &lines) + appendFiles(target.files.copyFiles, title: "Copy Files", to: &lines) + appendFiles(target.files.others, title: "Others", to: &lines) + + lines.append("") + lines.append("Dependencies:") + appendList(target.dependencies.targets, title: "Targets", to: &lines) + appendList(target.dependencies.packageProducts.map(\.summaryText), title: "Package Products", to: &lines) + appendList(target.dependencies.frameworks, title: "Frameworks", to: &lines) + appendList(target.dependencies.sdkFrameworks, title: "SDK Frameworks", to: &lines) + + let selectedConfigName = project.preferConfig ?? target.configs.keys.sorted().first + if let selectedConfigName, let settings = target.configs[selectedConfigName] { + lines.append("") + lines.append("Settings [\(selectedConfigName)]:") + for key in settings.keys.sorted() { + guard let value = settings[key] else { continue } + lines.append(" \(key) = \(value.summaryText)") + } + } + + return lines.joined(separator: "\n") + } + + private static func appendValue(_ value: String?, label: String, to lines: inout [String]) { + guard let value, !value.isEmpty else { return } + lines.append(" \(label): \(value)") + } + + private static func appendFiles(_ files: [XCode.File], title: String, to lines: inout [String]) { + guard !files.isEmpty else { return } + + lines.append(" \(title):") + for file in files.sorted(by: { $0.summaryPath < $1.summaryPath }) { + lines.append(" - \(file.summaryPath)") + } + } + + private static func appendList(_ values: [String], title: String, to lines: inout [String]) { + guard !values.isEmpty else { return } + + lines.append(" \(title):") + for value in values.sorted() { + lines.append(" - \(value)") + } + } + } +} + +private extension XCode.File { + var summaryPath: String { + path ?? name ?? fullPath ?? label ?? "" + } +} + +private extension XCode.PackageProductDependency { + var summaryText: String { + if let package, !package.isEmpty { + return "\(package) / \(productName)" + } + return productName + } +} + +private extension XCode.JSONValue { + var summaryText: String { + switch self { + case .string(let value): + return value + case .bool(let value): + return value ? "true" : "false" + case .int(let value): + return String(value) + case .double(let value): + return String(value) + case .array(let value): + return "[" + value.map(\.summaryText).joined(separator: ", ") + "]" + case .object(let value): + let items = value.keys.sorted().compactMap { key -> String? in + guard let value = value[key] else { return nil } + return "\(key): \(value.summaryText)" + } + return "{" + items.joined(separator: ", ") + "}" + case .null: + return "null" + } + } +} diff --git a/Sources/Xcode2/XCode.swift b/Sources/Xcode2/XCode.swift new file mode 100644 index 0000000..4e76ca6 --- /dev/null +++ b/Sources/Xcode2/XCode.swift @@ -0,0 +1 @@ +public enum XCode {} diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift new file mode 100644 index 0000000..27110c6 --- /dev/null +++ b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import XCode2 + +final class TargetSummaryFormatterTests: XCTestCase { + func testFormatTargetSummary() throws { + let target = XCode.Target( + name: "Example", + productName: "Example", + productType: "com.apple.product-type.application", + configs: [ + "Debug": [ + "SWIFT_VERSION": .string("5.9"), + ], + "Release": [ + "INFOPLIST_FILE": .string("Example/Info.plist"), + "IPHONEOS_DEPLOYMENT_TARGET": .string("16.0"), + "PRODUCT_BUNDLE_IDENTIFIER": .string("com.example.Example"), + "SWIFT_VERSION": .string("5.9"), + ], + ], + metadata: .init( + bundleID: "com.example.Example", + moduleName: "Example", + infoPlist: "Example/Info.plist", + deploymentTargets: ["iOS": "16.0"], + codeSign: .init( + developmentTeam: nil, + codeSignStyle: "Automatic", + codeSignIdentity: nil + ) + ), + buildPhases: [], + files: .init( + sources: [ + .init( + name: "ExampleApp.swift", + path: "Example/ExampleApp.swift", + fullPath: "/tmp/Example/ExampleApp.swift", + label: nil, + fileType: "sourcecode.swift", + sourceTree: "", + buildPhase: "sources", + compilerFlags: nil, + attributes: [] + ), + ], + headers: [], + resources: [ + .init( + name: "Assets.xcassets", + path: "Example/Assets.xcassets", + fullPath: "/tmp/Example/Assets.xcassets", + label: nil, + fileType: "folder.assetcatalog", + sourceTree: "", + buildPhase: "resources", + compilerFlags: nil, + attributes: [] + ), + ], + frameworks: [], + copyFiles: [], + others: [] + ), + dependencies: .init( + targets: ["Framework1"], + packageProducts: [], + frameworks: [], + sdkFrameworks: ["SwiftUI", "UIKit"] + ) + ) + + let project = XCode.Project( + name: "Example", + workspacePath: "/tmp", + projectPath: "/tmp/Example.xcodeproj", + preferConfig: "Release", + configs: [:], + packages: .init(remote: [], local: []), + targets: [target] + ) + + let summary = XCode.TargetSummaryFormatter.format(project: project, target: target) + + XCTAssertTrue(summary.contains("Target: Example")) + XCTAssertTrue(summary.contains("Type: com.apple.product-type.application")) + XCTAssertTrue(summary.contains("Bundle ID: com.example.Example")) + XCTAssertTrue(summary.contains("Sources:")) + XCTAssertTrue(summary.contains("- Example/ExampleApp.swift")) + XCTAssertTrue(summary.contains("Resources:")) + XCTAssertTrue(summary.contains("Dependencies:")) + XCTAssertTrue(summary.contains("SDK Frameworks:")) + XCTAssertTrue(summary.contains("Settings [Release]:")) + XCTAssertTrue(summary.contains("PRODUCT_BUNDLE_IDENTIFIER = com.example.Example")) + } +} From dc15088c22d0ed12a5fe97a88bcd214a5a20e7b1 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 22 Apr 2026 10:20:27 +0800 Subject: [PATCH 002/173] Add roadmap tree builder and command --- .gitignore | 4 +- Sources/Bazelize/Command.swift | 25 + Sources/Xcode2/RoadmapTreeBuilder.swift | 713 ++++++++++++++++++ .../XCode2Tests/RoadmapTreeBuilderTests.swift | 76 ++ docs/Roadmap.md | 97 +++ docs/Roadmap_ZH.md | 97 +++ .../plans/2026-04-09-xcode2-print-target.md | 102 +++ .../plans/2026-04-11-roadmap-command.md | 134 ++++ .../plans/2026-04-15-roadmap-bazelfile.md | 105 +++ .../2026-04-11-roadmap-command-design.md | 118 +++ .../2026-04-15-roadmap-bazelfile-design.md | 120 +++ 11 files changed, 1590 insertions(+), 1 deletion(-) create mode 100644 Sources/Xcode2/RoadmapTreeBuilder.swift create mode 100644 Tests/XCode2Tests/RoadmapTreeBuilderTests.swift create mode 100644 docs/Roadmap.md create mode 100644 docs/Roadmap_ZH.md create mode 100644 docs/superpowers/plans/2026-04-09-xcode2-print-target.md create mode 100644 docs/superpowers/plans/2026-04-11-roadmap-command.md create mode 100644 docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md create mode 100644 docs/superpowers/specs/2026-04-11-roadmap-command-design.md create mode 100644 docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md diff --git a/.gitignore b/.gitignore index 51ead6b..ccc0f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,6 @@ lefthook.yml .swiftpm/* .vscode/* -cache/ \ No newline at end of file +cache/ + +app/ \ No newline at end of file diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index 0acbe24..ccc5804 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -20,6 +20,7 @@ struct Command: AsyncParsableCommand { subcommands: [ GenerateCommand.self, XCode2Command.self, + RoadmapCommand.self, ], defaultSubcommand: GenerateCommand.self ) @@ -102,3 +103,27 @@ struct XCode2Command: AsyncParsableCommand { print(json) } } + +struct RoadmapCommand: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "roadmap", + abstract: "Create the roadmap tree layout from an Xcode project." + ) + + @Option(name: [.customLong("project", withSingleDash: false)], help: "PATH/TO/YOUR.xcodeproj") + var project: String + + @Option(name: [.customLong("output", withSingleDash: false)], help: "PATH/TO/OUTPUT") + var output: String + + @Option(name: [.short], help: "Preferred config name used by project parsing") + var config: String? + + func run() async throws { + let projectPath = Path.current + project + let outputPath = Path.current + output + let dump = try XCode.Project.load(path: projectPath, preferConfig: config) + + try XCode.RoadmapTreeBuilder(output: outputPath).build(project: dump) + } +} diff --git a/Sources/Xcode2/RoadmapTreeBuilder.swift b/Sources/Xcode2/RoadmapTreeBuilder.swift new file mode 100644 index 0000000..959201c --- /dev/null +++ b/Sources/Xcode2/RoadmapTreeBuilder.swift @@ -0,0 +1,713 @@ +import Foundation +import PathKit + +public extension XCode { + struct RoadmapTreeBuilder { + public let output: Path + + public init(output: Path) { + self.output = output + } + + public func build(project: XCode.Project) throws { + try output.mkpath() + try write(path: output + "BUILD", contents: rootBuildContents(project: project)) + try write(path: output + "MODULE.bazel", contents: moduleContents(project: project)) + try write(path: output + "Package.swift", contents: packageSwiftContents(project: project)) + try linkPackageResolvedIfPresent(project: project) + + let prebuilt = output + "Prebuilt" + try prebuilt.mkpath() + try materializePrebuiltFiles(project: project, prebuiltRoot: prebuilt) + try write(path: prebuilt + "BUILD", contents: prebuiltBuildContents(project: project)) + + for target in project.targets { + try build(target: target, project: project) + } + } + + private func build(target: XCode.Target, project: XCode.Project) throws { + let targetRoot = output + target.name + let sourcesRoot = targetRoot + "Sources" + let generatedRoot = targetRoot + "Generated" + + try sourcesRoot.mkpath() + try generatedRoot.mkpath() + try writeGeneratedFiles(target: target, generatedRoot: generatedRoot) + try write(path: targetRoot + "BUILD", contents: buildFileContents(target: target, project: project)) + + var materializedDirectories = Set() + for relativePath in target.pathsForRoadmapTree { + let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let hasMaterializedAncestor = materializedDirectories.contains { existing in + normalizedPath == existing || normalizedPath.hasPrefix(existing + "/") + } + guard !hasMaterializedAncestor else { continue } + + let source = Path(project.workspacePath) + relativePath + guard source.exists else { continue } + guard !source.isSelfReferentialSymlink else { continue } + + let destination = sourcesRoot + relativePath + try materialize(source: source, destination: destination) + if source.isDirectory { + materializedDirectories.insert(normalizedPath) + } + } + } + + private func rootBuildContents(project: XCode.Project) -> String { + if project.configs.isEmpty { + return "" + } + + let configSettings = project.configs.keys.sorted().map { config in + """ + config_setting( + name = "\(config)", + values = {"compilation_mode": "\(config.lowercased())"}, + ) + """ + }.joined(separator: "\n\n") + + return configSettings + "\n" + } + + private func prebuiltBuildContents(project: XCode.Project) -> String { + let xcframeworks = project.prebuiltXcframeworks + guard !xcframeworks.isEmpty else { return "" } + + let load = #"load("@build_bazel_rules_apple//apple:apple.bzl", "apple_dynamic_xcframework_import")"# + let rules = xcframeworks.compactMap { file -> String? in + guard let path = file.path, !path.isEmpty else { return nil } + let name = Path(path).lastComponentWithoutExtension + return """ + apple_dynamic_xcframework_import( + name = "\(name)", + xcframework_imports = glob([ + "\(path)/**", + ]), + visibility = ["//visibility:public"], + ) + """ + }.joined(separator: "\n\n") + + return load + "\n\n" + rules + "\n" + } + + private func moduleContents(project: XCode.Project) -> String { + let repos = swiftPackageRepoNames(project: project) + let useRepoItems = (["swift_deps"] + repos.map { #""\#($0)""# }).joined(separator: ",\n ") + + return """ + module(name = "example", version = "0.0.1") + + bazel_dep(name = "bazel_skylib", version = "1.9.0") + bazel_dep(name = "rules_cc", version = "0.2.17") + bazel_dep(name = "rules_apple", version = "4.5.0", repo_name = "build_bazel_rules_apple") + bazel_dep(name = "rules_swift", version = "3.5.0", repo_name = "build_bazel_rules_swift") + bazel_dep(name = "rules_swift_package_manager", version = "1.13.0") + + swift_deps = use_extension( + "@rules_swift_package_manager//:extensions.bzl", + "swift_deps", + ) + swift_deps.from_package( + declare_swift_deps_info = True, + resolved = "//:Package.resolved", + swift = "//:Package.swift", + ) + use_repo( + \(useRepoItems) + ) + """ + } + + private func packageSwiftContents(project: XCode.Project) -> String { + let remoteDeps = project.packages.remote.compactMap(packageDependencyLine(remote:)) + let localDeps = project.roadmapLocalPackages.map { local in + let path = local.packagePath.absolute().string + return #" .package(path: "\#(path)"),"# + } + let deps = (remoteDeps + localDeps).joined(separator: "\n") + + return """ + // swift-tools-version: 5.7 + import PackageDescription + + let package = Package( + name: "RoadmapPackages", + dependencies: [ + \(deps) + ] + ) + """ + } + + private func buildFileContents(target: XCode.Target, project: XCode.Project) -> String { + var sections: [String] = [] + var loads: [String: Set] = [:] + + if target.hasSwiftSources { + loads["@build_bazel_rules_swift//swift:swift.bzl", default: []].insert("swift_library") + sections.append(swiftLibraryContents(target: target, project: project)) + } else if target.hasObjcSources || target.hasHeaders { + loads["@rules_cc//cc:defs.bzl", default: []].insert("objc_library") + sections.append(objcLibraryContents(target: target, project: project)) + } + + switch target.roadmapKind { + case .application: + loads["@build_bazel_rules_apple//apple:ios.bzl", default: []].insert("ios_application") + sections.append(iosApplicationContents(target: target, project: project)) + case .framework: + loads["@build_bazel_rules_apple//apple:ios.bzl", default: []].insert("ios_framework") + sections.append(iosFrameworkContents(target: target, project: project)) + case .staticLibrary: + sections.append(staticLibraryAliasContents(target: target)) + case .other: + if sections.isEmpty { + sections.append("# Unsupported target type: \(target.productType ?? "unknown")") + } + } + + let loadLines = loads.keys.sorted().map { label in + let rules = loads[label, default: []].sorted().map { #""\#($0)""# }.joined(separator: ", ") + return #"load("\#(label)", \#(rules))"# + } + + return (loadLines + sections).joined(separator: "\n\n") + "\n" + } + + private func swiftLibraryContents(target: XCode.Target, project: XCode.Project) -> String { + let deps = quotedList( + target.targetLibraryDeps(project: project) + + target.swiftPackageProductLabels(project: project) + + target.prebuiltDependencyLabels + ) + return """ + swift_library( + name = "\(target.name)_library", + module_name = "\(target.moduleNameForRoadmap)", + srcs = glob(["Sources/**/*.swift"], allow_empty = True), + deps = \(deps), + visibility = ["//visibility:public"], + ) + """ + } + + private func objcLibraryContents(target: XCode.Target, project: XCode.Project) -> String { + let hdrs = #"glob(["Sources/**/*.h", "Sources/**/*.hpp"], allow_empty = True)"# + let srcs = #"glob(["Sources/**/*.m", "Sources/**/*.mm", "Sources/**/*.c", "Sources/**/*.cc", "Sources/**/*.cpp"], allow_empty = True)"# + let deps = quotedList( + target.targetLibraryDeps(project: project) + + target.swiftPackageProductLabels(project: project) + + target.prebuiltDependencyLabels + ) + + return """ + objc_library( + name = "\(target.name)_objc", + module_name = "\(target.moduleNameForRoadmap)", + srcs = \(srcs), + hdrs = \(hdrs), + includes = ["."], + deps = \(deps), + visibility = ["//visibility:private"], + ) + + alias( + name = "\(target.name)_library", + actual = ":\(target.name)_objc", + visibility = ["//visibility:public"], + ) + """ + } + + private func iosApplicationContents(target: XCode.Target, project: XCode.Project) -> String { + let deps = quotedList([":\(target.name)_library"] + target.prebuiltDependencyLabels) + let sdkFrameworks = quotedList(target.dependencies.sdkFrameworks) + let resources = #"glob(["Sources/**"], exclude = ["Sources/**/*.swift", "Sources/**/*.h", "Sources/**/*.hpp", "Sources/**/*.m", "Sources/**/*.mm", "Sources/**/*.c", "Sources/**/*.cc", "Sources/**/*.cpp"], allow_empty = True)"# + + var lines: [String] = [ + "ios_application(", + #" name = "\#(target.name)","#, + #" bundle_id = "\#(target.metadata.bundleID ?? "com.example.\(target.name)")","#, + ] + + if let minimumOS = target.metadata.deploymentTargets["iOS"] { + lines.append(#" minimum_os_version = "\#(minimumOS)","#) + } + if let families = target.appleFamiliesLiteral { + lines.append(" families = \(families),") + } + + lines.append(" deps = \(deps),") + lines.append(#" infoplists = ["Generated/Info.plist"],"#) + if sdkFrameworks != "[]" { + lines.append(" sdk_frameworks = \(sdkFrameworks),") + } + lines.append(" resources = \(resources),") + lines.append(#" visibility = ["//visibility:public"],"#) + lines.append(")") + return lines.joined(separator: "\n") + } + + private func iosFrameworkContents(target: XCode.Target, project: XCode.Project) -> String { + let deps = quotedList([":\(target.name)_library"] + target.prebuiltDependencyLabels) + let resources = #"glob(["Sources/**"], exclude = ["Sources/**/*.swift", "Sources/**/*.h", "Sources/**/*.hpp", "Sources/**/*.m", "Sources/**/*.mm", "Sources/**/*.c", "Sources/**/*.cc", "Sources/**/*.cpp"], allow_empty = True)"# + + var lines: [String] = [ + "ios_framework(", + #" name = "\#(target.name)","#, + ] + + if let bundleID = target.metadata.bundleID { + lines.append(#" bundle_id = "\#(bundleID)","#) + } + if let minimumOS = target.metadata.deploymentTargets["iOS"] { + lines.append(#" minimum_os_version = "\#(minimumOS)","#) + } + if let families = target.appleFamiliesLiteral { + lines.append(" families = \(families),") + } + + lines.append(" deps = \(deps),") + lines.append(#" infoplists = ["Generated/Info.plist"],"#) + lines.append(" resources = \(resources),") + lines.append(#" visibility = ["//visibility:public"],"#) + lines.append(")") + return lines.joined(separator: "\n") + } + + private func staticLibraryAliasContents(target: XCode.Target) -> String { + """ + alias( + name = "\(target.name)", + actual = ":\(target.name)_library", + visibility = ["//visibility:public"], + ) + """ + } + + private func packageDependencyLine(remote: XCode.RemotePackage) -> String? { + guard let url = remote.repositoryURL else { return nil } + + if let requirement = remote.requirement { + if let version = requirement.wrappedValue(prefix: "upToNextMajorVersion(") { + return #" .package(url: "\#(url)", from: "\#(version)"),"# + } + if let version = requirement.wrappedValue(prefix: "upToNextMinorVersion(") { + return #" .package(url: "\#(url)", .upToNextMinor(from: "\#(version)")),"# + } + if let version = requirement.wrappedValue(prefix: "exact(") { + return #" .package(url: "\#(url)", exact: "\#(version)"),"# + } + if let branch = requirement.wrappedValue(prefix: "branch(") { + return #" .package(url: "\#(url)", branch: "\#(branch)"),"# + } + if let revision = requirement.wrappedValue(prefix: "revision(") { + return #" .package(url: "\#(url)", revision: "\#(revision)"),"# + } + } + + return #" .package(url: "\#(url)", from: "0.0.1"),"# + } + + private func swiftPackageRepoNames(project: XCode.Project) -> [String] { + let remote = project.packages.remote.compactMap { package in + package.repositoryURL.map(repositoryName(url:)) + } + let local = project.roadmapLocalPackages.map { package in + repositoryName(path: package.packagePath.lastComponent) + } + return Array(Set(remote + local)).sorted() + } + + private func linkPackageResolvedIfPresent(project: XCode.Project) throws { + let source = Path(project.workspacePath) + "Package.resolved" + guard source.exists else { return } + + let destination = output + "Package.resolved" + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + + private func write(path: Path, contents: String) throws { + try path.parent().mkpath() + try contents.write(toFile: path.string, atomically: true, encoding: .utf8) + } + + private func replaceIfNeeded(at path: Path) throws { + guard path.exists || path.isSymlink else { return } + try path.delete() + } + + private func materialize(source: Path, destination: Path) throws { + if source.isDirectory { + if destination.isSymlink { + try destination.delete() + } + if !destination.exists { + try destination.mkpath() + } + for child in try source.children() { + guard !child.isSelfReferentialSymlink else { continue } + try materialize(source: child, destination: destination + child.lastComponent) + } + return + } + + try destination.parent().mkpath() + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + + private func materializePrebuiltFiles(project: XCode.Project, prebuiltRoot: Path) throws { + for file in project.prebuiltXcframeworks { + guard let relativePath = file.path, !relativePath.isEmpty else { continue } + let source = Path(project.workspacePath) + relativePath + guard source.exists else { continue } + guard !source.isSelfReferentialSymlink else { continue } + + let destination = prebuiltRoot + relativePath + try materialize(source: source, destination: destination) + } + } + + private func repositoryName(url: String) -> String { + repositoryName(module: Path(url).lastComponentWithoutExtension) + } + + private func repositoryName(path: String) -> String { + repositoryName(module: Path(path).lastComponent) + } + + private func repositoryName(module: String) -> String { + "swiftpkg_" + sanitize(module.lowercased()) + } + + private func sanitize(_ value: String) -> String { + value.replacingOccurrences(of: "-", with: "_") + } + + private func quotedList(_ values: [String]) -> String { + let all = Array(Set(values)).sorted() + return "[" + all.map { #""\#($0)""# }.joined(separator: ", ") + "]" + } + + private func writeGeneratedFiles(target: XCode.Target, generatedRoot: Path) throws { + switch target.roadmapKind { + case .application, .framework: + try write(path: generatedRoot + "Info.plist", contents: generatedInfoPlist(target: target)) + case .staticLibrary, .other: + break + } + } + + private func generatedInfoPlist(target: XCode.Target) -> String { + let bundleID = target.metadata.bundleID ?? "com.example.\(target.name)" + let bundleName = target.name + let packageType: String = switch target.roadmapKind { + case .application: "APPL" + case .framework: "FMWK" + case .staticLibrary, .other: "BNDL" + } + return """ + + + + + CFBundleIdentifier + \(bundleID) + CFBundleName + \(bundleName) + CFBundleExecutable + \(bundleName) + CFBundleShortVersionString + 1.0 + CFBundlePackageType + \(packageType) + CFBundleVersion + 1 + + + """ + } + } +} + +private extension XCode.Target { + enum RoadmapKind { + case application + case framework + case staticLibrary + case other + } + + var roadmapKind: RoadmapKind { + switch productType ?? "" { + case "com.apple.product-type.application": + return .application + case "com.apple.product-type.framework": + return .framework + case "com.apple.product-type.library.static": + return .staticLibrary + default: + return .other + } + } + + var hasSwiftSources: Bool { + files.sources.contains { $0.fileType == "sourcecode.swift" || ($0.path?.hasSuffix(".swift") ?? false) } + } + + var hasObjcSources: Bool { + files.sources.contains { + let path = $0.path ?? "" + return path.hasSuffix(".m") || path.hasSuffix(".mm") || path.hasSuffix(".c") || path.hasSuffix(".cc") || path.hasSuffix(".cpp") + } + } + + var hasHeaders: Bool { + !files.headers.isEmpty + } + + var moduleNameForRoadmap: String { + let moduleName = metadata.moduleName ?? name + if moduleName.contains("$(") || moduleName.isEmpty { + return name + } + return moduleName + } + + var appleFamiliesLiteral: String? { + guard let raw = selectedSettings["TARGETED_DEVICE_FAMILY"]?.summaryString else { return nil } + let families = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .compactMap { code -> String? in + switch code { + case "1": return "iphone" + case "2": return "ipad" + case "3": return "tv" + case "4": return "watch" + default: return nil + } + } + guard !families.isEmpty else { return nil } + return "[" + families.map { #""\#($0)""# }.joined(separator: ", ") + "]" + } + + var selectedSettings: [String: XCode.JSONValue] { + if let debug = configs["Debug"] { + return debug + } + if let first = configs.keys.sorted().first, let value = configs[first] { + return value + } + return [:] + } + + func targetLibraryDeps(project: XCode.Project) -> [String] { + dependencies.targets.compactMap { dep in + guard project.targets.contains(where: { $0.name == dep }) else { return nil } + return "//\(dep):\(dep)_library" + } + } + + func targetBundleDeps(project: XCode.Project) -> [String] { + dependencies.targets.compactMap { dep in + guard project.targets.contains(where: { $0.name == dep }) else { return nil } + return "//\(dep):\(dep)" + } + } + + func swiftPackageProductLabels(project: XCode.Project) -> [String] { + let localRepos = project.localPackageRepoByProduct + + return dependencies.packageProducts.compactMap { product in + if let package = product.package, !package.isEmpty { + let repo = "swiftpkg_" + sanitizeRepo(Path(package).lastComponentWithoutExtension.lowercased()) + return "@\(repo)//:\(product.productName)" + } + + if let repo = localRepos[product.productName] { + return "@\(repo)//:\(product.productName)" + } + + return nil + } + } + + private func sanitizeRepo(_ value: String) -> String { + value.replacingOccurrences(of: "-", with: "_") + } + + var prebuiltDependencyLabels: [String] { + files.frameworks.compactMap { file in + guard file.fileType == "wrapper.xcframework", let path = file.path, !path.isEmpty else { return nil } + let name = Path(path).lastComponentWithoutExtension + return "//Prebuilt:\(name)" + } + } + + var pathsForRoadmapTree: [String] { + let allFiles = files.sources + files.headers + files.resources + files.others + let candidates = allFiles.compactMap(\.roadmapRelativePath).sorted { + let lhsDepth = $0.split(separator: "/").count + let rhsDepth = $1.split(separator: "/").count + if lhsDepth == rhsDepth { + return $0 < $1 + } + return lhsDepth < rhsDepth + } + + var result: [String] = [] + var seen = Set() + + for path in candidates where seen.insert(path).inserted { + let hasAncestor = result.contains { existing in + path == existing || path.hasPrefix(existing + "/") + } + guard !hasAncestor else { continue } + result.append(path) + } + + return result + } +} + +private extension XCode.Project { + struct RoadmapLocalPackage { + let packagePath: Path + let repoName: String + let products: [String] + } + + var roadmapLocalPackages: [RoadmapLocalPackage] { + let usedLocalProducts = Set( + targets.flatMap { target -> [String] in + target.dependencies.packageProducts.compactMap { product in + guard product.package == nil else { return nil } + return product.productName + } + } + ) + + let explicit = packages.local.compactMap { package -> RoadmapLocalPackage? in + let packagePath = Path(workspacePath) + package.relativePath + let manifest = packagePath + "Package.swift" + guard let content = try? String(contentsOfFile: manifest.string) else { return nil } + let localPackage = RoadmapLocalPackage( + packagePath: packagePath, + repoName: "swiftpkg_" + package.relativePath.packageRepoBasename, + products: content.swiftPackageProductNames + ) + guard !usedLocalProducts.isDisjoint(with: localPackage.products) else { return nil } + return localPackage + } + + if !explicit.isEmpty { + return explicit + } + + let workspace = Path(workspacePath) + let children = (try? workspace.children()) ?? [] + return children + .filter(\.isDirectory) + .filter { ($0 + "Package.swift").exists } + .compactMap { directory -> RoadmapLocalPackage? in + let manifest = directory + "Package.swift" + guard let content = try? String(contentsOfFile: manifest.string) else { return nil } + let localPackage = RoadmapLocalPackage( + packagePath: directory, + repoName: "swiftpkg_" + directory.lastComponent.lowercased().replacingOccurrences(of: "-", with: "_"), + products: content.swiftPackageProductNames + ) + guard !usedLocalProducts.isDisjoint(with: localPackage.products) else { return nil } + return localPackage + } + } + + var localPackageRepoByProduct: [String: String] { + var result: [String: String] = [:] + + for package in roadmapLocalPackages { + for product in package.products { + result[product] = package.repoName + } + } + + return result + } + + var prebuiltXcframeworks: [XCode.File] { + let all = targets.flatMap { target in + target.files.frameworks.filter { $0.fileType == "wrapper.xcframework" } + } + + var seen = Set() + return all.filter { file in + guard let path = file.path else { return false } + return seen.insert(path).inserted + } + } +} + +private extension XCode.File { + var roadmapRelativePath: String? { + if let path, !path.isEmpty { + return path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + return nil + } +} + +private extension Path { + var isSelfReferentialSymlink: Bool { + guard isSymlink else { return false } + guard let destination = try? symlinkDestination().absolute() else { return false } + return destination == absolute() + } +} + +private extension XCode.JSONValue { + var summaryString: String? { + switch self { + case .string(let value): + return value + case .int(let value): + return String(value) + case .double(let value): + return String(value) + case .bool(let value): + return value ? "true" : "false" + case .array(let values): + return values.compactMap(\.summaryString).joined(separator: ",") + case .object, .null: + return nil + } + } +} + +private extension String { + var swiftPackageProductNames: [String] { + let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(startIndex..., in: self) + return regex.matches(in: self, range: range).compactMap { match in + guard let capture = Range(match.range(at: 1), in: self) else { return nil } + return String(self[capture]) + } + } + + var packageRepoBasename: String { + Path(self).lastComponent.lowercased().replacingOccurrences(of: "-", with: "_") + } + + func wrappedValue(prefix: String) -> String? { + guard hasPrefix(prefix), hasSuffix(")") else { return nil } + return String(dropFirst(prefix.count).dropLast()) + } +} diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift new file mode 100644 index 0000000..969d77e --- /dev/null +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -0,0 +1,76 @@ +import Foundation +import PathKit +import XCTest +@testable import XCode2 + +final class RoadmapTreeBuilderTests: XCTestCase { + func testBuildCreatesTargetTreeAndSymlinks() throws { + let projectPath = Path.current + "fixture/iOS2/Example.xcodeproj" + let project = try XCode.Project.load(path: projectPath, preferConfig: nil) + + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? output.delete() } + + try XCode.RoadmapTreeBuilder(output: output).build(project: project) + + XCTAssertTrue((output + "BUILD").exists) + XCTAssertTrue((output + "MODULE.bazel").exists) + XCTAssertTrue((output + "Package.swift").exists) + XCTAssertTrue((output + "Prebuilt").exists) + XCTAssertTrue((output + "Prebuilt/BUILD").exists) + XCTAssertTrue((output + "Prebuilt/SVProgressHUD.xcframework").exists) + XCTAssertTrue((output + "Example/Sources").exists) + XCTAssertTrue((output + "Example/Generated").exists) + XCTAssertTrue((output + "Example/BUILD").exists) + XCTAssertTrue((output + "Framework1/BUILD").exists) + XCTAssertTrue((output + "Static2/BUILD").exists) + + let exampleDir = output + "Example/Sources/Example" + XCTAssertTrue(exampleDir.isDirectory) + XCTAssertFalse(exampleDir.isSymlink) + + let exampleApp = output + "Example/Sources/Example/ExampleApp.swift" + XCTAssertTrue(exampleApp.isSymlink) + XCTAssertEqual( + try exampleApp.symlinkDestination().absolute().string, + (projectPath.parent() + "Example/ExampleApp.swift").absolute().string + ) + + let previewAsset = output + "Example/Sources/Example/Preview Content/Preview Assets.xcassets/Contents.json" + XCTAssertTrue(previewAsset.isSymlink) + XCTAssertEqual( + try previewAsset.symlinkDestination().absolute().string, + (projectPath.parent() + "Example/Preview Content/Preview Assets.xcassets/Contents.json").absolute().string + ) + + let exampleBuild = try String(contentsOfFile: (output + "Example/BUILD").string) + XCTAssertTrue(exampleBuild.contains("ios_application(")) + XCTAssertTrue(exampleBuild.contains("name = \"Example\"")) + XCTAssertTrue(exampleBuild.contains("swift_library(")) + XCTAssertTrue(exampleBuild.contains("name = \"Example_library\"")) + XCTAssertTrue(exampleBuild.contains("//Framework1:Framework1")) + XCTAssertTrue(exampleBuild.contains("//Prebuilt:SVProgressHUD")) + XCTAssertTrue(exampleBuild.contains("@swiftpkg_anycodable//:AnyCodable")) + XCTAssertTrue(exampleBuild.contains("@swiftpkg_local1//:LocalLib1")) + XCTAssertTrue(exampleBuild.contains("@swiftpkg_local1//:LocalLib2")) + + let frameworkBuild = try String(contentsOfFile: (output + "Framework1/BUILD").string) + XCTAssertTrue(frameworkBuild.contains("ios_framework(")) + XCTAssertTrue(frameworkBuild.contains("name = \"Framework1\"")) + + let static2Build = try String(contentsOfFile: (output + "Static2/BUILD").string) + XCTAssertTrue(static2Build.contains("objc_library(")) + XCTAssertTrue(static2Build.contains("name = \"Static2_objc\"")) + + let prebuiltBuild = try String(contentsOfFile: (output + "Prebuilt/BUILD").string) + XCTAssertTrue(prebuiltBuild.contains("apple_dynamic_xcframework_import(")) + XCTAssertTrue(prebuiltBuild.contains("name = \"SVProgressHUD\"")) + + let module = try String(contentsOfFile: (output + "MODULE.bazel").string) + XCTAssertTrue(module.contains("rules_apple")) + XCTAssertTrue(module.contains("rules_swift")) + XCTAssertTrue(module.contains("rules_swift_package_manager")) + XCTAssertTrue(module.contains("swift_deps = use_extension")) + XCTAssertTrue(module.contains("swiftpkg_local1")) + } +} diff --git a/docs/Roadmap.md b/docs/Roadmap.md new file mode 100644 index 0000000..f984fcf --- /dev/null +++ b/docs/Roadmap.md @@ -0,0 +1,97 @@ +# Tree + +## Goal + +This is the ideal output tree after running `bazelize`. + +- Tree layout is based on filesystem paths relative to `.xcodeproj` +- Tree layout does not follow Xcode logical groups +- Target metadata is handled by Bazel files instead of being emitted as files in the tree + +## Root + +```text +$Output/ <- Bazel Root + BUILD + MODULE.bazel + Package.swift <- generated if have SwiftPM + + Targets/ + $Target1/ + BUILD + Sources/ + Generated/ + + Prebuilt/ + BUILD + A.xcframework +``` + +## Target Layout + +Each target has its own directory under `Targets/`. + +```text +Targets/ + $Target/ + BUILD + Sources/ + Generated/ +``` + +- `Sources/` contains all filesystem entries related to the target +- `Sources/` includes source files, headers, and resources +- file entries keep their original relative path from the `.xcodeproj` root +- directory entries are symlinked as directories and are not flattened +- multiple targets may reference the same source path + +## Path Rules + +- Paths are resolved from the `.xcodeproj` relative path +- Xcode logical groups do not affect output layout + +Example: + +```text +Xcode: +App + UI + A.swift + +Real path: +A.swift + +Output: +Sources/A.swift -> /A.swift +``` + +Another example: + +```text +Real paths: +A.swift +B/B.swift +C/ + a.swift + b.swift + c.swift + +Target entries: +A.swift +B/B.swift +C/ + +Output: +Sources/A.swift -> /A.swift +Sources/B/B.swift -> /B/B.swift +Sources/C -> /C +``` + +## Special Directories + +- `Generated/` is target-local and reserved for files generated for that target +- `Prebuilt/` is global at the root level and stores prebuilt binaries + +## Deferred + +- missing-file behavior will be defined later diff --git a/docs/Roadmap_ZH.md b/docs/Roadmap_ZH.md new file mode 100644 index 0000000..926dc05 --- /dev/null +++ b/docs/Roadmap_ZH.md @@ -0,0 +1,97 @@ +# Tree + +## 目標 + +這是目前理想中 `bazelize` 執行完成後的輸出目錄結構。 + +- tree layout 以 `.xcodeproj` 相對路徑為準 +- tree layout 不依照 Xcode logical groups 呈現 +- target metadata 不會以檔案形式輸出,而是交由 Bazel files 處理 + +## Root + +```text +$Output/ <- Bazel Root + BUILD + MODULE.bazel + Package.swift <- 如果有 SwiftPM 則產生 + + Targets/ + $Target1/ + BUILD + Sources/ + Generated/ + + Prebuilt/ + BUILD + A.xcframework +``` + +## Target Layout + +每個 target 都會在 `Targets/` 底下有自己的目錄。 + +```text +Targets/ + $Target/ + BUILD + Sources/ + Generated/ +``` + +- `Sources/` 包含所有和該 target 相關的實體檔案系統 entry +- `Sources/` 內包含 source files、headers、resources +- file entry 會保留其相對於 `.xcodeproj` 的原始子路徑 +- directory entry 會直接以 directory symlink 的形式保留,不會展平 +- 多個 target 可以共享同一個來源路徑 + +## Path Rules + +- 所有路徑都以 `.xcodeproj` 相對路徑解析 +- Xcode logical groups 不影響輸出 layout + +範例: + +```text +Xcode: +App + UI + A.swift + +實際路徑: +A.swift + +輸出: +Sources/A.swift -> /A.swift +``` + +另一個範例: + +```text +實際路徑: +A.swift +B/B.swift +C/ + a.swift + b.swift + c.swift + +Target entries: +A.swift +B/B.swift +C/ + +輸出: +Sources/A.swift -> /A.swift +Sources/B/B.swift -> /B/B.swift +Sources/C -> /C +``` + +## Special Directories + +- `Generated/` 是 target-local,保留給該 target 專屬的 generated files +- `Prebuilt/` 是 root-level global directory,用來放 prebuilt binaries + +## Deferred + +- 缺檔時的處理行為之後再定義 diff --git a/docs/superpowers/plans/2026-04-09-xcode2-print-target.md b/docs/superpowers/plans/2026-04-09-xcode2-print-target.md new file mode 100644 index 0000000..93146d8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-xcode2-print-target.md @@ -0,0 +1,102 @@ +# XCode2 Print Target Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `--print-target ` option to `bazelize xcode2` that prints a human-readable summary for one target instead of the full project JSON dump. + +**Architecture:** Keep JSON output as the default behavior. Move the new text rendering into a small formatter in the `XCode2` module so it can be unit tested without invoking the executable target. The CLI command will only choose between JSON mode and summary mode. + +**Tech Stack:** Swift, Swift Argument Parser, XCTest + +--- + +### Task 1: Lock down the text output shape + +**Files:** +- Create: `Tests/XCode2Tests/TargetSummaryFormatterTests.swift` +- Modify: `Package.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +func testFormatTargetSummary() throws { + let summary = XCode.TargetSummaryFormatter.format(project: project, target: target) + + XCTAssertTrue(summary.contains("Target: Example")) + XCTAssertTrue(summary.contains("Type: com.apple.product-type.application")) + XCTAssertTrue(summary.contains("Files:")) + XCTAssertTrue(summary.contains("Settings [Release]:")) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter TargetSummaryFormatterTests/testFormatTargetSummary` +Expected: FAIL because `TargetSummaryFormatter` and the `XCode2Tests` target do not exist yet. + +- [ ] **Step 3: Add the new test target** + +```swift +.testTarget( + name: "XCode2Tests", + dependencies: ["XCode2"] +), +``` + +- [ ] **Step 4: Run test to verify it still fails for the right reason** + +Run: `swift test --filter TargetSummaryFormatterTests/testFormatTargetSummary` +Expected: FAIL because the formatter symbol is still missing. + +### Task 2: Implement formatter and CLI wiring + +**Files:** +- Create: `Sources/Xcode2/TargetSummaryFormatter.swift` +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Write minimal formatter implementation** + +```swift +public enum TargetSummaryFormatter { + public static func format(project: XCode.Project, target: XCode.Target) -> String { + // build readable text sections + } +} +``` + +- [ ] **Step 2: Wire command-line option** + +```swift +@Option(name: [.customLong("print-target", withSingleDash: false)]) +var printTarget: String? +``` + +- [ ] **Step 3: Select summary mode in the command** + +```swift +if let printTarget { + // find target and print formatted summary +} else { + // existing JSON output +} +``` + +- [ ] **Step 4: Run tests to verify green** + +Run: `swift test --filter TargetSummaryFormatterTests` +Expected: PASS + +### Task 3: Verify the integrated behavior + +**Files:** +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Run the targeted test suite** + +Run: `swift test --filter TargetSummaryFormatterTests` +Expected: PASS + +- [ ] **Step 2: Run a CLI sanity check** + +Run: `swift run bazelize xcode2 --project fixture/iOS/Example.xcodeproj --print-target Example` +Expected: output starts with `Target: Example` and includes `Type:`, `Files:`, and `Settings [Release]:`. diff --git a/docs/superpowers/plans/2026-04-11-roadmap-command.md b/docs/superpowers/plans/2026-04-11-roadmap-command.md new file mode 100644 index 0000000..ae3c5c4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-roadmap-command.md @@ -0,0 +1,134 @@ +# Roadmap Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `bazelize roadmap` command that creates the roadmap directory tree and target source symlinks for an Xcode project. + +**Architecture:** The CLI command will parse `--project`, `--output`, and optional config, then load `XCode.Project` and hand off to a small tree builder. The tree builder will create root placeholders, per-target directories, and symlink target-owned filesystem entries into `Sources/` while preserving relative paths from the project root. + +**Tech Stack:** Swift, Swift Argument Parser, PathKit, XCTest + +--- + +### Task 1: Lock down the expected output tree with a failing test + +**Files:** +- Create: `Tests/XCode2Tests/RoadmapTreeBuilderTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +func testBuildCreatesTargetTreeAndSymlinks() throws { + let projectPath = Path.current + "fixture/iOS2/Example.xcodeproj" + let project = try XCode.Project.load(path: projectPath, preferConfig: nil) + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + + try XCode.RoadmapTreeBuilder(output: output).build(project: project) + + XCTAssertTrue((output + "Targets/Example/Sources").exists) + XCTAssertTrue((output + "Targets/Example/Generated").exists) + XCTAssertTrue((output + "Targets/Example/BUILD").exists) + XCTAssertTrue((output + "Prebuilt/BUILD").exists) + XCTAssertEqual(try (output + "Targets/Example/Sources/Example/ExampleApp.swift").symlinkDestination(), projectPath.parent() + "Example/ExampleApp.swift") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: FAIL because `RoadmapTreeBuilder` does not exist yet. + +### Task 2: Implement the tree builder + +**Files:** +- Create: `Sources/Xcode2/RoadmapTreeBuilder.swift` + +- [ ] **Step 1: Add a minimal tree builder** + +```swift +public extension XCode { + struct RoadmapTreeBuilder { + let output: Path + + public func build(project: XCode.Project) throws { + // create root placeholders + // create target directories + // create symlinks + } + } +} +``` + +- [ ] **Step 2: Materialize root placeholders** + +Run builder code that creates: + +```text +BUILD +MODULE.bazel +Prebuilt/ +Prebuilt/BUILD +``` + +- [ ] **Step 3: Materialize target tree and symlinks** + +Run builder code that creates: + +```text +Targets//BUILD +Targets//Sources/ +Targets//Generated/ +``` + +and symlinks target `sources`, `headers`, `resources`, and `others` entries using project-root-relative paths. + +- [ ] **Step 4: Run the focused test to verify green** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: PASS + +### Task 3: Wire the CLI command + +**Files:** +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Add the new command type** + +```swift +struct RoadmapCommand: AsyncParsableCommand { + @Option var project: String + @Option var output: String + @Option var config: String? +} +``` + +- [ ] **Step 2: Register it in the root command** + +Add `RoadmapCommand.self` to `subcommands`. + +- [ ] **Step 3: Call the builder** + +```swift +let dump = try XCode.Project.load(path: path, preferConfig: config) +try XCode.RoadmapTreeBuilder(output: Path.current + output).build(project: dump) +``` + +- [ ] **Step 4: Re-run the focused test** + +Run: `swift test --filter RoadmapTreeBuilderTests` +Expected: PASS + +### Task 4: Verify the CLI end to end + +**Files:** +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Run the test suite for the new builder** + +Run: `swift test --filter RoadmapTreeBuilderTests` +Expected: PASS + +- [ ] **Step 2: Run the command against the fixture** + +Run: `swift run bazelize roadmap --project fixture/iOS2/Example.xcodeproj --output fixture/iOS2_O` +Expected: creates `fixture/iOS2_O/Targets/Example/Sources`, `fixture/iOS2_O/Targets/Example/Generated`, root `Prebuilt`, and representative source symlinks. diff --git a/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md b/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md new file mode 100644 index 0000000..f8e43af --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md @@ -0,0 +1,105 @@ +# Roadmap Bazel File Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the roadmap output generate package-shaped Bazel files that move `fixture/iOS2/Example.xcodeproj` toward `bazel run //Example:Example`. + +**Architecture:** Extend `RoadmapTreeBuilder` so it owns both filesystem materialization and minimal Bazel file generation. The builder will emit root files (`BUILD`, `MODULE.bazel`, `Package.swift`) and one package `BUILD` per target using lightweight string templates driven by the `XCode2` model. + +**Tech Stack:** Swift, PathKit, XCTest + +--- + +### Task 1: Lock down package-shaped output and BUILD content with a failing test + +**Files:** +- Modify: `Tests/XCode2Tests/RoadmapTreeBuilderTests.swift` + +- [ ] **Step 1: Add assertions for package layout and BUILD text** + +```swift +XCTAssertTrue((output + "Example/Sources").exists) +XCTAssertTrue((output + "Example/BUILD").exists) +XCTAssertTrue(try (output + "Example/BUILD").read().contains("ios_application(")) +XCTAssertTrue(try (output + "Example/BUILD").read().contains("name = \"Example\"")) +XCTAssertTrue(try (output + "Static2/BUILD").read().contains("objc_library(")) +XCTAssertTrue(try (output + "MODULE.bazel").read().contains("rules_swift_package_manager")) +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: FAIL because the current builder still writes `Targets/` and empty placeholders. + +### Task 2: Switch to package-shaped filesystem output + +**Files:** +- Modify: `Sources/Xcode2/RoadmapTreeBuilder.swift` + +- [ ] **Step 1: Change target output root** + +Update: + +```swift +let targetRoot = output + target.name +``` + +instead of `output + "Targets" + target.name`. + +- [ ] **Step 2: Re-run the focused test** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: FAIL on missing BUILD contents rather than wrong directory layout. + +### Task 3: Generate minimal BUILD and module files + +**Files:** +- Modify: `Sources/Xcode2/RoadmapTreeBuilder.swift` + +- [ ] **Step 1: Generate root `MODULE.bazel` and `Package.swift`** + +Add code that writes: + +```python +module(name = "example", version = "0.0.1") +``` + +plus bazel deps and SwiftPM extension wiring. + +- [ ] **Step 2: Generate package BUILD content** + +Implement minimal generation for: + +- `ios_application` +- `ios_framework` +- `swift_library` +- `objc_library` +- `alias` + +- [ ] **Step 3: Wire target and SwiftPM dependencies** + +Generate labels from: + +- `target.dependencies.targets` +- `target.dependencies.packageProducts` +- `target.dependencies.sdkFrameworks` + +- [ ] **Step 4: Re-run the focused test** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: PASS + +### Task 4: Verify the new Bazel file output + +**Files:** +- Modify: `Tests/XCode2Tests/RoadmapTreeBuilderTests.swift` + +- [ ] **Step 1: Run the builder test suite** + +Run: `swift test --filter RoadmapTreeBuilderTests` +Expected: PASS + +- [ ] **Step 2: Run the roadmap command against the fixture** + +Run: `swift run bazelize roadmap --project fixture/iOS2/Example.xcodeproj --output fixture/iOS2_O` +Expected: package-shaped output such as `fixture/iOS2_O/Example/BUILD` and `fixture/iOS2_O/Framework1/BUILD`. diff --git a/docs/superpowers/specs/2026-04-11-roadmap-command-design.md b/docs/superpowers/specs/2026-04-11-roadmap-command-design.md new file mode 100644 index 0000000..331cb13 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-roadmap-command-design.md @@ -0,0 +1,118 @@ +# Roadmap Command Design + +## Goal + +Add a new CLI command that materializes the tree described in `docs/Roadmap.md` from an Xcode project into an output directory. + +The first milestone is intentionally narrow: + +- create the output directory tree +- create per-target `Sources/` and `Generated/` directories +- create the root `Prebuilt/` directory +- create symlinks for target-owned filesystem entries +- create empty `BUILD` files as placeholders + +This milestone does not generate Bazel rules yet. + +## Scope + +Command shape: + +```bash +bazelize roadmap --project fixture/iOS2/Example.xcodeproj --output fixture/iOS2_O +``` + +Inputs: + +- `--project`: path to the `.xcodeproj` +- `--output`: path to the output root +- optional `-c/--config`: preferred config name, reused from `xcode2` + +Outputs: + +- `$Output/BUILD` +- `$Output/MODULE.bazel` +- `$Output/Prebuilt/BUILD` +- `$Output/Targets/$Target/BUILD` +- `$Output/Targets/$Target/Sources/...` +- `$Output/Targets/$Target/Generated/` + +## Path Rules + +The command follows the existing roadmap rules: + +- emitted paths are based on filesystem paths relative to the `.xcodeproj` root +- Xcode logical groups do not affect output layout +- source files, headers, resources, localized files, and other target-owned filesystem entries all go under `Sources/` +- directory entries are symlinked as directories and are not flattened +- target metadata stays in Bazel files and is not emitted as standalone files in the tree +- `Generated/` is target-local +- `Prebuilt/` is global at the root + +## Minimal Behavior + +For each target from `XCode.Project.targets`: + +1. create `Targets//` +2. create `Targets//Sources/` +3. create `Targets//Generated/` +4. create an empty `Targets//BUILD` +5. collect file entries from the target model +6. map each entry to a path relative to the project root +7. create parent directories under `Sources/` +8. create a symlink at the destination path pointing to the source path + +For root output: + +1. create output root +2. create empty root `BUILD` +3. create empty `MODULE.bazel` +4. create `Prebuilt/` +5. create empty `Prebuilt/BUILD` + +## File Selection + +The initial version should use the target file model already exposed by `XCode2`: + +- `target.files.sources` +- `target.files.headers` +- `target.files.resources` +- `target.files.others` + +Framework and copy-files entries should be excluded from `Sources/` for this first milestone because they are closer to dependency packaging than target-owned source tree materialization. Prebuilt binary handling stays reserved for a later increment. + +## Failure Handling + +This milestone keeps failure handling simple: + +- if an entry has no usable relative path, skip it +- if the source path does not exist, skip it for now +- if the destination already exists, replace it + +The roadmap already marks missing-file behavior as deferred, so this implementation should stay minimal and deterministic rather than complete. + +## Architecture + +Keep the command thin and move tree generation into a small reusable builder. + +- `RoadmapCommand` parses CLI arguments and loads `XCode.Project` +- `RoadmapTreeBuilder` creates directories and symlinks +- tests cover the builder output using the `fixture/iOS2` project + +## Testing + +Add a focused integration-style unit test that: + +1. loads `fixture/iOS2/Example.xcodeproj` +2. writes output into a temporary directory +3. verifies expected directories exist +4. verifies expected `BUILD` placeholders exist +5. verifies representative symlinks exist and point to the expected source paths + +## Open Choices Resolved + +- command name: `roadmap` +- output path: explicit `--output` +- generated files location: `Targets//Generated` +- prebuilt binary location: root `Prebuilt/` +- source layout: preserve original relative paths from the project root diff --git a/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md b/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md new file mode 100644 index 0000000..32defc4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md @@ -0,0 +1,120 @@ +# Roadmap Bazel File Design + +## Goal + +Extend the roadmap output so the generated workspace can move toward a real `bazel run //Example:Example` flow. + +The first milestone is focused and intentionally incomplete: + +- switch roadmap output from `Targets//...` to package-shaped directories like `/Example/...` +- generate minimal `BUILD` files for the app package and the dependency target packages it needs +- generate a minimal `MODULE.bazel` +- generate a minimal root `Package.swift` for SwiftPM integration + +This milestone is scoped to the `fixture/iOS2/Example.xcodeproj` style project and prioritizes the `Example` iOS app path. + +## Output Shape + +The output tree becomes: + +```text +/ + BUILD + MODULE.bazel + Package.swift + Prebuilt/ + BUILD + Example/ + BUILD + Sources/ + Generated/ + Framework1/ + BUILD + Sources/ + Generated/ +``` + +This package-shaped layout is required so the final target path is naturally `//Example:Example` instead of `//Targets/Example:Example`. + +## Bazel Rule Strategy + +Use the existing Bazelize rule mapping as the model: + +- application -> `ios_application` +- Swift sources -> `swift_library` +- ObjC sources -> `objc_library` +- framework target -> `ios_framework` +- static library target -> public `alias(name = "", actual = ":_library")` + +Each package should expose a public top-level target matching the package name. + +## Package-Specific Generation + +### App Package + +For `Example`: + +- generate `swift_library(name = "Example_library", ...)` +- generate `ios_application(name = "Example", ...)` +- wire target deps from `target.dependencies.targets` +- wire SwiftPM deps from `target.dependencies.packageProducts` +- wire SDK frameworks from `target.dependencies.sdkFrameworks` +- add resources from the package `Sources/` tree + +### Framework Package + +For `Framework1`, `Framework2`, `Framework3`: + +- generate the package language library target +- generate `ios_framework(name = "", ...)` +- depend on `:_library` +- depend on other target packages when needed + +### Static Library Package + +For `Static` and `Static2`: + +- generate `swift_library` or `objc_library` +- generate `alias(name = "", actual = ":_library")` + +## SwiftPM Strategy + +`Example` depends on Swift package products, so a placeholder `MODULE.bazel` is not enough. + +Generate a minimal root `Package.swift` from `XCode.Project.packages`: + +- remotes -> `.package(url: ..., ...)` +- locals -> `.package(path: ...)` + +Generate a minimal `MODULE.bazel` with: + +- `bazel_dep` entries for `bazel_skylib`, `rules_cc`, `rules_apple`, `rules_swift`, `rules_swift_package_manager` +- `swift_deps = use_extension(...)` +- `swift_deps.from_package(...)` +- `use_repo(...)` entries derived from package repository names + +Package-product labels should follow the existing convention: + +- remote package product -> `@swiftpkg_//:` +- local package product -> `@swiftpkg_//:` + +## Deferred + +This milestone still defers: + +- tests +- prebuilt binary import rules +- xcodeproj helper rules +- full `bazel run` success verification for every fixture target +- complete missing-file policy + +## Testing + +Add tests that verify: + +- package-shaped output directories are created +- `Example/BUILD` contains `ios_application(name = "Example")` +- `Example/BUILD` contains `swift_library(name = "Example_library")` +- `Framework1/BUILD` contains `ios_framework(name = "Framework1")` +- `Static2/BUILD` contains `objc_library(name = "Static2_objc")` +- `MODULE.bazel` contains rules and SwiftPM extension wiring From 3cc9e804f966275ed94b3abd93f853fdbef73d7a Mon Sep 17 00:00:00 2001 From: yume190 Date: Sun, 26 Apr 2026 12:53:21 +0800 Subject: [PATCH 003/173] fix --- Sources/Bazelize/Command.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index 64509c2..50d8fea 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -27,7 +27,7 @@ struct Command: AsyncParsableCommand { } struct GenerateCommand: AsyncParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( commandName: "generate", abstract: "Generate Bazel files from an Xcode project." ) @@ -65,7 +65,7 @@ struct GenerateCommand: AsyncParsableCommand { } struct XCode2Command: AsyncParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( commandName: "xcode2", abstract: "Dump an Xcode project structure as JSON or print one target summary." ) @@ -105,7 +105,7 @@ struct XCode2Command: AsyncParsableCommand { } struct RoadmapCommand: AsyncParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( commandName: "roadmap", abstract: "Create the roadmap tree layout from an Xcode project." ) From d6a5da340bfc3c2cb5d78de229c05c2382cd83e3 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 27 Apr 2026 15:51:08 +0800 Subject: [PATCH 004/173] Refactor XCode2 config loaders and build settings --- .../Loader/XCode+ConfigListLoader.swift | 36 ++ Sources/Xcode2/Loader/XCode+FileLoader.swift | 194 ++++++ .../Xcode2/Loader/XCode+ProjectLoader.swift | 566 +----------------- .../Xcode2/Loader/XCode+TargetLoader.swift | 293 +++++++++ .../Model/Config/XCode+BuildSettings.swift | 49 ++ .../Xcode2/Model/Project/XCode+Project.swift | 4 +- .../Xcode2/Model/Target/XCode+Target.swift | 4 +- Sources/Xcode2/RoadmapTreeBuilder.swift | 25 +- Sources/Xcode2/Support/XCode+JSONValue.swift | 59 -- Sources/Xcode2/TargetSummaryFormatter.swift | 31 +- Tests/XCode2Tests/BuildSettingsTests.swift | 17 + .../TargetSummaryFormatterTests.swift | 26 +- 12 files changed, 621 insertions(+), 683 deletions(-) create mode 100644 Sources/Xcode2/Loader/XCode+ConfigListLoader.swift create mode 100644 Sources/Xcode2/Loader/XCode+FileLoader.swift create mode 100644 Sources/Xcode2/Loader/XCode+TargetLoader.swift create mode 100644 Sources/Xcode2/Model/Config/XCode+BuildSettings.swift delete mode 100644 Sources/Xcode2/Support/XCode+JSONValue.swift create mode 100644 Tests/XCode2Tests/BuildSettingsTests.swift diff --git a/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift b/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift new file mode 100644 index 0000000..0efbcee --- /dev/null +++ b/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift @@ -0,0 +1,36 @@ +import XcodeProj + +struct ConfigListLoader: Hashable { + let native: XCConfigurationList? + + var configs: [String: XCode.BuildSettings] { + (native?.buildConfigurations ?? []).map { config in + ( + config.name, + .init(config) + ) + }.toDictionary() + } + + func merge(_ defaultConfig: ConfigListLoader?) -> [String: XCode.BuildSettings] { + guard let defaultConfig else { + return configs + } + + let defaults = defaultConfig.configs + return configs.map { name, current in + ( + name, + current.merged(with: defaults[name]) + ) + }.toDictionary() + } + + static func == (lhs: ConfigListLoader, rhs: ConfigListLoader) -> Bool { + lhs.native?.uuid == rhs.native?.uuid + } + + func hash(into hasher: inout Hasher) { + hasher.combine(native?.uuid) + } +} diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift new file mode 100644 index 0000000..ad0ee23 --- /dev/null +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -0,0 +1,194 @@ +import Foundation +import PathKit +import XcodeProj + +struct FileLoader { + let native: PBXFileElement + unowned let project: ProjectLoader + + var name: String? { + native.name ?? native.path + } + + var label: String? { + project.transformToLabel(relativePath) + } + + var packageName: String? { + relativePath?.split(separator: "/").first.map(String.init) + } + + var relativePath: String? { + let root = project.workspacePath.string + guard let fullPath else { return nil } + guard fullPath.hasPrefix(root + "/") else { return nil } + return fullPath.delete(prefix: root + "/") + } + + var fullPath: String? { + try? native.fullPath(sourceRoot: project.workspacePath.string) + } + + var fileType: String? { + ref?.lastKnownFileType ?? ref?.explicitFileType + } + + var sourceTree: String { + native.sourceTree?.description ?? "" + } + + var frameworkName: String? { + name?.replacingOccurrences(of: ".framework", with: "") + .replacingOccurrences(of: ".xcframework", with: "") + } + + var isSDKFramework: Bool { + sourceTree == PBXSourceTree.sdkRoot.description || + sourceTree == PBXSourceTree.developerDir.description + } + + private var ref: PBXFileReference? { + native as? PBXFileReference + } + + func file(buildPhase: String?, compilerFlags: String?, attributes: [String]) -> XCode.File { + .init( + name: name, + path: relativePath ?? native.path, + fullPath: fullPath, + label: label, + fileType: fileType, + sourceTree: sourceTree, + buildPhase: buildPhase, + compilerFlags: compilerFlags, + attributes: attributes + ) + } +} + +struct SynchronizedFile { + enum Category { + case source + case header + case resource + case other + } + + let path: String + let fullPath: String + let compilerFlags: String? + + var name: String { + Path(path).lastComponent + } + + var fileType: String? { + switch Path(path).extension?.lowercased() { + case "swift": return "sourcecode.swift" + case "m": return "sourcecode.c.objc" + case "mm": return "sourcecode.cpp.objcpp" + case "c": return "sourcecode.c.c" + case "cc", "cp", "cpp", "cxx": return "sourcecode.cpp.cpp" + case "h": return "sourcecode.c.h" + case "hh", "hpp", "hxx": return "sourcecode.cpp.h" + case "metal": return "sourcecode.metal" + case "xib": return "file.xib" + case "storyboard": return "file.storyboard" + case "xcassets": return "folder.assetcatalog" + case "strings": return "text.plist.strings" + case "stringsdict": return "text.plist.stringsdict" + case "plist": return "text.plist.xml" + case "xcframework": return "wrapper.xcframework" + case "framework": return "wrapper.framework" + default: return nil + } + } + + var category: Category { + switch fileType { + case "sourcecode.swift", + "sourcecode.c.objc", + "sourcecode.cpp.objcpp", + "sourcecode.c.c", + "sourcecode.cpp.cpp", + "sourcecode.metal": + return .source + case "sourcecode.c.h", + "sourcecode.cpp.h": + return .header + case "file.xib", + "file.storyboard", + "folder.assetcatalog", + "text.plist.strings", + "text.plist.stringsdict", + "text.plist.xml": + return .resource + default: + return .other + } + } + + var file: XCode.File { + .init( + name: name, + path: path, + fullPath: fullPath, + label: nil, + fileType: fileType, + sourceTree: "", + buildPhase: buildPhase, + compilerFlags: compilerFlags, + attributes: [] + ) + } + + private var buildPhase: String? { + switch category { + case .source: return BuildPhase.sources.rawValue + case .header: return BuildPhase.headers.rawValue + case .resource: return BuildPhase.resources.rawValue + case .other: return nil + } + } +} + +extension PBXFileElement { + func flatten() throws -> [PBXFileElement] { + if let group = self as? PBXGroup { + return group.children.flatMap { (try? $0.flatten()) ?? [] } + } + + if let ref = self as? PBXFileReference { + return [ref] + } + + return [] + } +} + +extension Sequence { + func toDictionary() -> [K: V] where Element == (K, V) { + Dictionary(uniqueKeysWithValues: self) + } +} + +func unique(_ values: [T], key: (T) -> String) -> [T] { + var result: [T] = [] + var seen = Set() + + for value in values { + let id = key(value) + if seen.insert(id).inserted { + result.append(value) + } + } + + return result +} + +extension String { + func delete(prefix: String) -> String? { + guard hasPrefix(prefix) else { return nil } + return String(dropFirst(prefix.count)) + } +} diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index 84c0fd7..a5fb506 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -13,7 +13,7 @@ final class ProjectLoader { private let xcodeProj: XcodeProj private let native: PBXProj private let path: Path - fileprivate let preferConfig: String? + let preferConfig: String? init(path: Path, preferConfig: String?) throws { self.path = path @@ -31,7 +31,7 @@ final class ProjectLoader { } func model() throws -> XCode.Project { - XCode.Project( + return XCode.Project( name: rootProject?.name ?? path.lastComponentWithoutExtension, workspacePath: workspacePath.string, projectPath: path.string, @@ -79,7 +79,8 @@ final class ProjectLoader { } private var localPackages: [XCode.LocalPackage] { - (rootProject?.localPackages ?? []).map { package in + print("local") + return (rootProject?.localPackages ?? []).map { package in .init( name: package.name, relativePath: package.relativePath @@ -87,7 +88,7 @@ final class ProjectLoader { } } - fileprivate func packageFiles(targetName: String) -> [FileLoader] { + func packageFiles(targetName: String) -> [FileLoader] { allFiles .compactMap { FileLoader(native: $0, project: self) } .filter { file in @@ -114,484 +115,6 @@ final class ProjectLoader { } } -private struct TargetLoader { - let native: PBXNativeTarget - unowned let project: ProjectLoader - let configList: ConfigListLoader - let mergedConfig: [String: [String: XCode.JSONValue]] - - init(native: PBXNativeTarget, project: ProjectLoader, defaultConfigList: ConfigListLoader?) { - self.native = native - self.project = project - configList = ConfigListLoader(native: native.buildConfigurationList) - mergedConfig = configList.merge(defaultConfigList) - } - - var name: String { native.name } - - var model: XCode.Target { - let buildPhases = native.buildPhases.map(XCode.BuildPhase.init) - let synchronizedFiles = synchronizedGroupFiles - - let sourceFiles = unique( - fileModels(from: sourceBuildFiles, buildPhase: .sources) + - synchronizedFiles.filter { file in - file.category == .source - }.map(\.file) - ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } - let headerFiles = unique( - fileModels(from: headerBuildFiles, buildPhase: .headers) + - packageHeaders + - synchronizedFiles.filter { file in - file.category == .header - }.map(\.file) - ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } - let resourceFiles = unique( - fileModels(from: resourceBuildFiles, buildPhase: .resources) + - synchronizedFiles.filter { file in - file.category == .resource - }.map(\.file) - ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } - let frameworkFiles = fileModels(from: frameworkBuildFiles, buildPhase: .frameworks) - let copyFiles = fileModels(from: copyBuildFiles, buildPhase: .copyFiles) - - let knownPaths = Set( - (sourceFiles + headerFiles + resourceFiles + frameworkFiles + copyFiles) - .compactMap(\.path) - ) - - let otherFiles = project.packageFiles(targetName: name) - .filter { file in - guard let path = file.relativePath else { return false } - return !knownPaths.contains(path) - } - .map { $0.file(buildPhase: nil, compilerFlags: nil, attributes: []) } + - synchronizedFiles.filter { file in - file.category == .other && !knownPaths.contains(file.file.path ?? "") - }.map(\.file) - - return XCode.Target( - name: name, - productName: native.productName, - productType: native.productType?.rawValue, - configs: mergedConfig, - metadata: metadata, - buildPhases: buildPhases, - files: .init( - sources: sourceFiles, - headers: headerFiles, - resources: resourceFiles, - frameworks: frameworkFiles, - copyFiles: copyFiles, - others: unique(otherFiles) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } - ), - dependencies: dependencies - ) - } - - private var metadata: XCode.TargetMetadata { - let settings = selectedConfig ?? [:] - - return .init( - bundleID: settings["PRODUCT_BUNDLE_IDENTIFIER"]?.stringValue, - moduleName: settings["PRODUCT_MODULE_NAME"]?.stringValue ?? settings["PRODUCT_NAME"]?.stringValue, - infoPlist: settings["INFOPLIST_FILE"]?.stringValue, - deploymentTargets: deploymentTargets(from: settings), - codeSign: .init( - developmentTeam: settings["DEVELOPMENT_TEAM"]?.stringValue, - codeSignStyle: settings["CODE_SIGN_STYLE"]?.stringValue, - codeSignIdentity: settings["CODE_SIGN_IDENTITY"]?.stringValue - ) - ) - } - - private var dependencies: XCode.Dependencies { - let frameworkNames = frameworkBuildFiles.compactMap { buildFile -> String? in - guard let file = buildFile.file else { return nil } - let wrapped = FileLoader(native: file, project: project) - guard !wrapped.isSDKFramework else { return nil } - return wrapped.name - } - - let sdkFrameworks = frameworkBuildFiles.compactMap { buildFile -> String? in - guard let file = buildFile.file else { return nil } - let wrapped = FileLoader(native: file, project: project) - guard wrapped.isSDKFramework else { return nil } - return wrapped.frameworkName - } - - let packageProducts = (native.packageProductDependencies ?? []).map { dependency in - XCode.PackageProductDependency( - productName: dependency.productName, - package: dependency.package?.repositoryURL - ) - } - - let targetDependencies = native.dependencies.compactMap { dependency in - dependency.target?.name ?? dependency.name - } - - return .init( - targets: Set(targetDependencies).sorted(), - packageProducts: unique(packageProducts) { "\($0.productName)|\($0.package ?? "")" }, - frameworks: Set(frameworkNames.compactMap { $0 }).sorted(), - sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted() - ) - } - - private var selectedConfig: [String: XCode.JSONValue]? { - if let prefer = project.preferConfig, let hit = mergedConfig[prefer] { - return hit - } - return mergedConfig - .sorted { $0.key < $1.key } - .map(\.value) - .first - } - - private var packageHeaders: [XCode.File] { - project.packageFiles(targetName: name) - .filter { file in - guard let type = file.fileType else { return false } - return type == "sourcecode.c.h" || type == "sourcecode.cpp.h" - } - .map { $0.file(buildPhase: BuildPhase.headers.rawValue, compilerFlags: nil, attributes: []) } - } - - private var sourceBuildFiles: [PBXBuildFile] { - (try? native.sourcesBuildPhase()?.files) ?? [] - } - - private var headerBuildFiles: [PBXBuildFile] { - native.buildPhases - .compactMap { $0 as? PBXHeadersBuildPhase } - .compactMap(\.files) - .flatMap { $0 } - } - - private var resourceBuildFiles: [PBXBuildFile] { - (try? native.resourcesBuildPhase()?.files) ?? [] - } - - private var frameworkBuildFiles: [PBXBuildFile] { - (try? native.frameworksBuildPhase()?.files) ?? [] - } - - private var copyBuildFiles: [PBXBuildFile] { - native.buildPhases - .compactMap { $0 as? PBXCopyFilesBuildPhase } - .compactMap(\.files) - .flatMap { $0 } - } - - private var synchronizedGroupFiles: [SynchronizedFile] { - (native.fileSystemSynchronizedGroups ?? []).flatMap { group in - synchronizedFiles(in: group) - } - } - - private func synchronizedFiles(in group: PBXFileSystemSynchronizedRootGroup) -> [SynchronizedFile] { - guard let relativeRoot = group.path else { return [] } - let root = project.workspacePath + relativeRoot - guard root.exists else { return [] } - - let excluded = synchronizedExcludedPaths(group) - let compilerFlags = synchronizedCompilerFlags(group) - - return (try? root.recursiveChildren())? - .filter(\.isFile) - .compactMap { file in - let relative = file.string.delete(prefix: project.workspacePath.string + "/") - guard let relative else { return nil } - - let pathInGroup = relative.delete(prefix: relativeRoot + "/") ?? "" - guard !excluded.contains(pathInGroup), !excluded.contains(relative) else { - return nil - } - - return SynchronizedFile( - path: relative, - fullPath: file.string, - compilerFlags: compilerFlags[pathInGroup] ?? compilerFlags[relative] - ) - } ?? [] - } - - private func synchronizedExcludedPaths(_ group: PBXFileSystemSynchronizedRootGroup) -> Set { - let buildExceptions = (group.exceptions ?? []).compactMap { - $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet - }.filter { exception in - exception.target?.name == name - } - - let membershipExceptions = buildExceptions - .compactMap(\.membershipExceptions) - .flatMap { $0 } - - return Set(membershipExceptions) - } - - private func synchronizedCompilerFlags(_ group: PBXFileSystemSynchronizedRootGroup) -> [String: String] { - let buildExceptions = (group.exceptions ?? []).compactMap { - $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet - }.filter { exception in - exception.target?.name == name - } - - return buildExceptions - .compactMap(\.additionalCompilerFlagsByRelativePath) - .reduce(into: [:]) { result, next in - result.merge(next) { first, _ in first } - } - } - - private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [XCode.File] { - buildFiles.compactMap { buildFile in - guard let file = buildFile.file else { return nil } - return FileLoader(native: file, project: project).file( - buildPhase: buildPhase.rawValue, - compilerFlags: buildFile.compilerFlags, - attributes: buildFile.attributes ?? [] - ) - } - } - - private func deploymentTargets(from settings: [String: XCode.JSONValue]) -> [String: String] { - [ - "iOS": settings["IPHONEOS_DEPLOYMENT_TARGET"]?.stringValue, - "macOS": settings["MACOSX_DEPLOYMENT_TARGET"]?.stringValue, - "tvOS": settings["TVOS_DEPLOYMENT_TARGET"]?.stringValue, - "watchOS": settings["WATCHOS_DEPLOYMENT_TARGET"]?.stringValue, - "driverKit": settings["DRIVERKIT_DEPLOYMENT_TARGET"]?.stringValue, - ].compactMapValues { $0 } - } -} - -private struct ConfigListLoader: Hashable { - let native: XCConfigurationList? - - var configs: [String: [String: XCode.JSONValue]] { - (native?.buildConfigurations ?? []).map { config in - ( - config.name, - config.buildSettings.mapValues(XCode.JSONValue.normalize) - ) - }.toDictionary() - } - - func merge(_ defaultConfig: ConfigListLoader?) -> [String: [String: XCode.JSONValue]] { - guard let defaultConfig else { - return configs - } - - let defaults = defaultConfig.configs - return configs.map { name, current in - let merged = current.merging(defaults[name] ?? [:]) { first, _ in - first - } - return ( - name, - merged - ) - }.toDictionary() - } - - static func == (lhs: ConfigListLoader, rhs: ConfigListLoader) -> Bool { - lhs.native?.uuid == rhs.native?.uuid - } - - func hash(into hasher: inout Hasher) { - hasher.combine(native?.uuid) - } -} - -private struct FileLoader { - let native: PBXFileElement - unowned let project: ProjectLoader - - var name: String? { - native.name ?? native.path - } - - var label: String? { - project.transformToLabel(relativePath) - } - - var packageName: String? { - relativePath?.split(separator: "/").first.map(String.init) - } - - var relativePath: String? { - let root = project.workspacePath.string - guard let fullPath else { return nil } - guard fullPath.hasPrefix(root + "/") else { return nil } - return fullPath.delete(prefix: root + "/") - } - - var fullPath: String? { - try? native.fullPath(sourceRoot: project.workspacePath.string) - } - - var fileType: String? { - ref?.lastKnownFileType ?? ref?.explicitFileType - } - - var sourceTree: String { - native.sourceTree?.description ?? "" - } - - var frameworkName: String? { - name?.replacingOccurrences(of: ".framework", with: "") - .replacingOccurrences(of: ".xcframework", with: "") - } - - var isSDKFramework: Bool { - sourceTree == PBXSourceTree.sdkRoot.description || - sourceTree == PBXSourceTree.developerDir.description - } - - private var ref: PBXFileReference? { - native as? PBXFileReference - } - - func file(buildPhase: String?, compilerFlags: String?, attributes: [String]) -> XCode.File { - .init( - name: name, - path: relativePath ?? native.path, - fullPath: fullPath, - label: label, - fileType: fileType, - sourceTree: sourceTree, - buildPhase: buildPhase, - compilerFlags: compilerFlags, - attributes: attributes - ) - } -} - -private struct SynchronizedFile { - enum Category { - case source - case header - case resource - case other - } - - let path: String - let fullPath: String - let compilerFlags: String? - - var name: String { - Path(path).lastComponent - } - - var fileType: String? { - switch Path(path).extension?.lowercased() { - case "swift": return "sourcecode.swift" - case "m": return "sourcecode.c.objc" - case "mm": return "sourcecode.cpp.objcpp" - case "c": return "sourcecode.c.c" - case "cc", "cp", "cpp", "cxx": return "sourcecode.cpp.cpp" - case "h": return "sourcecode.c.h" - case "hh", "hpp", "hxx": return "sourcecode.cpp.h" - case "metal": return "sourcecode.metal" - case "xib": return "file.xib" - case "storyboard": return "file.storyboard" - case "xcassets": return "folder.assetcatalog" - case "strings": return "text.plist.strings" - case "stringsdict": return "text.plist.stringsdict" - case "plist": return "text.plist.xml" - case "xcframework": return "wrapper.xcframework" - case "framework": return "wrapper.framework" - default: return nil - } - } - - var category: Category { - switch fileType { - case "sourcecode.swift", - "sourcecode.c.objc", - "sourcecode.cpp.objcpp", - "sourcecode.c.c", - "sourcecode.cpp.cpp", - "sourcecode.metal": - return .source - case "sourcecode.c.h", - "sourcecode.cpp.h": - return .header - case "file.xib", - "file.storyboard", - "folder.assetcatalog", - "text.plist.strings", - "text.plist.stringsdict", - "text.plist.xml": - return .resource - default: - return .other - } - } - - var file: XCode.File { - .init( - name: name, - path: path, - fullPath: fullPath, - label: nil, - fileType: fileType, - sourceTree: "", - buildPhase: buildPhase, - compilerFlags: compilerFlags, - attributes: [] - ) - } - - private var buildPhase: String? { - switch category { - case .source: return BuildPhase.sources.rawValue - case .header: return BuildPhase.headers.rawValue - case .resource: return BuildPhase.resources.rawValue - case .other: return nil - } - } -} - -private extension XCode.BuildPhase { - init(phase: PBXBuildPhase) { - let destination: XCode.CopyFilesDestination? - if let copyPhase = phase as? PBXCopyFilesBuildPhase { - destination = .init( - path: copyPhase.dstPath, - subfolder: copyPhase.dstSubfolder?.rawValue, - subfolderSpec: copyPhase.dstSubfolderSpec?.rawValue - ) - } else { - destination = nil - } - - self.init( - type: phase.buildPhase.rawValue, - name: phase.name(), - files: (phase.files ?? []).compactMap { buildFile in - XCode.BuildPhaseFile( - name: (buildFile.file as? PBXFileReference)?.name ?? - (buildFile.file as? PBXFileReference)?.path ?? - buildFile.product?.productName, - path: buildFile.file?.path, - fileType: (buildFile.file as? PBXFileReference)?.lastKnownFileType, - compilerFlags: buildFile.compilerFlags, - attributes: buildFile.attributes ?? [] - ) - }, - inputPaths: (phase as? PBXShellScriptBuildPhase)?.inputPaths ?? [], - outputPaths: (phase as? PBXShellScriptBuildPhase)?.outputPaths ?? [], - inputFileListPaths: phase.inputFileListPaths ?? [], - outputFileListPaths: phase.outputFileListPaths ?? [], - shellScript: (phase as? PBXShellScriptBuildPhase)?.shellScript, - destination: destination - ) - } -} - private extension XCRemoteSwiftPackageReference.VersionRequirement { var stringValue: String { switch self { @@ -610,82 +133,3 @@ private extension XCRemoteSwiftPackageReference.VersionRequirement { } } } - -private extension XCode.JSONValue { - var stringValue: String? { - if case let .string(value) = self { - return value - } - return nil - } - - static func normalize(_ input: Any) -> Self { - switch input { - case let value as Self: - return value - case let value as String: - return .string(value) - case let value as Bool: - return .bool(value) - case let value as Int: - return .int(value) - case let value as Double: - return .double(value) - case let value as NSNumber: - if CFGetTypeID(value) == CFBooleanGetTypeID() { - return .bool(value.boolValue) - } - if floor(value.doubleValue) == value.doubleValue { - return .int(value.intValue) - } - return .double(value.doubleValue) - case let value as [String: Any]: - return .object(value.mapValues(Self.normalize)) - case let value as [Any]: - return .array(value.map(Self.normalize)) - default: - return .string(String(describing: input)) - } - } -} - -private extension PBXFileElement { - func flatten() throws -> [PBXFileElement] { - if let group = self as? PBXGroup { - return group.children.flatMap { (try? $0.flatten()) ?? [] } - } - - if let ref = self as? PBXFileReference { - return [ref] - } - - return [] - } -} - -private extension Sequence { - func toDictionary() -> [K: V] where Element == (K, V) { - Dictionary(uniqueKeysWithValues: self) - } -} - -private func unique(_ values: [T], key: (T) -> String) -> [T] { - var result: [T] = [] - var seen = Set() - - for value in values { - let id = key(value) - if seen.insert(id).inserted { - result.append(value) - } - } - - return result -} - -private extension String { - func delete(prefix: String) -> String? { - guard hasPrefix(prefix) else { return nil } - return String(dropFirst(prefix.count)) - } -} diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift new file mode 100644 index 0000000..132a3b3 --- /dev/null +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -0,0 +1,293 @@ +import Foundation +import PathKit +import XcodeProj + +struct TargetLoader { + let native: PBXNativeTarget + unowned let project: ProjectLoader + let configList: ConfigListLoader + let mergedConfig: [String: XCode.BuildSettings] + + init(native: PBXNativeTarget, project: ProjectLoader, defaultConfigList: ConfigListLoader?) { + self.native = native + self.project = project + configList = ConfigListLoader(native: native.buildConfigurationList) + mergedConfig = configList.merge(defaultConfigList) + } + + var name: String { native.name } + + var model: XCode.Target { + let buildPhases = native.buildPhases.map(XCode.BuildPhase.init) + let synchronizedFiles = synchronizedGroupFiles + + let sourceFiles = unique( + fileModels(from: sourceBuildFiles, buildPhase: .sources) + + synchronizedFiles.filter { file in + file.category == .source + }.map(\.file) + ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let headerFiles = unique( + fileModels(from: headerBuildFiles, buildPhase: .headers) + + packageHeaders + + synchronizedFiles.filter { file in + file.category == .header + }.map(\.file) + ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let resourceFiles = unique( + fileModels(from: resourceBuildFiles, buildPhase: .resources) + + synchronizedFiles.filter { file in + file.category == .resource + }.map(\.file) + ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let frameworkFiles = fileModels(from: frameworkBuildFiles, buildPhase: .frameworks) + let copyFiles = fileModels(from: copyBuildFiles, buildPhase: .copyFiles) + + let knownPaths = Set( + (sourceFiles + headerFiles + resourceFiles + frameworkFiles + copyFiles) + .compactMap(\.path) + ) + + let otherFiles = project.packageFiles(targetName: name) + .filter { file in + guard let path = file.relativePath else { return false } + return !knownPaths.contains(path) + } + .map { $0.file(buildPhase: nil, compilerFlags: nil, attributes: []) } + + synchronizedFiles.filter { file in + file.category == .other && !knownPaths.contains(file.file.path ?? "") + }.map(\.file) + + return XCode.Target( + name: name, + productName: native.productName, + productType: native.productType?.rawValue, + configs: mergedConfig, + metadata: metadata, + buildPhases: buildPhases, + files: .init( + sources: sourceFiles, + headers: headerFiles, + resources: resourceFiles, + frameworks: frameworkFiles, + copyFiles: copyFiles, + others: unique(otherFiles) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + ), + dependencies: dependencies + ) + } + + private var metadata: XCode.TargetMetadata { + let settings = selectedConfig ?? .init(name: "", setting: [:]) + + return .init( + bundleID: settings["PRODUCT_BUNDLE_IDENTIFIER"], + moduleName: settings["PRODUCT_MODULE_NAME"] ?? settings["PRODUCT_NAME"], + infoPlist: settings["INFOPLIST_FILE"], + deploymentTargets: deploymentTargets(from: settings), + codeSign: .init( + developmentTeam: settings["DEVELOPMENT_TEAM"], + codeSignStyle: settings["CODE_SIGN_STYLE"], + codeSignIdentity: settings["CODE_SIGN_IDENTITY"] + ) + ) + } + + private var dependencies: XCode.Dependencies { + let frameworkNames = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard !wrapped.isSDKFramework else { return nil } + return wrapped.name + } + + let sdkFrameworks = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.isSDKFramework else { return nil } + return wrapped.frameworkName + } + + let packageProducts = (native.packageProductDependencies ?? []).map { dependency in + XCode.PackageProductDependency( + productName: dependency.productName, + package: dependency.package?.repositoryURL + ) + } + + let targetDependencies = native.dependencies.compactMap { dependency in + dependency.target?.name ?? dependency.name + } + + return .init( + targets: Set(targetDependencies).sorted(), + packageProducts: unique(packageProducts) { "\($0.productName)|\($0.package ?? "")" }, + frameworks: Set(frameworkNames.compactMap { $0 }).sorted(), + sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted() + ) + } + + private var selectedConfig: XCode.BuildSettings? { + if let prefer = project.preferConfig, let hit = mergedConfig[prefer] { + return hit + } + return mergedConfig + .sorted { $0.key < $1.key } + .map(\.value) + .first + } + + private var packageHeaders: [XCode.File] { + project.packageFiles(targetName: name) + .filter { file in + guard let type = file.fileType else { return false } + return type == "sourcecode.c.h" || type == "sourcecode.cpp.h" + } + .map { $0.file(buildPhase: BuildPhase.headers.rawValue, compilerFlags: nil, attributes: []) } + } + + private var sourceBuildFiles: [PBXBuildFile] { + (try? native.sourcesBuildPhase()?.files) ?? [] + } + + private var headerBuildFiles: [PBXBuildFile] { + native.buildPhases + .compactMap { $0 as? PBXHeadersBuildPhase } + .compactMap(\.files) + .flatMap { $0 } + } + + private var resourceBuildFiles: [PBXBuildFile] { + (try? native.resourcesBuildPhase()?.files) ?? [] + } + + private var frameworkBuildFiles: [PBXBuildFile] { + (try? native.frameworksBuildPhase()?.files) ?? [] + } + + private var copyBuildFiles: [PBXBuildFile] { + native.buildPhases + .compactMap { $0 as? PBXCopyFilesBuildPhase } + .compactMap(\.files) + .flatMap { $0 } + } + + private var synchronizedGroupFiles: [SynchronizedFile] { + (native.fileSystemSynchronizedGroups ?? []).flatMap { group in + synchronizedFiles(in: group) + } + } + + private func synchronizedFiles(in group: PBXFileSystemSynchronizedRootGroup) -> [SynchronizedFile] { + guard let relativeRoot = group.path else { return [] } + let root = project.workspacePath + relativeRoot + guard root.exists else { return [] } + + let excluded = synchronizedExcludedPaths(group) + let compilerFlags = synchronizedCompilerFlags(group) + + return (try? root.recursiveChildren())? + .filter(\.isFile) + .compactMap { file in + let relative = file.string.delete(prefix: project.workspacePath.string + "/") + guard let relative else { return nil } + + let pathInGroup = relative.delete(prefix: relativeRoot + "/") ?? "" + guard !excluded.contains(pathInGroup), !excluded.contains(relative) else { + return nil + } + + return SynchronizedFile( + path: relative, + fullPath: file.string, + compilerFlags: compilerFlags[pathInGroup] ?? compilerFlags[relative] + ) + } ?? [] + } + + private func synchronizedExcludedPaths(_ group: PBXFileSystemSynchronizedRootGroup) -> Set { + let buildExceptions = (group.exceptions ?? []).compactMap { + $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet + }.filter { exception in + exception.target?.name == name + } + + let membershipExceptions = buildExceptions + .compactMap(\.membershipExceptions) + .flatMap { $0 } + + return Set(membershipExceptions) + } + + private func synchronizedCompilerFlags(_ group: PBXFileSystemSynchronizedRootGroup) -> [String: String] { + let buildExceptions = (group.exceptions ?? []).compactMap { + $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet + }.filter { exception in + exception.target?.name == name + } + + return buildExceptions + .compactMap(\.additionalCompilerFlagsByRelativePath) + .reduce(into: [:]) { result, next in + result.merge(next) { first, _ in first } + } + } + + private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [XCode.File] { + buildFiles.compactMap { buildFile in + guard let file = buildFile.file else { return nil } + return FileLoader(native: file, project: project).file( + buildPhase: buildPhase.rawValue, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? [] + ) + } + } + + private func deploymentTargets(from settings: XCode.BuildSettings) -> [String: String] { + [ + "iOS": settings["IPHONEOS_DEPLOYMENT_TARGET"], + "macOS": settings["MACOSX_DEPLOYMENT_TARGET"], + "tvOS": settings["TVOS_DEPLOYMENT_TARGET"], + "watchOS": settings["WATCHOS_DEPLOYMENT_TARGET"], + "driverKit": settings["DRIVERKIT_DEPLOYMENT_TARGET"], + ].compactMapValues { $0 } + } +} + +private extension XCode.BuildPhase { + init(phase: PBXBuildPhase) { + let destination: XCode.CopyFilesDestination? + if let copyPhase = phase as? PBXCopyFilesBuildPhase { + destination = .init( + path: copyPhase.dstPath, + subfolder: copyPhase.dstSubfolder?.rawValue, + subfolderSpec: copyPhase.dstSubfolderSpec?.rawValue + ) + } else { + destination = nil + } + + self.init( + type: phase.buildPhase.rawValue, + name: phase.name(), + files: (phase.files ?? []).compactMap { buildFile in + XCode.BuildPhaseFile( + name: (buildFile.file as? PBXFileReference)?.name ?? + (buildFile.file as? PBXFileReference)?.path ?? + buildFile.product?.productName, + path: buildFile.file?.path, + fileType: (buildFile.file as? PBXFileReference)?.lastKnownFileType, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? [] + ) + }, + inputPaths: (phase as? PBXShellScriptBuildPhase)?.inputPaths ?? [], + outputPaths: (phase as? PBXShellScriptBuildPhase)?.outputPaths ?? [], + inputFileListPaths: phase.inputFileListPaths ?? [], + outputFileListPaths: phase.outputFileListPaths ?? [], + shellScript: (phase as? PBXShellScriptBuildPhase)?.shellScript, + destination: destination + ) + } +} diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift new file mode 100644 index 0000000..48aaba3 --- /dev/null +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -0,0 +1,49 @@ +import Foundation +import XcodeProj + +extension BuildSetting { + var value: String { + switch self { + case .string(let value): + return value + case .array(let value): + return value.joined(separator: " ") + } + } +} + +public extension XCode { + struct BuildSettings: Encodable { + public let name: String + public let setting: [String: String] + + public init(name: String, setting: [String: String]) { + self.name = name + self.setting = setting + } + + init(_ config: XCBuildConfiguration) { + self.init( + name: config.name, + setting: config.buildSettings.mapValues(\.value) + ) + } + + func merged(with defaults: BuildSettings?) -> BuildSettings { + guard let defaults else { + return self + } + + return .init( + name: name, + setting: setting.merging(defaults.setting) { current, _ in + current + } + ) + } + + public subscript(key: String) -> String? { + setting[key] + } + } +} diff --git a/Sources/Xcode2/Model/Project/XCode+Project.swift b/Sources/Xcode2/Model/Project/XCode+Project.swift index de70798..25def8f 100644 --- a/Sources/Xcode2/Model/Project/XCode+Project.swift +++ b/Sources/Xcode2/Model/Project/XCode+Project.swift @@ -1,12 +1,12 @@ import PathKit public extension XCode { - struct Project: Codable { + struct Project: Encodable { public let name: String public let workspacePath: String public let projectPath: String public let preferConfig: String? - public let configs: [String: [String: JSONValue]] + public let configs: [String: BuildSettings] public let packages: Packages public let targets: [Target] diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift index 6a32224..ee70a7e 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -1,9 +1,9 @@ public extension XCode { - struct Target: Codable { + struct Target: Encodable { public let name: String public let productName: String? public let productType: String? - public let configs: [String: [String: JSONValue]] + public let configs: [String: BuildSettings] public let metadata: TargetMetadata public let buildPhases: [BuildPhase] public let files: Files diff --git a/Sources/Xcode2/RoadmapTreeBuilder.swift b/Sources/Xcode2/RoadmapTreeBuilder.swift index 959201c..f00f1ee 100644 --- a/Sources/Xcode2/RoadmapTreeBuilder.swift +++ b/Sources/Xcode2/RoadmapTreeBuilder.swift @@ -482,7 +482,7 @@ private extension XCode.Target { } var appleFamiliesLiteral: String? { - guard let raw = selectedSettings["TARGETED_DEVICE_FAMILY"]?.summaryString else { return nil } + guard let raw = selectedSettings["TARGETED_DEVICE_FAMILY"] else { return nil } let families = raw .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } @@ -499,14 +499,14 @@ private extension XCode.Target { return "[" + families.map { #""\#($0)""# }.joined(separator: ", ") + "]" } - var selectedSettings: [String: XCode.JSONValue] { + var selectedSettings: XCode.BuildSettings { if let debug = configs["Debug"] { return debug } if let first = configs.keys.sorted().first, let value = configs[first] { return value } - return [:] + return .init(name: "", setting: [:]) } func targetLibraryDeps(project: XCode.Project) -> [String] { @@ -672,25 +672,6 @@ private extension Path { } } -private extension XCode.JSONValue { - var summaryString: String? { - switch self { - case .string(let value): - return value - case .int(let value): - return String(value) - case .double(let value): - return String(value) - case .bool(let value): - return value ? "true" : "false" - case .array(let values): - return values.compactMap(\.summaryString).joined(separator: ",") - case .object, .null: - return nil - } - } -} - private extension String { var swiftPackageProductNames: [String] { let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# diff --git a/Sources/Xcode2/Support/XCode+JSONValue.swift b/Sources/Xcode2/Support/XCode+JSONValue.swift deleted file mode 100644 index bff800a..0000000 --- a/Sources/Xcode2/Support/XCode+JSONValue.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -public extension XCode { - enum JSONValue: Codable { - case string(String) - case bool(Bool) - case int(Int) - case double(Double) - case array([JSONValue]) - case object([String: JSONValue]) - case null - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Int.self) { - self = .int(value) - } else if let value = try? container.decode(Double.self) { - self = .double(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([String: JSONValue].self) { - self = .object(value) - } else if let value = try? container.decode([JSONValue].self) { - self = .array(value) - } else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Unsupported JSON value" - ) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - switch self { - case .string(let value): - try container.encode(value) - case .bool(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - case .double(let value): - try container.encode(value) - case .array(let value): - try container.encode(value) - case .object(let value): - try container.encode(value) - case .null: - try container.encodeNil() - } - } - } -} diff --git a/Sources/Xcode2/TargetSummaryFormatter.swift b/Sources/Xcode2/TargetSummaryFormatter.swift index e1452fb..8fc57f9 100644 --- a/Sources/Xcode2/TargetSummaryFormatter.swift +++ b/Sources/Xcode2/TargetSummaryFormatter.swift @@ -50,9 +50,9 @@ public extension XCode { if let selectedConfigName, let settings = target.configs[selectedConfigName] { lines.append("") lines.append("Settings [\(selectedConfigName)]:") - for key in settings.keys.sorted() { - guard let value = settings[key] else { continue } - lines.append(" \(key) = \(value.summaryText)") + for key in settings.setting.keys.sorted() { + guard let value = settings.setting[key] else { continue } + lines.append(" \(key) = \(value)") } } @@ -98,28 +98,3 @@ private extension XCode.PackageProductDependency { return productName } } - -private extension XCode.JSONValue { - var summaryText: String { - switch self { - case .string(let value): - return value - case .bool(let value): - return value ? "true" : "false" - case .int(let value): - return String(value) - case .double(let value): - return String(value) - case .array(let value): - return "[" + value.map(\.summaryText).joined(separator: ", ") + "]" - case .object(let value): - let items = value.keys.sorted().compactMap { key -> String? in - guard let value = value[key] else { return nil } - return "\(key): \(value.summaryText)" - } - return "{" + items.joined(separator: ", ") + "}" - case .null: - return "null" - } - } -} diff --git a/Tests/XCode2Tests/BuildSettingsTests.swift b/Tests/XCode2Tests/BuildSettingsTests.swift new file mode 100644 index 0000000..1711f28 --- /dev/null +++ b/Tests/XCode2Tests/BuildSettingsTests.swift @@ -0,0 +1,17 @@ +import XCTest +@testable import XCode2 + +final class BuildSettingsTests: XCTestCase { + func testBuildSettingsSupportsStringAndArrayValues() { + let settings = XCode.BuildSettings( + name: "Debug", + setting: [ + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.app", + "TARGETED_DEVICE_FAMILY": "1 2", + ] + ) + + XCTAssertEqual(settings["PRODUCT_BUNDLE_IDENTIFIER"], "com.example.app") + XCTAssertEqual(settings["TARGETED_DEVICE_FAMILY"], "1 2") + } +} diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift index 27110c6..41bfc01 100644 --- a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift +++ b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift @@ -8,15 +8,22 @@ final class TargetSummaryFormatterTests: XCTestCase { productName: "Example", productType: "com.apple.product-type.application", configs: [ - "Debug": [ - "SWIFT_VERSION": .string("5.9"), - ], - "Release": [ - "INFOPLIST_FILE": .string("Example/Info.plist"), - "IPHONEOS_DEPLOYMENT_TARGET": .string("16.0"), - "PRODUCT_BUNDLE_IDENTIFIER": .string("com.example.Example"), - "SWIFT_VERSION": .string("5.9"), - ], + "Debug": .init( + name: "Debug", + setting: [ + "SWIFT_VERSION": "5.9", + ] + ), + "Release": .init( + name: "Release", + setting: [ + "INFOPLIST_FILE": "Example/Info.plist", + "IPHONEOS_DEPLOYMENT_TARGET": "16.0", + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.Example", + "SWIFT_VERSION": "5.9", + "TARGETED_DEVICE_FAMILY": "1 2", + ] + ), ], metadata: .init( bundleID: "com.example.Example", @@ -92,5 +99,6 @@ final class TargetSummaryFormatterTests: XCTestCase { XCTAssertTrue(summary.contains("SDK Frameworks:")) XCTAssertTrue(summary.contains("Settings [Release]:")) XCTAssertTrue(summary.contains("PRODUCT_BUNDLE_IDENTIFIER = com.example.Example")) + XCTAssertTrue(summary.contains("TARGETED_DEVICE_FAMILY = 1 2")) } } From 4802accc6c7ad00fb99ccfbe53bf67b106c7184e Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 27 Apr 2026 20:11:26 +0800 Subject: [PATCH 005/173] Refine XCode2 build settings namespaces --- .../Xcode2/Loader/XCode+ProjectLoader.swift | 43 ++++- .../Xcode2/Loader/XCode+TargetLoader.swift | 24 +-- .../Config/XCode+BuildSettings+Metadata.swift | 35 ++++ .../Config/XCode+BuildSettings+PList.swift | 162 ++++++++++++++++++ .../Config/XCode+BuildSettings+Platform.swift | 51 ++++++ .../Model/Config/XCode+BuildSettings.swift | 6 +- .../Model/Config/XCode+DeviceFamily.swift | 30 ++++ Sources/Xcode2/RoadmapTreeBuilder.swift | 27 ++- Sources/Xcode2/TargetSummaryFormatter.swift | 4 +- Tests/XCode2Tests/BuildSettingsTests.swift | 97 +++++++++++ Tests/XCode2Tests/ProjectLoaderTests.swift | 24 +++ 11 files changed, 463 insertions(+), 40 deletions(-) create mode 100644 Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift create mode 100644 Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift create mode 100644 Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift create mode 100644 Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift create mode 100644 Tests/XCode2Tests/ProjectLoaderTests.swift diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index a5fb506..e7db667 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -79,13 +79,34 @@ final class ProjectLoader { } private var localPackages: [XCode.LocalPackage] { - print("local") - return (rootProject?.localPackages ?? []).map { package in - .init( + let explicit = (rootProject?.localPackages ?? []).map { package in + XCode.LocalPackage( name: package.name, relativePath: package.relativePath ) } + return Self.mergeLocalPackages( + explicit: explicit, + discovered: discoveredLocalPackages + ) + } + + private var discoveredLocalPackages: [XCode.LocalPackage] { + allFiles + .compactMap { FileLoader(native: $0, project: self) } + .compactMap { file in + guard let relativePath = file.relativePath else { return nil } + guard let fullPath = file.fullPath else { return nil } + + let packageRoot = Path(fullPath) + guard packageRoot.isDirectory else { return nil } + guard (packageRoot + "Package.swift").exists else { return nil } + + return XCode.LocalPackage( + name: file.name ?? packageRoot.lastComponent, + relativePath: relativePath + ) + } } func packageFiles(targetName: String) -> [FileLoader] { @@ -113,6 +134,22 @@ final class ProjectLoader { return "//:\(package)/\(restPath)" } } + + static func mergeLocalPackages( + explicit: [XCode.LocalPackage], + discovered: [XCode.LocalPackage] + ) -> [XCode.LocalPackage] { + var result: [XCode.LocalPackage] = [] + var seen = Set() + + for package in explicit + discovered { + if seen.insert(package.relativePath).inserted { + result.append(package) + } + } + + return result + } } private extension XCRemoteSwiftPackageReference.VersionRequirement { diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index 132a3b3..f3fa304 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -81,14 +81,14 @@ struct TargetLoader { let settings = selectedConfig ?? .init(name: "", setting: [:]) return .init( - bundleID: settings["PRODUCT_BUNDLE_IDENTIFIER"], - moduleName: settings["PRODUCT_MODULE_NAME"] ?? settings["PRODUCT_NAME"], - infoPlist: settings["INFOPLIST_FILE"], - deploymentTargets: deploymentTargets(from: settings), + bundleID: settings.metadata.bundleID, + moduleName: settings.metadata.moduleName ?? settings.metadata.productName, + infoPlist: settings.plist.infoPlist, + deploymentTargets: settings.platform.deploymentTargets, codeSign: .init( - developmentTeam: settings["DEVELOPMENT_TEAM"], - codeSignStyle: settings["CODE_SIGN_STYLE"], - codeSignIdentity: settings["CODE_SIGN_IDENTITY"] + developmentTeam: settings.metadata.developmentTeam, + codeSignStyle: settings.metadata.codeSignStyle, + codeSignIdentity: settings.metadata.codeSignIdentity ) ) } @@ -243,16 +243,6 @@ struct TargetLoader { ) } } - - private func deploymentTargets(from settings: XCode.BuildSettings) -> [String: String] { - [ - "iOS": settings["IPHONEOS_DEPLOYMENT_TARGET"], - "macOS": settings["MACOSX_DEPLOYMENT_TARGET"], - "tvOS": settings["TVOS_DEPLOYMENT_TARGET"], - "watchOS": settings["WATCHOS_DEPLOYMENT_TARGET"], - "driverKit": settings["DRIVERKIT_DEPLOYMENT_TARGET"], - ].compactMapValues { $0 } - } } private extension XCode.BuildPhase { diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift new file mode 100644 index 0000000..2a6c082 --- /dev/null +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift @@ -0,0 +1,35 @@ +import Foundation + +public extension XCode.BuildSettings { + var metadata: Metadata { + .init(settings: self) + } + + struct Metadata { + fileprivate let settings: XCode.BuildSettings + + public var bundleID: String? { + settings["PRODUCT_BUNDLE_IDENTIFIER"] + } + + public var moduleName: String? { + settings["PRODUCT_MODULE_NAME"] + } + + public var productName: String? { + settings["PRODUCT_NAME"] + } + + public var developmentTeam: String? { + settings["DEVELOPMENT_TEAM"] + } + + public var codeSignStyle: String? { + settings["CODE_SIGN_STYLE"] + } + + public var codeSignIdentity: String? { + settings["CODE_SIGN_IDENTITY"] + } + } +} diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift new file mode 100644 index 0000000..aaadeb1 --- /dev/null +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift @@ -0,0 +1,162 @@ +import Foundation + +private let plistPrefix = "INFOPLIST_KEY_" + +public extension XCode.BuildSettings { + // MARK: Info.plist + + var plist: Plist { + .init(settings: self) + } + + var generatedPlist: GeneratedPlist { + .init(settings: self) + } + + struct Plist { + fileprivate let settings: XCode.BuildSettings + + /// "ABCDEF/Info.plist" + public var infoPlist: String? { + settings["INFOPLIST_FILE"] + } + + /// "LaunchScreen" + public var launch: String? { + plistValue("UILaunchStoryboardName") + } + + /// "Main" + public var storyboard: String? { + plistValue("UIMainStoryboardFile") + } + + /// INFOPLIST_KEY_ + /// INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad + public var keys: [String] { + settings.keys.filter { key in + key.hasPrefix(plistPrefix) + } + } + + private func plistValue(_ key: String) -> String? { + settings["\(plistPrefix)\(key)"] + } + } + + struct GeneratedPlist { + fileprivate let settings: XCode.BuildSettings + + /// "YES" + public var enabled: Bool { + settings["GENERATE_INFOPLIST_FILE"] == "YES" + } + + /// CFBundleVersion - CURRENT_PROJECT_VERSION + /// + /// Default Info.plist value: + /// CFBundleVersion -> $(CURRENT_PROJECT_VERSION) + public var currentProjectVersion: String? { + settings["CURRENT_PROJECT_VERSION"] + } + + /// CFBundleShortVersionString - MARKETING_VERSION + /// + /// Default Info.plist value: + /// CFBundleShortVersionString -> $(MARKETING_VERSION) + public var marketingVersion: String? { + settings["MARKETING_VERSION"] + } + + /// Default Info.plist value: + /// CFBundleName -> $(PRODUCT_NAME) + /// CFBundleIdentifier -> $(PRODUCT_BUNDLE_IDENTIFIER) + /// CFBundleExecutable -> $(EXECUTABLE_NAME) + /// CFBundlePackageType -> $(PRODUCT_BUNDLE_PACKAGE_TYPE) + /// CFBundleDevelopmentRegion -> $(DEVELOPMENT_LANGUAGE) + public var defaultInfoPlistKeyNotes: [String] { + [ + "CFBundleName -> $(PRODUCT_NAME)", + "CFBundleIdentifier -> $(PRODUCT_BUNDLE_IDENTIFIER)", + "CFBundleExecutable -> $(EXECUTABLE_NAME)", + "CFBundlePackageType -> $(PRODUCT_BUNDLE_PACKAGE_TYPE)", + "CFBundleDevelopmentRegion -> $(DEVELOPMENT_LANGUAGE)", + "CFBundleVersion -> $(CURRENT_PROJECT_VERSION)", + "CFBundleShortVersionString -> $(MARKETING_VERSION)", + ] + } + + /// GENERATED_INFOPLIST_FILE + /// + /// Render a minimal subset of INFOPLIST_KEY_* settings into plist XML + /// fragments so generated Info.plist files preserve common Xcode + /// build-setting customizations. + public var entries: [String] { + guard enabled else { return [] } + + return settings.plist.keys.sorted().flatMap { key -> [String] in + guard let value = settings[key] else { return [] } + + switch plistDecision(for: key) { + case .string: + return [plistKey(key), plistString(value)] + case .stringArray: + return [plistKey(key), plistStringArray(value)] + case .bool: + return [plistKey(key), plistBool(value)] + case .unknown: + return [] + } + } + } + + private func plistDecision(for key: String) -> PlistDecision { + switch key { + case "INFOPLIST_KEY_UIMainStoryboardFile", + "INFOPLIST_KEY_UILaunchStoryboardName": + return .string + case "INFOPLIST_KEY_UISupportedInterfaceOrientations": + return .stringArray + case "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": + return .bool + default: + return .unknown + } + } + + private func plistKey(_ key: String) -> String { + let newKey = key.delete(prefix: plistPrefix) ?? key + return "\(newKey)" + } + + private func plistBool(_ value: String) -> String { + value == "YES" ? "" : "" + } + + private func plistString(_ value: String) -> String { + "\(value)" + } + + private func plistStringArray(_ value: String) -> String { + let strings = value + .split(separator: " ") + .map(String.init) + .map(plistString) + .map { " \($0)" } + .joined(separator: "\n") + + return """ + + \(strings) + + """ + } + } +} + +private enum PlistDecision { + case string + case stringArray + case bool + case unknown +} diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift new file mode 100644 index 0000000..0f15db5 --- /dev/null +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift @@ -0,0 +1,51 @@ +import Foundation + +public extension XCode.BuildSettings { + var platform: Platform { + .init(settings: self) + } + + struct Platform { + fileprivate let settings: XCode.BuildSettings + + public var iOS: String? { + settings["IPHONEOS_DEPLOYMENT_TARGET"] + } + + public var macOS: String? { + settings["MACOSX_DEPLOYMENT_TARGET"] + } + + public var tvOS: String? { + settings["TVOS_DEPLOYMENT_TARGET"] + } + + public var watchOS: String? { + settings["WATCHOS_DEPLOYMENT_TARGET"] + } + + public var driverKit: String? { + settings["DRIVERKIT_DEPLOYMENT_TARGET"] + } + + public var deploymentTargets: [String: String] { + [ + "iOS": iOS, + "macOS": macOS, + "tvOS": tvOS, + "watchOS": watchOS, + "driverKit": driverKit, + ].compactMapValues { $0 } + } + + public var deviceFamily: [XCode.DeviceFamily] { + XCode.DeviceFamily.parse(settings["TARGETED_DEVICE_FAMILY"]) + } + + public var appleFamiliesLiteral: String? { + let families = deviceFamily.map(\.code) + guard !families.isEmpty else { return nil } + return "[" + families.map { #""\#($0)""# }.joined(separator: ", ") + "]" + } + } +} diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 48aaba3..4bfa1b0 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -15,7 +15,7 @@ extension BuildSetting { public extension XCode { struct BuildSettings: Encodable { public let name: String - public let setting: [String: String] + private let setting: [String: String] public init(name: String, setting: [String: String]) { self.name = name @@ -45,5 +45,9 @@ public extension XCode { public subscript(key: String) -> String? { setting[key] } + + var keys: [String] { + Array(setting.keys) + } } } diff --git a/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift b/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift new file mode 100644 index 0000000..ae6fd70 --- /dev/null +++ b/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift @@ -0,0 +1,30 @@ +import Foundation + +public extension XCode { + enum DeviceFamily: String { + case iphone = "1" + case ipad = "2" + case appletv = "3" + case applewatch = "4" + case homepod = "5" + case mac = "6" + + public var code: String { + switch self { + case .iphone: return "iphone" + case .ipad: return "ipad" + case .appletv: return "appletv" + case .applewatch: return "watch" + case .homepod: return "homepod" + case .mac: return "mac" + } + } + + static func parse(_ rawValue: String?) -> [Self] { + rawValue? + .split { $0 == "," || $0 == " " } + .map(String.init) + .compactMap(Self.init(rawValue:)) ?? [] + } + } +} diff --git a/Sources/Xcode2/RoadmapTreeBuilder.swift b/Sources/Xcode2/RoadmapTreeBuilder.swift index f00f1ee..9aba240 100644 --- a/Sources/Xcode2/RoadmapTreeBuilder.swift +++ b/Sources/Xcode2/RoadmapTreeBuilder.swift @@ -406,13 +406,20 @@ public extension XCode { } private func generatedInfoPlist(target: XCode.Target) -> String { + let settings = target.selectedSettings let bundleID = target.metadata.bundleID ?? "com.example.\(target.name)" let bundleName = target.name + let shortVersion = settings.generatedPlist.marketingVersion ?? "1.0" + let bundleVersion = settings.generatedPlist.currentProjectVersion ?? "1" let packageType: String = switch target.roadmapKind { case .application: "APPL" case .framework: "FMWK" case .staticLibrary, .other: "BNDL" } + let extraEntries = settings.generatedPlist.entries + .map { " \($0.replacingOccurrences(of: "\n", with: "\n "))" } + .joined(separator: "\n") + let extraBlock = extraEntries.isEmpty ? "" : "\n\(extraEntries)" return """ @@ -425,11 +432,11 @@ public extension XCode { CFBundleExecutable \(bundleName) CFBundleShortVersionString - 1.0 + \(shortVersion) CFBundlePackageType \(packageType) CFBundleVersion - 1 + \(bundleVersion)\(extraBlock) """ @@ -482,21 +489,7 @@ private extension XCode.Target { } var appleFamiliesLiteral: String? { - guard let raw = selectedSettings["TARGETED_DEVICE_FAMILY"] else { return nil } - let families = raw - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .compactMap { code -> String? in - switch code { - case "1": return "iphone" - case "2": return "ipad" - case "3": return "tv" - case "4": return "watch" - default: return nil - } - } - guard !families.isEmpty else { return nil } - return "[" + families.map { #""\#($0)""# }.joined(separator: ", ") + "]" + selectedSettings.platform.appleFamiliesLiteral } var selectedSettings: XCode.BuildSettings { diff --git a/Sources/Xcode2/TargetSummaryFormatter.swift b/Sources/Xcode2/TargetSummaryFormatter.swift index 8fc57f9..29e976b 100644 --- a/Sources/Xcode2/TargetSummaryFormatter.swift +++ b/Sources/Xcode2/TargetSummaryFormatter.swift @@ -50,8 +50,8 @@ public extension XCode { if let selectedConfigName, let settings = target.configs[selectedConfigName] { lines.append("") lines.append("Settings [\(selectedConfigName)]:") - for key in settings.setting.keys.sorted() { - guard let value = settings.setting[key] else { continue } + for key in settings.keys.sorted() { + guard let value = settings[key] else { continue } lines.append(" \(key) = \(value)") } } diff --git a/Tests/XCode2Tests/BuildSettingsTests.swift b/Tests/XCode2Tests/BuildSettingsTests.swift index 1711f28..47d7df1 100644 --- a/Tests/XCode2Tests/BuildSettingsTests.swift +++ b/Tests/XCode2Tests/BuildSettingsTests.swift @@ -14,4 +14,101 @@ final class BuildSettingsTests: XCTestCase { XCTAssertEqual(settings["PRODUCT_BUNDLE_IDENTIFIER"], "com.example.app") XCTAssertEqual(settings["TARGETED_DEVICE_FAMILY"], "1 2") } + + func testBuildSettingsPlistHelpersReadExpectedKeys() { + let settings = XCode.BuildSettings( + name: "Release", + setting: [ + "GENERATE_INFOPLIST_FILE": "YES", + "INFOPLIST_FILE": "App/Info.plist", + "INFOPLIST_KEY_CFBundleDisplayName": "Example", + "INFOPLIST_KEY_UILaunchStoryboardName": "LaunchScreen", + "INFOPLIST_KEY_UIMainStoryboardFile": "Main", + "CURRENT_PROJECT_VERSION": "42", + "MARKETING_VERSION": "2.3", + ] + ) + + XCTAssertTrue(settings.generatedPlist.enabled) + XCTAssertEqual(settings.plist.infoPlist, "App/Info.plist") + XCTAssertEqual(settings.plist.launch, "LaunchScreen") + XCTAssertEqual(settings.plist.storyboard, "Main") + XCTAssertEqual(settings.plist.keys, ["INFOPLIST_KEY_CFBundleDisplayName"]) + XCTAssertEqual(settings.generatedPlist.currentProjectVersion, "42") + XCTAssertEqual(settings.generatedPlist.marketingVersion, "2.3") + } + + func testBuildSettingsPlatformHelpersReadDeploymentTargets() { + let settings = XCode.BuildSettings( + name: "Release", + setting: [ + "IPHONEOS_DEPLOYMENT_TARGET": "16.0", + "MACOSX_DEPLOYMENT_TARGET": "14.0", + "WATCHOS_DEPLOYMENT_TARGET": "10.0", + ] + ) + + XCTAssertEqual(settings.platform.iOS, "16.0") + XCTAssertEqual(settings.platform.macOS, "14.0") + XCTAssertNil(settings.platform.tvOS) + XCTAssertEqual( + settings.platform.deploymentTargets, + ["iOS": "16.0", "macOS": "14.0", "watchOS": "10.0"] + ) + } + + func testBuildSettingsPlatformHelpersReadAppleFamiliesLiteral() { + let settings = XCode.BuildSettings( + name: "Release", + setting: [ + "TARGETED_DEVICE_FAMILY": "1 2", + ] + ) + + XCTAssertEqual(settings.platform.deviceFamily.map(\.code), ["iphone", "ipad"]) + XCTAssertEqual(settings.platform.appleFamiliesLiteral, #"[\"iphone\", \"ipad\"]"#) + } + + func testBuildSettingsMetadataHelpersReadExpectedKeys() { + let settings = XCode.BuildSettings( + name: "Release", + setting: [ + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.app", + "PRODUCT_MODULE_NAME": "ExampleModule", + "PRODUCT_NAME": "ExampleApp", + "DEVELOPMENT_TEAM": "TEAM123", + "CODE_SIGN_STYLE": "Automatic", + "CODE_SIGN_IDENTITY": "Apple Development", + ] + ) + + XCTAssertEqual(settings.metadata.bundleID, "com.example.app") + XCTAssertEqual(settings.metadata.moduleName, "ExampleModule") + XCTAssertEqual(settings.metadata.productName, "ExampleApp") + XCTAssertEqual(settings.metadata.developmentTeam, "TEAM123") + XCTAssertEqual(settings.metadata.codeSignStyle, "Automatic") + XCTAssertEqual(settings.metadata.codeSignIdentity, "Apple Development") + } + + func testBuildSettingsPlistEntriesRenderCommonInfoPlistKeys() { + let settings = XCode.BuildSettings( + name: "Release", + setting: [ + "GENERATE_INFOPLIST_FILE": "YES", + "INFOPLIST_KEY_UILaunchStoryboardName": "LaunchScreen", + "INFOPLIST_KEY_UISupportedInterfaceOrientations": "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft", + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": "YES", + ] + ) + + let plist = settings.generatedPlist.entries.joined(separator: "\n") + + XCTAssertTrue(plist.contains("UILaunchStoryboardName")) + XCTAssertTrue(plist.contains("LaunchScreen")) + XCTAssertTrue(plist.contains("UISupportedInterfaceOrientations")) + XCTAssertTrue(plist.contains("UIInterfaceOrientationPortrait")) + XCTAssertTrue(plist.contains("UIInterfaceOrientationLandscapeLeft")) + XCTAssertTrue(plist.contains("UIApplicationSupportsIndirectInputEvents")) + XCTAssertTrue(plist.contains("")) + } } diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift new file mode 100644 index 0000000..09e7c80 --- /dev/null +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import XCode2 + +final class ProjectLoaderTests: XCTestCase { + func testMergeLocalPackagesKeepsExplicitEntriesFirstAndDeduplicatesByPath() { + let explicit: [XCode.LocalPackage] = [ + .init(name: "Local1", relativePath: "Local1"), + .init(name: "Local2", relativePath: "Local2"), + ] + let discovered: [XCode.LocalPackage] = [ + .init(name: "Local1 (Scanned)", relativePath: "Local1"), + .init(name: "Local3", relativePath: "Local3"), + ] + + let merged = ProjectLoader.mergeLocalPackages( + explicit: explicit, + discovered: discovered + ) + + XCTAssertEqual(merged.map(\.relativePath), ["Local1", "Local2", "Local3"]) + XCTAssertEqual(merged.first?.name, "Local1") + XCTAssertEqual(merged.last?.name, "Local3") + } +} From 5f72d00cd98cece73228491a531638dacd1f86e8 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 29 Apr 2026 11:41:01 +0800 Subject: [PATCH 006/173] Refine XCode2 labels and tests --- Sources/Xcode2/Loader/XCode+FileLoader.swift | 164 +++++++++++++----- .../Xcode2/Loader/XCode+ProjectLoader.swift | 105 +++++++++-- .../Xcode2/Loader/XCode+TargetLoader.swift | 26 ++- .../Config/XCode+BuildSettings+PList.swift | 1 + Sources/Xcode2/Model/File/XCode+Files.swift | 21 +++ .../Xcode2/Model/Phase/XCode+BuildPhase.swift | 33 ++++ .../XCode+PackageProductDependency.swift | 1 + .../Model/Target/XCode+Dependencies.swift | 17 ++ Sources/Xcode2/RoadmapTreeBuilder.swift | 5 + Sources/Xcode2/TargetSummaryFormatter.swift | 3 + Tests/XCode2Tests/BuildSettingsTests.swift | 81 +++++---- Tests/XCode2Tests/EncodingTests.swift | 24 +++ Tests/XCode2Tests/ProjectLoaderTests.swift | 13 +- .../XCode2Tests/RoadmapTreeBuilderTests.swift | 95 +++++----- .../TargetSummaryFormatterTests.swift | 55 ++++-- Tests/XCodeTests/PropertyTests.swift | 2 +- 16 files changed, 467 insertions(+), 179 deletions(-) create mode 100644 Tests/XCode2Tests/EncodingTests.swift diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index ad0ee23..6ef6f56 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -2,6 +2,49 @@ import Foundation import PathKit import XcodeProj +enum KnownFileType: String { + case swift = "sourcecode.swift" + case objc = "sourcecode.c.objc" + case objcxx = "sourcecode.cpp.objcpp" + case c = "sourcecode.c.c" + case cpp = "sourcecode.cpp.cpp" + case cHeader = "sourcecode.c.h" + case cppHeader = "sourcecode.cpp.h" + case metal = "sourcecode.metal" + case staticLibrary = "archive.ar" + case xib = "file.xib" + case storyboard = "file.storyboard" + case xcassets = "folder.assetcatalog" + case strings = "text.plist.strings" + case stringsdict = "text.plist.stringsdict" + case plist = "text.plist.xml" + case xcframework = "wrapper.xcframework" + case framework = "wrapper.framework" + + init?(path: String) { + switch Path(path).extension?.lowercased() { + case "swift": self = .swift + case "m": self = .objc + case "mm": self = .objcxx + case "c": self = .c + case "cc", "cp", "cpp", "cxx": self = .cpp + case "h": self = .cHeader + case "hh", "hpp", "hxx": self = .cppHeader + case "metal": self = .metal + case "a": self = .staticLibrary + case "xib": self = .xib + case "storyboard": self = .storyboard + case "xcassets": self = .xcassets + case "strings": self = .strings + case "stringsdict": self = .stringsdict + case "plist": self = .plist + case "xcframework": self = .xcframework + case "framework": self = .framework + default: return nil + } + } +} + struct FileLoader { let native: PBXFileElement unowned let project: ProjectLoader @@ -10,12 +53,8 @@ struct FileLoader { native.name ?? native.path } - var label: String? { - project.transformToLabel(relativePath) - } - var packageName: String? { - relativePath?.split(separator: "/").first.map(String.init) + project.packageName(for: native) } var relativePath: String? { @@ -42,6 +81,24 @@ struct FileLoader { .replacingOccurrences(of: ".xcframework", with: "") } + var frameworkIdentity: String? { + guard let name else { return nil } + + if name.hasSuffix(".framework") { + return name.replacingOccurrences(of: ".framework", with: "") + } + + if name.hasSuffix(".xcframework") { + return name.replacingOccurrences(of: ".xcframework", with: "") + } + + if name.hasPrefix("lib"), name.hasSuffix(".a") { + return String(name.dropFirst(3).dropLast(2)) + } + + return name + } + var isSDKFramework: Bool { sourceTree == PBXSourceTree.sdkRoot.description || sourceTree == PBXSourceTree.developerDir.description @@ -56,7 +113,7 @@ struct FileLoader { name: name, path: relativePath ?? native.path, fullPath: fullPath, - label: label, + label: label(buildPhase: buildPhase), fileType: fileType, sourceTree: sourceTree, buildPhase: buildPhase, @@ -64,6 +121,29 @@ struct FileLoader { attributes: attributes ) } + + func label(buildPhase: String?) -> String? { + if buildPhase == BuildPhase.frameworks.rawValue, canUsePrebuiltLabel { + return project.transformToLabel(relativePath, .prebuilt) + } + return project.transformToLabel( + relativePath, + .source(packageName: packageName) + ) + } + + private var canUsePrebuiltLabel: Bool { + if let typedFileType, typedFileType.isBinaryArtifact { + return true + } + + guard let name else { return false } + return name.hasSuffix(".a") + } + + private var typedFileType: KnownFileType? { + fileType.flatMap(KnownFileType.init(rawValue:)) + } } struct SynchronizedFile { @@ -71,6 +151,7 @@ struct SynchronizedFile { case source case header case resource + case binary case other } @@ -83,49 +164,11 @@ struct SynchronizedFile { } var fileType: String? { - switch Path(path).extension?.lowercased() { - case "swift": return "sourcecode.swift" - case "m": return "sourcecode.c.objc" - case "mm": return "sourcecode.cpp.objcpp" - case "c": return "sourcecode.c.c" - case "cc", "cp", "cpp", "cxx": return "sourcecode.cpp.cpp" - case "h": return "sourcecode.c.h" - case "hh", "hpp", "hxx": return "sourcecode.cpp.h" - case "metal": return "sourcecode.metal" - case "xib": return "file.xib" - case "storyboard": return "file.storyboard" - case "xcassets": return "folder.assetcatalog" - case "strings": return "text.plist.strings" - case "stringsdict": return "text.plist.stringsdict" - case "plist": return "text.plist.xml" - case "xcframework": return "wrapper.xcframework" - case "framework": return "wrapper.framework" - default: return nil - } + typedFileType?.rawValue } var category: Category { - switch fileType { - case "sourcecode.swift", - "sourcecode.c.objc", - "sourcecode.cpp.objcpp", - "sourcecode.c.c", - "sourcecode.cpp.cpp", - "sourcecode.metal": - return .source - case "sourcecode.c.h", - "sourcecode.cpp.h": - return .header - case "file.xib", - "file.storyboard", - "folder.assetcatalog", - "text.plist.strings", - "text.plist.stringsdict", - "text.plist.xml": - return .resource - default: - return .other - } + typedFileType?.category ?? .other } var file: XCode.File { @@ -147,9 +190,38 @@ struct SynchronizedFile { case .source: return BuildPhase.sources.rawValue case .header: return BuildPhase.headers.rawValue case .resource: return BuildPhase.resources.rawValue + case .binary: return nil case .other: return nil } } + + private var typedFileType: KnownFileType? { + KnownFileType(path: path) + } +} + +private extension KnownFileType { + var category: SynchronizedFile.Category { + switch self { + case .swift, .objc, .objcxx, .c, .cpp, .metal: + return .source + case .cHeader, .cppHeader: + return .header + case .xib, .storyboard, .xcassets, .strings, .stringsdict, .plist: + return .resource + case .staticLibrary, .xcframework, .framework: + return .binary + } + } + + var isBinaryArtifact: Bool { + switch self { + case .staticLibrary, .xcframework, .framework: + return true + default: + return false + } + } } extension PBXFileElement { diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index e7db667..5a764b4 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -14,22 +14,22 @@ final class ProjectLoader { private let native: PBXProj private let path: Path let preferConfig: String? - + init(path: Path, preferConfig: String?) throws { self.path = path self.preferConfig = preferConfig xcodeProj = try XcodeProj(path: path) native = xcodeProj.pbxproj } - + var rootProject: PBXProject? { native.rootObject } - + var workspacePath: Path { path.parent() } - + func model() throws -> XCode.Project { return XCode.Project( name: rootProject?.name ?? path.lastComponentWithoutExtension, @@ -48,7 +48,7 @@ final class ProjectLoader { private lazy var allFiles: [PBXFileElement] = { (try? native.rootGroup()?.flatten()) ?? [] }() - + private lazy var targets: [TargetLoader] = { native.nativeTargets.map { TargetLoader( @@ -58,16 +58,19 @@ final class ProjectLoader { ) } }() - + private lazy var defaultConfigList: ConfigListLoader? = { let all = Set(native.configurationLists.map { ConfigListLoader(native: $0) }) let targetLists = native.nativeTargets.map { ConfigListLoader(native: $0.buildConfigurationList) } - + return all.subtracting(targetLists).first }() +} +// MARK: - SwiftPM +extension ProjectLoader { private var remotePackages: [XCode.RemotePackage] { (rootProject?.remotePackages ?? []).map { package in .init( @@ -117,23 +120,54 @@ final class ProjectLoader { } } - func transformToLabel(_ relativePath: String?) -> String? { - guard let path = relativePath else { return nil } - - let commentedLabel = "# \(path)" - guard let package = path.split(separator: "/").first.map(String.init) else { - return commentedLabel + func packageName(for file: PBXFileElement) -> String? { + for target in native.nativeTargets { + if targetOwnsFile(target: target, file: file) { + return target.name + } } - guard let restPath = path.delete(prefix: package + "/") else { - return commentedLabel + + return nil + } + + var localPackagePathByProduct: [String: String] { + var result: [String: String] = [:] + + for package in localPackages { + let packageRoot = workspacePath + package.relativePath + let manifest = packageRoot + "Package.swift" + guard let content = try? String(contentsOfFile: manifest.string) else { continue } + + for product in content.swiftPackageProductNames { + result[product] = package.relativePath + } } - if targets.map(\.name).contains(package) { - return "//\(package):\(restPath)" - } else { - return "//:\(package)/\(restPath)" + return result + } + + enum LabelKind { + case source(packageName: String?) + case prebuilt + + var packageName: String { + switch self { + case .source(let packageName): + return packageName ?? "" + case .prebuilt: + return "Prebuilt" + } } } + + func transformToLabel( + _ relativePath: String?, + _ kind: LabelKind + ) -> String? { + guard let path = relativePath else { return nil } + + return "//\(kind.packageName):\(path)" + } static func mergeLocalPackages( explicit: [XCode.LocalPackage], @@ -152,6 +186,27 @@ final class ProjectLoader { } } +private extension ProjectLoader { + func targetOwnsFile(target: PBXNativeTarget, file: PBXFileElement) -> Bool { + if target.buildPhases.contains(where: { phase in + phase.files?.contains(where: { $0.file === file }) == true + }) { + return true + } + + guard let filePath = try? file.fullPath(sourceRoot: workspacePath.string) else { + return false + } + + return (target.fileSystemSynchronizedGroups ?? []).contains(where: { group in + guard let root = try? group.fullPath(sourceRoot: workspacePath.string) else { + return false + } + return filePath == root || filePath.hasPrefix(root + "/") + }) + } +} + private extension XCRemoteSwiftPackageReference.VersionRequirement { var stringValue: String { switch self { @@ -170,3 +225,15 @@ private extension XCRemoteSwiftPackageReference.VersionRequirement { } } } + +private extension String { + var swiftPackageProductNames: [String] { + let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(startIndex..., in: self) + return regex.matches(in: self, range: range).compactMap { match in + guard let capture = Range(match.range(at: 1), in: self) else { return nil } + return String(self[capture]) + } + } +} diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index f3fa304..22f71e0 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -94,10 +94,25 @@ struct TargetLoader { } private var dependencies: XCode.Dependencies { - let frameworkNames = frameworkBuildFiles.compactMap { buildFile -> String? in + let targetDependencies = native.dependencies.compactMap { dependency in + dependency.target?.name ?? dependency.name + } + let targetDependencyIdentities = Set(targetDependencies) + + let frameworks = frameworkBuildFiles.compactMap { buildFile -> String? in guard let file = buildFile.file else { return nil } let wrapped = FileLoader(native: file, project: project) guard !wrapped.isSDKFramework else { return nil } + + if let identity = wrapped.frameworkIdentity, targetDependencyIdentities.contains(identity) { + return nil + } + + if let label = wrapped.label(buildPhase: BuildPhase.frameworks.rawValue), + label.hasPrefix("//Prebuilt:") { + return label + } + return wrapped.name } @@ -111,18 +126,15 @@ struct TargetLoader { let packageProducts = (native.packageProductDependencies ?? []).map { dependency in XCode.PackageProductDependency( productName: dependency.productName, - package: dependency.package?.repositoryURL + package: dependency.package?.repositoryURL, + packagePath: project.localPackagePathByProduct[dependency.productName] ) } - let targetDependencies = native.dependencies.compactMap { dependency in - dependency.target?.name ?? dependency.name - } - return .init( targets: Set(targetDependencies).sorted(), packageProducts: unique(packageProducts) { "\($0.productName)|\($0.package ?? "")" }, - frameworks: Set(frameworkNames.compactMap { $0 }).sorted(), + frameworks: Set(frameworks.compactMap { $0 }).sorted(), sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted() ) } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift index aaadeb1..00e3962 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift @@ -37,6 +37,7 @@ public extension XCode.BuildSettings { settings.keys.filter { key in key.hasPrefix(plistPrefix) } + .sorted() } private func plistValue(_ key: String) -> String? { diff --git a/Sources/Xcode2/Model/File/XCode+Files.swift b/Sources/Xcode2/Model/File/XCode+Files.swift index 3d91806..f08eec1 100644 --- a/Sources/Xcode2/Model/File/XCode+Files.swift +++ b/Sources/Xcode2/Model/File/XCode+Files.swift @@ -8,3 +8,24 @@ public extension XCode { public let others: [File] } } + +extension XCode.Files { + enum CodingKeys: String, CodingKey { + case sources + case headers + case resources + case frameworks + case copyFiles + case others + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(sources.nonEmpty, forKey: .sources) + try container.encodeIfPresent(headers.nonEmpty, forKey: .headers) + try container.encodeIfPresent(resources.nonEmpty, forKey: .resources) + try container.encodeIfPresent(frameworks.nonEmpty, forKey: .frameworks) + try container.encodeIfPresent(copyFiles.nonEmpty, forKey: .copyFiles) + try container.encodeIfPresent(others.nonEmpty, forKey: .others) + } +} diff --git a/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift b/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift index 1850f13..a404906 100644 --- a/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift +++ b/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift @@ -11,3 +11,36 @@ public extension XCode { public let destination: CopyFilesDestination? } } + +extension XCode.BuildPhase { + enum CodingKeys: String, CodingKey { + case type + case name + case files + case inputPaths + case outputPaths + case inputFileListPaths + case outputFileListPaths + case shellScript + case destination + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(files.nonEmpty, forKey: .files) + try container.encodeIfPresent(inputPaths.nonEmpty, forKey: .inputPaths) + try container.encodeIfPresent(outputPaths.nonEmpty, forKey: .outputPaths) + try container.encodeIfPresent(inputFileListPaths.nonEmpty, forKey: .inputFileListPaths) + try container.encodeIfPresent(outputFileListPaths.nonEmpty, forKey: .outputFileListPaths) + try container.encodeIfPresent(shellScript, forKey: .shellScript) + try container.encodeIfPresent(destination, forKey: .destination) + } +} + +extension Array { + var nonEmpty: Self? { + isEmpty ? nil : self + } +} diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift b/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift index ddf4c1c..b2c2e0c 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift +++ b/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift @@ -2,5 +2,6 @@ public extension XCode { struct PackageProductDependency: Codable { public let productName: String public let package: String? + public let packagePath: String? } } diff --git a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift index e18c68e..300c317 100644 --- a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift +++ b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift @@ -6,3 +6,20 @@ public extension XCode { public let sdkFrameworks: [String] } } + +extension XCode.Dependencies { + enum CodingKeys: String, CodingKey { + case targets + case packageProducts + case frameworks + case sdkFrameworks + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(targets.nonEmpty, forKey: .targets) + try container.encodeIfPresent(packageProducts.nonEmpty, forKey: .packageProducts) + try container.encodeIfPresent(frameworks.nonEmpty, forKey: .frameworks) + try container.encodeIfPresent(sdkFrameworks.nonEmpty, forKey: .sdkFrameworks) + } +} diff --git a/Sources/Xcode2/RoadmapTreeBuilder.swift b/Sources/Xcode2/RoadmapTreeBuilder.swift index 9aba240..90cc44f 100644 --- a/Sources/Xcode2/RoadmapTreeBuilder.swift +++ b/Sources/Xcode2/RoadmapTreeBuilder.swift @@ -525,6 +525,11 @@ private extension XCode.Target { return "@\(repo)//:\(product.productName)" } + if let packagePath = product.packagePath, !packagePath.isEmpty { + let repo = "swiftpkg_" + sanitizeRepo(Path(packagePath).lastComponent.lowercased()) + return "@\(repo)//:\(product.productName)" + } + if let repo = localRepos[product.productName] { return "@\(repo)//:\(product.productName)" } diff --git a/Sources/Xcode2/TargetSummaryFormatter.swift b/Sources/Xcode2/TargetSummaryFormatter.swift index 29e976b..beec9fb 100644 --- a/Sources/Xcode2/TargetSummaryFormatter.swift +++ b/Sources/Xcode2/TargetSummaryFormatter.swift @@ -95,6 +95,9 @@ private extension XCode.PackageProductDependency { if let package, !package.isEmpty { return "\(package) / \(productName)" } + if let packagePath, !packagePath.isEmpty { + return "\(packagePath) / \(productName)" + } return productName } } diff --git a/Tests/XCode2Tests/BuildSettingsTests.swift b/Tests/XCode2Tests/BuildSettingsTests.swift index 47d7df1..6910959 100644 --- a/Tests/XCode2Tests/BuildSettingsTests.swift +++ b/Tests/XCode2Tests/BuildSettingsTests.swift @@ -1,8 +1,9 @@ -import XCTest +import Testing @testable import XCode2 -final class BuildSettingsTests: XCTestCase { - func testBuildSettingsSupportsStringAndArrayValues() { +struct BuildSettingsTests { + @Test + func buildSettingsHelpersExposeSemanticValues() { let settings = XCode.BuildSettings( name: "Debug", setting: [ @@ -11,11 +12,12 @@ final class BuildSettingsTests: XCTestCase { ] ) - XCTAssertEqual(settings["PRODUCT_BUNDLE_IDENTIFIER"], "com.example.app") - XCTAssertEqual(settings["TARGETED_DEVICE_FAMILY"], "1 2") + #expect(settings.metadata.bundleID == "com.example.app") + #expect(settings.platform.deviceFamily.map(\.code) == ["iphone", "ipad"]) } - func testBuildSettingsPlistHelpersReadExpectedKeys() { + @Test + func buildSettingsPlistHelpersReadExpectedKeys() { let settings = XCode.BuildSettings( name: "Release", setting: [ @@ -29,16 +31,16 @@ final class BuildSettingsTests: XCTestCase { ] ) - XCTAssertTrue(settings.generatedPlist.enabled) - XCTAssertEqual(settings.plist.infoPlist, "App/Info.plist") - XCTAssertEqual(settings.plist.launch, "LaunchScreen") - XCTAssertEqual(settings.plist.storyboard, "Main") - XCTAssertEqual(settings.plist.keys, ["INFOPLIST_KEY_CFBundleDisplayName"]) - XCTAssertEqual(settings.generatedPlist.currentProjectVersion, "42") - XCTAssertEqual(settings.generatedPlist.marketingVersion, "2.3") + #expect(settings.generatedPlist.enabled) + #expect(settings.plist.infoPlist == "App/Info.plist") + #expect(settings.plist.launch == "LaunchScreen") + #expect(settings.plist.storyboard == "Main") + #expect(settings.generatedPlist.currentProjectVersion == "42") + #expect(settings.generatedPlist.marketingVersion == "2.3") } - func testBuildSettingsPlatformHelpersReadDeploymentTargets() { + @Test + func buildSettingsPlatformHelpersReadDeploymentTargets() { let settings = XCode.BuildSettings( name: "Release", setting: [ @@ -48,16 +50,17 @@ final class BuildSettingsTests: XCTestCase { ] ) - XCTAssertEqual(settings.platform.iOS, "16.0") - XCTAssertEqual(settings.platform.macOS, "14.0") - XCTAssertNil(settings.platform.tvOS) - XCTAssertEqual( - settings.platform.deploymentTargets, - ["iOS": "16.0", "macOS": "14.0", "watchOS": "10.0"] + #expect(settings.platform.iOS == "16.0") + #expect(settings.platform.macOS == "14.0") + #expect(settings.platform.tvOS == nil) + #expect( + settings.platform.deploymentTargets == + ["iOS": "16.0", "macOS": "14.0", "watchOS": "10.0"] ) } - func testBuildSettingsPlatformHelpersReadAppleFamiliesLiteral() { + @Test + func buildSettingsPlatformHelpersReadAppleFamiliesLiteral() { let settings = XCode.BuildSettings( name: "Release", setting: [ @@ -65,11 +68,12 @@ final class BuildSettingsTests: XCTestCase { ] ) - XCTAssertEqual(settings.platform.deviceFamily.map(\.code), ["iphone", "ipad"]) - XCTAssertEqual(settings.platform.appleFamiliesLiteral, #"[\"iphone\", \"ipad\"]"#) + #expect(settings.platform.deviceFamily.map(\.code) == ["iphone", "ipad"]) + #expect(settings.platform.appleFamiliesLiteral == #"["iphone", "ipad"]"#) } - func testBuildSettingsMetadataHelpersReadExpectedKeys() { + @Test + func buildSettingsMetadataHelpersReadExpectedKeys() { let settings = XCode.BuildSettings( name: "Release", setting: [ @@ -82,15 +86,16 @@ final class BuildSettingsTests: XCTestCase { ] ) - XCTAssertEqual(settings.metadata.bundleID, "com.example.app") - XCTAssertEqual(settings.metadata.moduleName, "ExampleModule") - XCTAssertEqual(settings.metadata.productName, "ExampleApp") - XCTAssertEqual(settings.metadata.developmentTeam, "TEAM123") - XCTAssertEqual(settings.metadata.codeSignStyle, "Automatic") - XCTAssertEqual(settings.metadata.codeSignIdentity, "Apple Development") + #expect(settings.metadata.bundleID == "com.example.app") + #expect(settings.metadata.moduleName == "ExampleModule") + #expect(settings.metadata.productName == "ExampleApp") + #expect(settings.metadata.developmentTeam == "TEAM123") + #expect(settings.metadata.codeSignStyle == "Automatic") + #expect(settings.metadata.codeSignIdentity == "Apple Development") } - func testBuildSettingsPlistEntriesRenderCommonInfoPlistKeys() { + @Test + func buildSettingsPlistEntriesRenderCommonInfoPlistKeys() { let settings = XCode.BuildSettings( name: "Release", setting: [ @@ -103,12 +108,12 @@ final class BuildSettingsTests: XCTestCase { let plist = settings.generatedPlist.entries.joined(separator: "\n") - XCTAssertTrue(plist.contains("UILaunchStoryboardName")) - XCTAssertTrue(plist.contains("LaunchScreen")) - XCTAssertTrue(plist.contains("UISupportedInterfaceOrientations")) - XCTAssertTrue(plist.contains("UIInterfaceOrientationPortrait")) - XCTAssertTrue(plist.contains("UIInterfaceOrientationLandscapeLeft")) - XCTAssertTrue(plist.contains("UIApplicationSupportsIndirectInputEvents")) - XCTAssertTrue(plist.contains("")) + #expect(plist.contains("UILaunchStoryboardName")) + #expect(plist.contains("LaunchScreen")) + #expect(plist.contains("UISupportedInterfaceOrientations")) + #expect(plist.contains("UIInterfaceOrientationPortrait")) + #expect(plist.contains("UIInterfaceOrientationLandscapeLeft")) + #expect(plist.contains("UIApplicationSupportsIndirectInputEvents")) + #expect(plist.contains("")) } } diff --git a/Tests/XCode2Tests/EncodingTests.swift b/Tests/XCode2Tests/EncodingTests.swift new file mode 100644 index 0000000..414a722 --- /dev/null +++ b/Tests/XCode2Tests/EncodingTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import XCode2 + +struct EncodingTests { + @Test + func filesEncodingOmitsEmptyCopyFiles() throws { + let value = XCode.Files( + sources: [], + headers: [], + resources: [], + frameworks: [], + copyFiles: [], + others: [] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(value) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json == "{}") + } +} diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift index 09e7c80..35e907e 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -1,8 +1,9 @@ -import XCTest +import Testing @testable import XCode2 -final class ProjectLoaderTests: XCTestCase { - func testMergeLocalPackagesKeepsExplicitEntriesFirstAndDeduplicatesByPath() { +struct ProjectLoaderTests { + @Test + func mergeLocalPackagesKeepsExplicitEntriesFirstAndDeduplicatesByPath() { let explicit: [XCode.LocalPackage] = [ .init(name: "Local1", relativePath: "Local1"), .init(name: "Local2", relativePath: "Local2"), @@ -17,8 +18,8 @@ final class ProjectLoaderTests: XCTestCase { discovered: discovered ) - XCTAssertEqual(merged.map(\.relativePath), ["Local1", "Local2", "Local3"]) - XCTAssertEqual(merged.first?.name, "Local1") - XCTAssertEqual(merged.last?.name, "Local3") + #expect(merged.map(\.relativePath) == ["Local1", "Local2", "Local3"]) + #expect(merged.first?.name == "Local1") + #expect(merged.last?.name == "Local3") } } diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 969d77e..6d00efc 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -1,11 +1,16 @@ import Foundation import PathKit -import XCTest +import Testing @testable import XCode2 -final class RoadmapTreeBuilderTests: XCTestCase { - func testBuildCreatesTargetTreeAndSymlinks() throws { - let projectPath = Path.current + "fixture/iOS2/Example.xcodeproj" +struct RoadmapTreeBuilderTests { + @Test + func buildCreatesTargetTreeAndSymlinks() throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "fixture/iOS/Example.xcodeproj" let project = try XCode.Project.load(path: projectPath, preferConfig: nil) let output = Path(NSTemporaryDirectory()) + UUID().uuidString @@ -13,64 +18,64 @@ final class RoadmapTreeBuilderTests: XCTestCase { try XCode.RoadmapTreeBuilder(output: output).build(project: project) - XCTAssertTrue((output + "BUILD").exists) - XCTAssertTrue((output + "MODULE.bazel").exists) - XCTAssertTrue((output + "Package.swift").exists) - XCTAssertTrue((output + "Prebuilt").exists) - XCTAssertTrue((output + "Prebuilt/BUILD").exists) - XCTAssertTrue((output + "Prebuilt/SVProgressHUD.xcframework").exists) - XCTAssertTrue((output + "Example/Sources").exists) - XCTAssertTrue((output + "Example/Generated").exists) - XCTAssertTrue((output + "Example/BUILD").exists) - XCTAssertTrue((output + "Framework1/BUILD").exists) - XCTAssertTrue((output + "Static2/BUILD").exists) + #expect((output + "BUILD").exists) + #expect((output + "MODULE.bazel").exists) + #expect((output + "Package.swift").exists) + #expect((output + "Prebuilt").exists) + #expect((output + "Prebuilt/BUILD").exists) + #expect((output + "Prebuilt/SVProgressHUD.xcframework").exists) + #expect((output + "Example/Sources").exists) + #expect((output + "Example/Generated").exists) + #expect((output + "Example/BUILD").exists) + #expect((output + "Framework1/BUILD").exists) + #expect((output + "Static2/BUILD").exists) let exampleDir = output + "Example/Sources/Example" - XCTAssertTrue(exampleDir.isDirectory) - XCTAssertFalse(exampleDir.isSymlink) + #expect(exampleDir.isDirectory) + #expect(!exampleDir.isSymlink) let exampleApp = output + "Example/Sources/Example/ExampleApp.swift" - XCTAssertTrue(exampleApp.isSymlink) - XCTAssertEqual( - try exampleApp.symlinkDestination().absolute().string, - (projectPath.parent() + "Example/ExampleApp.swift").absolute().string + #expect(exampleApp.isSymlink) + #expect( + try exampleApp.symlinkDestination().absolute().string == + (projectPath.parent() + "Example/ExampleApp.swift").absolute().string ) let previewAsset = output + "Example/Sources/Example/Preview Content/Preview Assets.xcassets/Contents.json" - XCTAssertTrue(previewAsset.isSymlink) - XCTAssertEqual( - try previewAsset.symlinkDestination().absolute().string, - (projectPath.parent() + "Example/Preview Content/Preview Assets.xcassets/Contents.json").absolute().string + #expect(previewAsset.isSymlink) + #expect( + try previewAsset.symlinkDestination().absolute().string == + (projectPath.parent() + "Example/Preview Content/Preview Assets.xcassets/Contents.json").absolute().string ) let exampleBuild = try String(contentsOfFile: (output + "Example/BUILD").string) - XCTAssertTrue(exampleBuild.contains("ios_application(")) - XCTAssertTrue(exampleBuild.contains("name = \"Example\"")) - XCTAssertTrue(exampleBuild.contains("swift_library(")) - XCTAssertTrue(exampleBuild.contains("name = \"Example_library\"")) - XCTAssertTrue(exampleBuild.contains("//Framework1:Framework1")) - XCTAssertTrue(exampleBuild.contains("//Prebuilt:SVProgressHUD")) - XCTAssertTrue(exampleBuild.contains("@swiftpkg_anycodable//:AnyCodable")) - XCTAssertTrue(exampleBuild.contains("@swiftpkg_local1//:LocalLib1")) - XCTAssertTrue(exampleBuild.contains("@swiftpkg_local1//:LocalLib2")) + #expect(exampleBuild.contains("ios_application(")) + #expect(exampleBuild.contains("name = \"Example\"")) + #expect(exampleBuild.contains("swift_library(")) + #expect(exampleBuild.contains("name = \"Example_library\"")) + #expect(exampleBuild.contains("//Framework1:Framework1")) + #expect(exampleBuild.contains("//Prebuilt:SVProgressHUD")) + #expect(exampleBuild.contains("@swiftpkg_anycodable//:AnyCodable")) + #expect(exampleBuild.contains("@swiftpkg_local1//:LocalLib1")) + #expect(exampleBuild.contains("@swiftpkg_local1//:LocalLib2")) let frameworkBuild = try String(contentsOfFile: (output + "Framework1/BUILD").string) - XCTAssertTrue(frameworkBuild.contains("ios_framework(")) - XCTAssertTrue(frameworkBuild.contains("name = \"Framework1\"")) + #expect(frameworkBuild.contains("ios_framework(")) + #expect(frameworkBuild.contains("name = \"Framework1\"")) let static2Build = try String(contentsOfFile: (output + "Static2/BUILD").string) - XCTAssertTrue(static2Build.contains("objc_library(")) - XCTAssertTrue(static2Build.contains("name = \"Static2_objc\"")) + #expect(static2Build.contains("objc_library(")) + #expect(static2Build.contains("name = \"Static2_objc\"")) let prebuiltBuild = try String(contentsOfFile: (output + "Prebuilt/BUILD").string) - XCTAssertTrue(prebuiltBuild.contains("apple_dynamic_xcframework_import(")) - XCTAssertTrue(prebuiltBuild.contains("name = \"SVProgressHUD\"")) + #expect(prebuiltBuild.contains("apple_dynamic_xcframework_import(")) + #expect(prebuiltBuild.contains("name = \"SVProgressHUD\"")) let module = try String(contentsOfFile: (output + "MODULE.bazel").string) - XCTAssertTrue(module.contains("rules_apple")) - XCTAssertTrue(module.contains("rules_swift")) - XCTAssertTrue(module.contains("rules_swift_package_manager")) - XCTAssertTrue(module.contains("swift_deps = use_extension")) - XCTAssertTrue(module.contains("swiftpkg_local1")) + #expect(module.contains("rules_apple")) + #expect(module.contains("rules_swift")) + #expect(module.contains("rules_swift_package_manager")) + #expect(module.contains("swift_deps = use_extension")) + #expect(module.contains("swiftpkg_local1")) } } diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift index 41bfc01..fdbf8b3 100644 --- a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift +++ b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift @@ -1,8 +1,9 @@ -import XCTest +import Testing @testable import XCode2 -final class TargetSummaryFormatterTests: XCTestCase { - func testFormatTargetSummary() throws { +struct TargetSummaryFormatterTests { + @Test + func formatTargetSummary() throws { let target = XCode.Target( name: "Example", productName: "Example", @@ -65,14 +66,32 @@ final class TargetSummaryFormatterTests: XCTestCase { attributes: [] ), ], - frameworks: [], + frameworks: [ + .init( + name: "SVProgressHUD.xcframework", + path: "Vendor/SVProgressHUD.xcframework", + fullPath: "/tmp/Vendor/SVProgressHUD.xcframework", + label: "//Prebuilt:SVProgressHUD", + fileType: "wrapper.xcframework", + sourceTree: "", + buildPhase: "frameworks", + compilerFlags: nil, + attributes: [] + ), + ], copyFiles: [], others: [] ), dependencies: .init( targets: ["Framework1"], - packageProducts: [], - frameworks: [], + packageProducts: [ + .init( + productName: "LocalLib1", + package: nil, + packagePath: "../Local1" + ), + ], + frameworks: ["//Prebuilt:SVProgressHUD"], sdkFrameworks: ["SwiftUI", "UIKit"] ) ) @@ -89,16 +108,18 @@ final class TargetSummaryFormatterTests: XCTestCase { let summary = XCode.TargetSummaryFormatter.format(project: project, target: target) - XCTAssertTrue(summary.contains("Target: Example")) - XCTAssertTrue(summary.contains("Type: com.apple.product-type.application")) - XCTAssertTrue(summary.contains("Bundle ID: com.example.Example")) - XCTAssertTrue(summary.contains("Sources:")) - XCTAssertTrue(summary.contains("- Example/ExampleApp.swift")) - XCTAssertTrue(summary.contains("Resources:")) - XCTAssertTrue(summary.contains("Dependencies:")) - XCTAssertTrue(summary.contains("SDK Frameworks:")) - XCTAssertTrue(summary.contains("Settings [Release]:")) - XCTAssertTrue(summary.contains("PRODUCT_BUNDLE_IDENTIFIER = com.example.Example")) - XCTAssertTrue(summary.contains("TARGETED_DEVICE_FAMILY = 1 2")) + #expect(summary.contains("Target: Example")) + #expect(summary.contains("Type: com.apple.product-type.application")) + #expect(summary.contains("Bundle ID: com.example.Example")) + #expect(summary.contains("Sources:")) + #expect(summary.contains("- Example/ExampleApp.swift")) + #expect(summary.contains("Resources:")) + #expect(summary.contains("Dependencies:")) + #expect(summary.contains("../Local1 / LocalLib1")) + #expect(summary.contains("//Prebuilt:SVProgressHUD")) + #expect(summary.contains("SDK Frameworks:")) + #expect(summary.contains("Settings [Release]:")) + #expect(summary.contains("PRODUCT_BUNDLE_IDENTIFIER = com.example.Example")) + #expect(summary.contains("TARGETED_DEVICE_FAMILY = 1 2")) } } diff --git a/Tests/XCodeTests/PropertyTests.swift b/Tests/XCodeTests/PropertyTests.swift index 358120a..b7faca9 100644 --- a/Tests/XCodeTests/PropertyTests.swift +++ b/Tests/XCodeTests/PropertyTests.swift @@ -74,7 +74,7 @@ private struct Setting: @unchecked Sendable { // MARK: - XCodeTests -enum XCodeTests { +struct XCodeTests { private static let release = Setting([ "iOS": "9.0", "macOS": "10.15", From 2b9c2c9fd9760a029964283a5d20a04598c19922 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 30 Apr 2026 17:17:56 +0800 Subject: [PATCH 007/173] Migrate BazelizeKit to XCode2 roadmap flow --- .github/workflows/swift.yml | 8 - .gitignore | 3 +- Makefile | 29 +- Package.swift | 36 +- Plugins/RepoEnumPlugin/plugin.swift | 35 + RepoSources.yml | 16 + Sources/Bazelize/Command.swift | 54 +- Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift | 1 - Sources/BazelizeKit/Bazel/Bazel+Module.swift | 10 +- .../Bazel/Bazel+PrebuiltBUILD.swift | 75 +++ .../BazelizeKit/Bazel/Bazel+RootBuild.swift | 1 - .../BazelizeKit/Bazel/Bazel+TargetBUILD.swift | 15 +- Sources/BazelizeKit/Bazel/Bazel+Version.swift | 15 + .../Bazel/Bazel+WORKSPACE+Builder.swift | 94 --- .../BazelizeKit/Bazel/Bazel+WORKSPACE.swift | 26 - .../Codegen/Codegen+Application.swift | 39 +- .../Codegen/Codegen+Framework.swift | 18 +- .../BazelizeKit/Codegen/Codegen+Plist.swift | 73 ++- .../Codegen/Codegen+StaticLibrary.swift | 1 - .../BazelizeKit/Codegen/Codegen+Target.swift | 45 +- .../BazelizeKit/Codegen/Codegen+UITest.swift | 19 +- .../Codegen/Codegen+UnitTest.swift | 11 +- .../Codegen/Language/Codegen+Library.swift | 1 - .../Language/Codegen+ObjcLibrary.swift | 1 - .../Language/Codegen+SwiftLibrary.swift | 25 +- .../Codegen/Resource/Codegen+Asset.swift | 14 +- .../Codegen/Resource/Codegen+Strings.swift | 1 - Sources/BazelizeKit/Kit.swift | 123 ++-- Sources/BazelizeKit/Module.swift | 5 + Sources/BazelizeKit/Plugin/Plugin+Apple.swift | 2 +- .../BazelizeKit/Plugin/Plugin+Imported.swift | 82 --- .../BazelizeKit/Plugin/Plugin+Linker.swift | 9 +- Sources/BazelizeKit/Plugin/Plugin+Swift.swift | 2 +- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 114 +++- .../BazelizeKit/Plugin/Plugin+XCodeProj.swift | 3 +- Sources/BazelizeKit/Repo/Repo+Apple.swift | 114 +++- .../BazelizeKit/Repo/Repo+AppleLinker.swift | 31 + Sources/BazelizeKit/Repo/Repo+Bazel.swift | 165 +++++ .../BazelizeKit/Repo/Repo+BazelSkylib.swift | 38 ++ Sources/BazelizeKit/Repo/Repo+Hammer.swift | 27 - Sources/BazelizeKit/Repo/Repo+Pod.swift | 29 - Sources/BazelizeKit/Repo/Repo+RulesCC.swift | 47 ++ Sources/BazelizeKit/Repo/Repo+Swift.swift | 103 ++- Sources/BazelizeKit/Repo/Repo+SwiftPM.swift | 113 +++- Sources/BazelizeKit/Repo/Repo+XCodeProj.swift | 103 ++- .../Roadmap/BazelizeKit+Roadmap.swift | 175 +++++ Sources/BazelizeKit/XCode2Compat.swift | 59 ++ Sources/RepoEnumCore/RepoEnumCore.swift | 238 +++++++ Sources/RepoEnumGenerator/Entry.swift | 23 + .../Loader/XCode+ConfigListLoader.swift | 6 +- Sources/Xcode2/Loader/XCode+FileLoader.swift | 21 +- .../Xcode2/Loader/XCode+ProjectLoader.swift | 103 ++- .../Xcode2/Loader/XCode+TargetLoader.swift | 58 +- .../Config/XCode+BuildSettings+Metadata.swift | 6 +- .../Config/XCode+BuildSettings+PList.swift | 12 +- .../Config/XCode+BuildSettings+Platform.swift | 21 +- .../Model/Config/XCode+BuildSettings.swift | 21 +- .../Model/Config/XCode+DeviceFamily.swift | 4 +- Sources/Xcode2/Model/File/XCode+File.swift | 4 +- Sources/Xcode2/Model/File/XCode+Files.swift | 6 +- .../Xcode2/Model/Phase/XCode+BuildPhase.swift | 6 +- .../Model/Phase/XCode+BuildPhaseFile.swift | 4 +- .../Phase/XCode+CopyFilesDestination.swift | 4 +- .../Xcode2/Model/Project/XCode+Project.swift | 62 +- .../Model/SwiftPM/XCode+LocalPackage.swift | 4 +- .../XCode+PackageProductDependency.swift | 4 +- .../Xcode2/Model/SwiftPM/XCode+Packages.swift | 4 +- .../Model/SwiftPM/XCode+RemotePackage.swift | 15 +- .../Xcode2/Model/Target/XCode+CodeSign.swift | 4 +- .../Model/Target/XCode+Dependencies.swift | 6 +- .../Xcode2/Model/Target/XCode+Target.swift | 145 ++++- .../Model/Target/XCode+TargetMetadata.swift | 4 +- Sources/Xcode2/RoadmapTreeBuilder.swift | 598 ++---------------- Sources/Xcode2/TargetSummaryFormatter.swift | 14 +- Sources/Xcode2/XCode.swift | 2 +- .../RepoEnumCoreTests/RepoEnumCoreTests.swift | 99 +++ Tests/XCode2Tests/BuildSettingsTests.swift | 21 +- Tests/XCode2Tests/EncodingTests.swift | 3 +- Tests/XCode2Tests/ProjectLoaderTests.swift | 3 +- .../XCode2Tests/RoadmapTreeBuilderTests.swift | 41 +- .../TargetSummaryFormatterTests.swift | 33 +- git_release.py | 92 --- 82 files changed, 2187 insertions(+), 1514 deletions(-) create mode 100644 Plugins/RepoEnumPlugin/plugin.swift create mode 100644 RepoSources.yml create mode 100644 Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift create mode 100644 Sources/BazelizeKit/Bazel/Bazel+Version.swift delete mode 100644 Sources/BazelizeKit/Bazel/Bazel+WORKSPACE+Builder.swift delete mode 100644 Sources/BazelizeKit/Bazel/Bazel+WORKSPACE.swift create mode 100644 Sources/BazelizeKit/Module.swift delete mode 100644 Sources/BazelizeKit/Plugin/Plugin+Imported.swift create mode 100644 Sources/BazelizeKit/Repo/Repo+AppleLinker.swift create mode 100644 Sources/BazelizeKit/Repo/Repo+Bazel.swift create mode 100644 Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift delete mode 100644 Sources/BazelizeKit/Repo/Repo+Hammer.swift delete mode 100644 Sources/BazelizeKit/Repo/Repo+Pod.swift create mode 100644 Sources/BazelizeKit/Repo/Repo+RulesCC.swift create mode 100644 Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift create mode 100644 Sources/BazelizeKit/XCode2Compat.swift create mode 100644 Sources/RepoEnumCore/RepoEnumCore.swift create mode 100644 Sources/RepoEnumGenerator/Entry.swift create mode 100644 Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift delete mode 100644 git_release.py diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index b4df8b6..de1bcb5 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -21,14 +21,6 @@ concurrency: cancel-in-progress: true jobs: - lint: - runs-on: macos-26 - steps: - - uses: actions/checkout@v6 - - - name: swiftformat --lint - run: | - swiftformat --lint --config .swiftformat . build: runs-on: macos-26 diff --git a/.gitignore b/.gitignore index d7efbf2..3c277e7 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,5 @@ cache/ .codex-local-skills/ .vendor/ -app/ \ No newline at end of file +app/ +Generated/ \ No newline at end of file diff --git a/Makefile b/Makefile index 0db27c2..70ddeb0 100644 --- a/Makefile +++ b/Makefile @@ -33,27 +33,14 @@ test: swift test -v --skip CocoapodTests 2>&1 | xcpretty # COCOAPOD=$(shell which pod) swift test -v 2>&1 | xcbeautify -Apple := bazelbuild/rules_apple -Swift := bazelbuild/rules_swift -XCodeProj := buildbuddy-io/rules_xcodeproj -REPOS := Apple Swift XCodeProj - -SPM := cgrindel/rules_swift_package_manager -REPO_SPM := SPM - - -# user/repo rule_name output_file_path -# python3 git_release.py bazelbuild/rules_apple Apple Sources/BazelizeKit/Rule/Rule+Apple.swift -$(REPOS): - python3 git_release.py $($@) $@ Sources/BazelizeKit/Repo/Repo+$@.swift 5 normal - -$(REPO_SPM): - python3 git_release.py $($@) $@ Sources/BazelizeKit/Repo/Repo+$@.swift 5 archive - -rules: $(REPOS) - -spm: $(REPO_SPM) - .PHONY: bazelize bazelize: install cd fixture/iOS && make bazelize + +.PHONY: update-repo-enums +update-repo-enums: + swift package plugin --allow-network-connections all --allow-writing-to-package-directory repo-enum + +.PHONY: replace +replace: update-repo-enums + cp Generated/*.swift Sources/BazelizeKit/Repo/ diff --git a/Package.swift b/Package.swift index e327fad..6739dc0 100644 --- a/Package.swift +++ b/Package.swift @@ -40,6 +40,12 @@ let package = Package( "BazelizeKit", "XCode2", ]), + .executableTarget( + name: "RepoEnumGenerator", + dependencies: [ + "RepoEnumCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ]), .target( name: "BazelRules", @@ -62,15 +68,37 @@ let package = Package( name: "BazelizeKit", dependencies: [ "Yams", + "PathKit", "BazelRules", - "XCode", + "XCode2", "Util", "Starlark", "PluginLoader", .product(name: "XcodeProj", package: "XcodeProj"), ]), + .target( + name: "RepoEnumCore", + dependencies: [ + "Yams", + ]), + .plugin( + name: "RepoEnumPlugin", + capability: .command( + intent: .custom( + verb: "repo-enum", + description: "Generate Repo+*.swift files from GitHub tags."), + permissions: [ + .allowNetworkConnections( + scope: .all(), + reason: "Fetch GitHub tags for configured repositories."), + .writeToPackageDirectory( + reason: "Write generated Repo enum files into the package directory."), + ]), + dependencies: [ + "RepoEnumGenerator", + ]), .target( name: "Util", @@ -103,8 +131,10 @@ let package = Package( path: "Sources/XCode2"), .testTarget( name: "XCode2Tests", - dependencies: ["XCode2"] - ), + dependencies: ["XCode2", "BazelizeKit"]), + .testTarget( + name: "RepoEnumCoreTests", + dependencies: ["RepoEnumCore"]), .testTarget( name: "XCodeTests", dependencies: ["XCode"]), diff --git a/Plugins/RepoEnumPlugin/plugin.swift b/Plugins/RepoEnumPlugin/plugin.swift new file mode 100644 index 0000000..4af7b95 --- /dev/null +++ b/Plugins/RepoEnumPlugin/plugin.swift @@ -0,0 +1,35 @@ +import Foundation +import PackagePlugin + +// MARK: - RepoEnumPlugin + +@main +struct RepoEnumPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) async throws { + let tool = try context.tool(named: "RepoEnumGenerator") + let process = Process() + process.executableURL = tool.url + process.currentDirectoryURL = context.package.directoryURL + process.arguments = arguments + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw RepoEnumPluginError.executionFailed(status: process.terminationStatus) + } + } +} + +// MARK: - RepoEnumPluginError + +private enum RepoEnumPluginError: LocalizedError { + case executionFailed(status: Int32) + + var errorDescription: String? { + switch self { + case .executionFailed(let status): + return "RepoEnumGenerator exited with status \(status)." + } + } +} diff --git a/RepoSources.yml b/RepoSources.yml new file mode 100644 index 0000000..83ed3bd --- /dev/null +++ b/RepoSources.yml @@ -0,0 +1,16 @@ +- name: Apple + url: https://github.com/bazelbuild/rules_apple +- name: Swift + url: https://github.com/bazelbuild/rules_swift +- name: XCodeProj + url: https://github.com/MobileNativeFoundation/rules_xcodeproj +- name: SwiftPM + url: https://github.com/cgrindel/rules_swift_package_manager +- name: AppleLinker + url: https://github.com/keith/rules_apple_linker +- name: Bazel + url: https://github.com/bazelbuild/bazel +- name: BazelSkylib + url: https://github.com/bazelbuild/bazel-skylib +- name: RulesCC + url: https://github.com/bazelbuild/rules_cc \ No newline at end of file diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index 50d8fea..748d7cf 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -11,6 +11,8 @@ import Foundation import PathKit import XCode2 +// MARK: - Command + @main struct Command: AsyncParsableCommand { static let configuration = CommandConfiguration( @@ -20,21 +22,24 @@ struct Command: AsyncParsableCommand { subcommands: [ GenerateCommand.self, XCode2Command.self, - RoadmapCommand.self, +// RoadmapCommand.self, ], - defaultSubcommand: GenerateCommand.self - ) + defaultSubcommand: GenerateCommand.self) } +// MARK: - GenerateCommand + struct GenerateCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "generate", - abstract: "Generate Bazel files from an Xcode project." - ) + abstract: "Generate Bazel files from an Xcode project.") @Option(name: [.customLong("project", withSingleDash: false)], help: "PATH/TO/YOUR.xcodeproj") var project: String + @Option(name: [.customLong("output", withSingleDash: false)], help: "PATH/TO/OUTPUT") + var output: String + @Option(name: [.short], help: "Debug/Release") var config = "Release" @@ -49,7 +54,11 @@ struct GenerateCommand: AsyncParsableCommand { func run() async throws { let path = Path.current + project - let kit = try await Kit(path, config) + let outputPath = Path.current + output + let kit = try await Kit( + path, + config, + outputPath: outputPath) guard !clear else { kit.clear() @@ -64,11 +73,12 @@ struct GenerateCommand: AsyncParsableCommand { } } +// MARK: - XCode2Command + struct XCode2Command: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "xcode2", - abstract: "Dump an Xcode project structure as JSON or print one target summary." - ) + abstract: "Dump an Xcode project structure as JSON or print one target summary.") @Option(name: [.customLong("project", withSingleDash: false)], help: "PATH/TO/YOUR.xcodeproj") var project: String @@ -76,7 +86,9 @@ struct XCode2Command: AsyncParsableCommand { @Option(name: [.short], help: "Preferred config name used by project parsing") var config: String? - @Option(name: [.customLong("print-target", withSingleDash: false)], help: "Print a human-readable summary for a single target") + @Option( + name: [.customLong("print-target", withSingleDash: false)], + help: "Print a human-readable summary for a single target") var printTarget: String? func run() async throws { @@ -103,27 +115,3 @@ struct XCode2Command: AsyncParsableCommand { print(json) } } - -struct RoadmapCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "roadmap", - abstract: "Create the roadmap tree layout from an Xcode project." - ) - - @Option(name: [.customLong("project", withSingleDash: false)], help: "PATH/TO/YOUR.xcodeproj") - var project: String - - @Option(name: [.customLong("output", withSingleDash: false)], help: "PATH/TO/OUTPUT") - var output: String - - @Option(name: [.short], help: "Preferred config name used by project parsing") - var config: String? - - func run() async throws { - let projectPath = Path.current + project - let outputPath = Path.current + output - let dump = try XCode.Project.load(path: projectPath, preferConfig: config) - - try XCode.RoadmapTreeBuilder(output: outputPath).build(project: dump) - } -} diff --git a/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift b/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift index 237a1e7..ea42801 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift @@ -7,7 +7,6 @@ import Foundation import PathKit -import XCode extension Bazel { /// [config](https://bazel.build/docs/configurable-attributes) diff --git a/Sources/BazelizeKit/Bazel/Bazel+Module.swift b/Sources/BazelizeKit/Bazel/Bazel+Module.swift index ba3c12d..6ebed05 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Module.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Module.swift @@ -14,6 +14,8 @@ extension Bazel { struct Module: BazelFile { let path: Path public let builder = CodeBuilder() + private let skylib: Repo.BazelSkylib = .v1_9_1 + private let cc: Repo.RulesCC = .v0_2_18 init(_ root: Path) { path = root + "MODULE.bazel" @@ -30,8 +32,12 @@ extension Bazel { "name" => "example" "version" => "0.0.1" } - builder.bazel_dep(name: "bazel_skylib", version: "1.9.0") - builder.bazel_dep(name: "rules_cc", version: "0.2.17") + builder.bazel_dep( + name: "bazel_skylib", + version: skylib.rawValue) + builder.bazel_dep( + name: "rules_cc", + version: cc.rawValue) } } } diff --git a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift new file mode 100644 index 0000000..a07184c --- /dev/null +++ b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift @@ -0,0 +1,75 @@ +import BazelRules +import PathKit +import Starlark +import XCode2 + +extension Bazel { + struct PrebuiltBuild: BazelFile { + let path: Path + public let builder = CodeBuilder() + init(_ root: Path) { + path = root + "Prebuilt" + "BUILD" + } + + var code: String { + builder.build() + } + + mutating func setup(_ kit: Kit) { + let imported = kit.project.targets.flatMap(\.files.frameworks) + + let frameworks = imported.filter { file in + file.fileType == "wrapper.framework" + } + + let xcframeworks = imported.filter { file in + file.fileType == "wrapper.xcframework" + } + + buildFrameworks(frameworks) + buildXCFrameworks(xcframeworks) + } + + private func buildXCFrameworks(_ files: [XCode.File]) { + guard !files.isEmpty else { return } + builder.load(.apple_dynamic_xcframework_import) + + for file in unique(files) { + guard let path = file.path, !path.isEmpty else { continue } + let name = Path(path).lastComponentWithoutExtension + builder.call( + Rules.Apple.General.Call.apple_dynamic_xcframework_import( + name: name, + xcframework_imports: Starlark.glob([ + "\(Path(path).lastComponent)/**", + ]), + visibility: .public)) + } + } + + private func buildFrameworks(_ files: [XCode.File]) { + guard !files.isEmpty else { return } + builder.load(.apple_dynamic_framework_import) + + for file in unique(files) { + guard let path = file.path, !path.isEmpty else { continue } + let name = Path(path).lastComponentWithoutExtension + builder.call( + Rules.Apple.General.Call.apple_dynamic_framework_import( + name: name, + framework_imports: Starlark.glob([ + "\(Path(path).lastComponent)/**", + ]), + visibility: .public)) + } + } + + private func unique(_ files: [XCode.File]) -> [XCode.File] { + var seen = Set() + return files.filter { file in + guard let path = file.path, !path.isEmpty else { return false } + return seen.insert(path).inserted + } + } + } +} diff --git a/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift b/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift index 0e1253e..c628e0f 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift @@ -9,7 +9,6 @@ import BazelRules import Foundation import PathKit import Starlark -import XCode extension Bazel { /// /BUILD diff --git a/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift index 2cfa760..f891c74 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift @@ -1,31 +1,22 @@ -// -// TargetBUILD.swift -// -// -// Created by Yume on 2022/12/8. -// - - import Foundation import PathKit import Starlark import Util -import XCode extension Bazel { /// /{TARGET}/BUILD struct TargetBuild: BazelFile { // MARK: Lifecycle - init(_ root: Path, _ target: XCode.Target) { + init(_ root: Path, _ target: Target) { self.target = target - targetPath = root + target.name + targetPath = root + "Targets" + target.name path = targetPath + "BUILD" } // MARK: Internal - let target: XCode.Target + let target: Target let path: Path let targetPath: Path diff --git a/Sources/BazelizeKit/Bazel/Bazel+Version.swift b/Sources/BazelizeKit/Bazel/Bazel+Version.swift new file mode 100644 index 0000000..e0edbc8 --- /dev/null +++ b/Sources/BazelizeKit/Bazel/Bazel+Version.swift @@ -0,0 +1,15 @@ +import Foundation +import PathKit + +extension Bazel { + /// .bazelversion + struct Version: BazelFile { + let path: Path + let code = "\(Self.repo.rawValue)" + static private let repo: Repo.Bazel = .v9_1_0 + + init(_ root: Path) { + path = root + ".bazelversion" + } + } +} diff --git a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE+Builder.swift b/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE+Builder.swift deleted file mode 100644 index 3b23819..0000000 --- a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE+Builder.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// Workspace.swift -// -// -// Created by Yume on 2022/7/25. -// - -import Foundation - -extension Bazel.Workspace { - public struct Builder { - // MARK: Public - -// mutating -// public func `default`() { -// http_archive() -// rulesApple(repo: .v2_0_0) -// rulesSwift(repo: .v1_5_1) -// rulesPod(repo: .v4_1_0_412495) -// rulesSPM(repo: .v0_11_2) -// rulesSPM2() -// rulesXCodeProj(repo: .v0_11_0) -// rulesHammer(repo: .v3_4_3_3) -// } - - mutating - public func rulesPod(repo: Repo.Pod) { - _code = """ - # rules_pods - http_archive( - name = "rules_pods", - urls = ["https://github.com/pinterest/PodToBUILD/releases/download/\(repo.rawValue)/PodToBUILD.zip"], - # sha256 = "\(repo.sha256)", - ) - - load("@rules_pods//BazelExtensions:workspace.bzl", "new_pod_repository") - """ - } - - mutating - public func rulesSPM(repo: Repo.SPM) { - _code = """ - # rules_spm - http_archive( - name = "cgrindel_rules_spm", - # sha256 = "\(repo.sha256)", - strip_prefix = "rules_spm-\(repo.version)", - urls = [ - "http://github.com/cgrindel/rules_spm/archive/\(repo.rawValue).tar.gz", - ], - ) - - load( - "@cgrindel_rules_spm//spm:deps.bzl", - "spm_rules_dependencies", - ) - - spm_rules_dependencies() - """ - } - - mutating - public func rulesHammer(repo: Repo.Hammer) { - _code = """ - # rules_hammer - http_archive( - name = "xchammer", - urls = [ "https://github.com/pinterest/xchammer/releases/download/\(repo.rawValue)/xchammer.zip" ], - ) - """ - } - - mutating - public func custom(code: String) { - _code = code - } - - // MARK: Internal - - internal func build() -> String { - codes.joined(separator: "\n\n") - } - - // MARK: Private - - private var codes: [String] = [] - - - private var _code: String { - get { "" } - set { codes.append(newValue) } - } - } -} diff --git a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE.swift b/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE.swift deleted file mode 100644 index 93aa04b..0000000 --- a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// WORKSPACE.swift -// -// -// Created by Yume on 2022/4/27. -// - -import Foundation -import PathKit -import Util - -extension Bazel { - /// /WORKSPACE - struct Workspace: BazelFile { - let path: Path - public let builder = CodeBuilder() - - init(_ root: Path) { - path = root + "WORKSPACE" - } - - var code: String { - builder.build() - } - } -} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index b6fd227..09d823d 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -1,27 +1,14 @@ -// -// Codegen+Application.swift -// -// -// Created by Yume on 2022/4/29. -// - -import BazelRules -import Foundation -import PathKit -import Starlark -import XCode - extension Target { // MARK: Internal func generateApplicationCode(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.sdk) { + switch prefer(\.platform.sdk) { case .iOS: buildIOS(builder, kit) case .macOS: buildMac(builder, kit) case .tvOS: buildTV(builder, kit) case .watchOS: buildWatch(builder, kit) case .auto: - let family = prefer(\.deviceFamily) + let family = prefer(\.platform.deviceFamily) guard let family else { return } @@ -37,7 +24,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_command_line_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" }, @@ -46,7 +33,7 @@ extension Target { plist_auto // plist_default }, - minimum_os_version: prefer(\.macOS), + minimum_os_version: prefer(\.platform.macOS), visibility: .public)) } @@ -57,7 +44,7 @@ extension Target { builder.call( Rules.Apple.WatchOS.Call.watchos_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" frameworks @@ -67,7 +54,7 @@ extension Target { plist_auto plist_default }, - minimum_os_version: prefer(\.watchOS), + minimum_os_version: prefer(\.platform.watchOS), resources: .build { resources }, @@ -79,19 +66,19 @@ extension Target { builder.call( Rules.Apple.IOS.Call.ios_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" frameworks }, - families: prefer(\.deviceFamily)?.map(\.code), + families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { plist_file plist_auto plist_default }, // "launch_storyboard" => ":Base.lproj/LaunchScreen.storyboard" - minimum_os_version: prefer(\.iOS), + minimum_os_version: prefer(\.platform.iOS), sdk_frameworks: frameworksSDK, strings: .build { if !allStrings.isEmpty { @@ -106,7 +93,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" }, @@ -115,7 +102,7 @@ extension Target { plist_auto plist_default }, - minimum_os_version: prefer(\.macOS), + minimum_os_version: prefer(\.platform.macOS), visibility: .public)) } @@ -124,7 +111,7 @@ extension Target { builder.call( Rules.Apple.TVOS.Call.tvos_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" frameworks @@ -134,7 +121,7 @@ extension Target { plist_auto plist_default }, - minimum_os_version: prefer(\.tvOS), + minimum_os_version: prefer(\.platform.tvOS), resources: .build { resources }, diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index 73d2db6..174dfd5 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -1,15 +1,3 @@ -// -// Codegen+Framework.swift -// -// -// Created by Yume on 2022/4/29. -// - -import BazelRules -import Foundation -import Starlark -import XCode - // TODO: https://github.com/XCodeBazelize/Bazelize/issues/8 framework(static/dynamic) extension Target { @@ -18,18 +6,18 @@ extension Target { builder.call( Rules.Apple.IOS.Call.ios_framework( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" frameworks }, - families: prefer(\.deviceFamily)?.map(\.code), + families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { plist_file plist_auto // plist_default }, - minimum_os_version: prefer(\.iOS), + minimum_os_version: prefer(\.platform.iOS), visibility: .public)) } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index 12d7bc6..b9c5db6 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -9,12 +9,14 @@ import BazelRules import Foundation import PathKit import Starlark -import XCode extension Target { - func generateLoadPlistFragment(_ builder: CodeBuilder) { - let isGeneratePlist = plistContent != nil - guard isGeneratePlist || isGeneratePlistAuto || isGeneratePlistDefault else { + func generateLoadPlistFragment(_ builder: CodeBuilder, _ kit: Kit) { + guard + plistContent(project: kit.project) != nil || + isGeneratePlistAuto(project: kit.project) || + isGeneratePlistDefault(project: kit.project) + else { return } builder.load(loadableRule: Rules.Plist.plist_fragment) @@ -27,14 +29,14 @@ extension Target { // MARK: Internal var plist_file: Starlark.Label? { - if let _ = plistContent { + if configs.values.contains(where: { $0.plist.infoPlist != nil }) { return ":plist_file" } return nil } - func generatePlistFile(_ builder: CodeBuilder, _: Kit) { - guard let plist = plistContent else { return } + func generatePlistFile(_ builder: CodeBuilder, _ kit: Kit) { + guard let plist = plistContent(project: kit.project) else { return } builder.call( Rules.Plist.Call.plist_fragment( name: "plist_file", @@ -49,11 +51,12 @@ extension Target { // MARK: Private - private var plistContent: String? { - guard let plistPath = prefer(\.infoPlist) else { + private func plistContent(project: Project?) -> String? { + guard let project else { return nil } + guard let plistPath = prefer(\.plist.infoPlist) else { return nil } - let path: Path = project.workspacePath + plistPath + let path = Path(project.workspacePath) + plistPath guard let content: String = try? path.read() else { return nil } guard @@ -80,12 +83,13 @@ extension Target { // MARK: Internal var plist_auto: Starlark.Label? { - isGeneratePlistAuto ? ":plist_auto" : nil + configs.values.contains(where: { !$0.generatedPlist.entries.isEmpty }) ? ":plist_auto" : nil } - func generatePlistAuto(_ builder: CodeBuilder) { - if isGeneratePlistAuto { - let plist = prefer(\.plist) ?? [] + func generatePlistAuto(_ builder: CodeBuilder, _: Kit) { + let settings = selectedSettings + let plist = settings.generatedPlist.entries + if !plist.isEmpty { builder.call( Rules.Plist.Call.plist_fragment( name: "plist_auto", @@ -101,10 +105,10 @@ extension Target { // MARK: Private - private var isGeneratePlistAuto: Bool { - let isAutoGen = prefer(\.generateInfoPlist) ?? false - let isEmptyPlist = (prefer(\.plist) ?? []).isEmpty - return isAutoGen && !isEmptyPlist + private func isGeneratePlistAuto(project: Project?) -> Bool { + guard project != nil else { return false } + let settings = selectedSettings + return settings.generatedPlist.enabled && !settings.generatedPlist.entries.isEmpty } } @@ -116,12 +120,12 @@ extension Target { // MARK: Internal var plist_default: Starlark.Label? { - isGeneratePlistDefault ? ":plist_default" : nil + configs.values.contains(where: { !defaultPlistFragments(for: $0).isEmpty }) ? ":plist_default" : nil } - func generatePlistDefault(_ builder: CodeBuilder) { - if isGeneratePlistDefault { - let plist = prefer(\.defaultPlist) ?? [] + func generatePlistDefault(_ builder: CodeBuilder, _: Kit) { + let plist = defaultPlistFragments(for: selectedSettings) + if !plist.isEmpty { builder.call( Rules.Plist.Call.plist_fragment( name: "plist_default", @@ -137,8 +141,27 @@ extension Target { // MARK: Private - private var isGeneratePlistDefault: Bool { - let plist = prefer(\.defaultPlist) ?? [] - return !plist.isEmpty + private func isGeneratePlistDefault(project: Project?) -> Bool { + guard project != nil else { return false } + return !defaultPlistFragments(for: selectedSettings).isEmpty + } + + private func defaultPlistFragments(for settings: BuildSettings) -> [String] { + let defaults = [ + ("CFBundleName", "$(PRODUCT_NAME)"), + ("CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"), + ("CFBundleVersion", settings.generatedPlist.currentProjectVersion ?? "$(CURRENT_PROJECT_VERSION)"), + ("CFBundleExecutable", "$(EXECUTABLE_NAME)"), + ("CFBundlePackageType", "$(PRODUCT_BUNDLE_PACKAGE_TYPE)"), + ("CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"), + ("CFBundleShortVersionString", settings.generatedPlist.marketingVersion ?? "$(MARKETING_VERSION)"), + ] + + return defaults.map { key, value in + """ + \(key) + \(value) + """ + } } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift b/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift index 7da58d9..b3173be 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift @@ -8,7 +8,6 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { func generateStaticLibrary(_ builder: CodeBuilder, _: Kit) { diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 75a2ddd..f8e5e5c 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -1,58 +1,35 @@ -// -// Target+Codegen.swift -// -// -// Created by Yume on 2022/4/29. -// - -import Foundation -import PathKit import Util -import XCode extension Target { - var isTest: Bool { - switch native.productType { - case .unitTestBundle: fallthrough - case .ocUnitTestBundle: fallthrough - case .uiTestBundle: - return true - default: return false - } - } - func generateCode(_ kit: Kit) -> String { let builder = CodeBuilder() generateLibrary(builder, kit) - generateLoadPlistFragment(builder) + generateLoadPlistFragment(builder, kit) generatePlistFile(builder, kit) - generatePlistAuto(builder) - generatePlistDefault(builder) + generatePlistAuto(builder, kit) + generatePlistDefault(builder, kit) let name = name - let native = native - switch native.productType { - case .application: + switch productType { + case "com.apple.product-type.application": generateStrings(builder, kit) generateApplicationCode(builder, kit) - case .commandLineTool: + case "com.apple.product-type.tool": generateCommandLineApplicationCode(builder, kit) - case .framework: + case "com.apple.product-type.framework": generateFrameworkCode(builder, kit) -// case .staticFramework: break - case .staticLibrary: + case "com.apple.product-type.library.static": generateStaticLibrary(builder, kit) -// case .appExtension: break - case .unitTestBundle: + case "com.apple.product-type.bundle.unit-test": generateUnitTest(builder, kit) - case .uiTestBundle: + case "com.apple.product-type.bundle.ui-testing": generateUITest(builder, kit) default: Log.codeGenerate.warning(""" Name: \(name, privacy: .public) - Type: \(native.productType?.rawValue ?? "") not gen + Type: \(productType ?? "") not gen """) } return builder.build() diff --git a/Sources/BazelizeKit/Codegen/Codegen+UITest.swift b/Sources/BazelizeKit/Codegen/Codegen+UITest.swift index 8c3d6ed..b4e55d0 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+UITest.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+UITest.swift @@ -8,13 +8,12 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { // MARK: Internal func generateUITest(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.sdk) { + switch prefer(\.platform.sdk) { case .iOS: generateIOSUITest(builder, kit) case .macOS: generateMacUITest(builder, kit) case .tvOS: generateTVUITest(builder, kit) @@ -33,9 +32,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.iOS), + minimum_os_version: prefer(\.platform.iOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } @@ -48,9 +47,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.macOS), + minimum_os_version: prefer(\.platform.macOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } @@ -63,9 +62,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.tvOS), + minimum_os_version: prefer(\.platform.tvOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } @@ -78,9 +77,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.watchOS), + minimum_os_version: prefer(\.platform.watchOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } diff --git a/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift b/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift index da85e99..aab7700 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift @@ -8,13 +8,12 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { // MARK: Internal func generateUnitTest(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.sdk) { + switch prefer(\.platform.sdk) { case .iOS: generateIOSUnitTest(builder, kit) case .macOS: generateMacUnitTest(builder, kit) case .tvOS: generateTVUnitTest(builder, kit) @@ -33,7 +32,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.iOS), + minimum_os_version: prefer(\.platform.iOS), visibility: .public)) } @@ -45,7 +44,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.macOS), + minimum_os_version: prefer(\.platform.macOS), visibility: .public)) } @@ -57,7 +56,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.tvOS), + minimum_os_version: prefer(\.platform.tvOS), visibility: .public)) } @@ -69,7 +68,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.watchOS), + minimum_os_version: prefer(\.platform.watchOS), visibility: .public)) } } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 0f94cb3..704f3b6 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -7,7 +7,6 @@ import Foundation import Util -import XCode extension Target { func generateLibrary(_ builder: CodeBuilder, _ kit: Kit) { diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 0b0db68..a571c48 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -8,7 +8,6 @@ import BazelRules import Foundation import Starlark -import XCode // TODO: https://github.com/XCodeBazelize/Bazelize/issues/7 diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index e8144ae..ea163cb 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -1,19 +1,8 @@ -// -// Codegen+SwiftLibrary.swift -// -// -// Created by Yume on 2022/7/4. -// - -import BazelRules -import Foundation -import Starlark -import XCode - extension Target { // MARK: Internal func generateSwiftLibrary(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project let plugin = kit.plugins.compactMap { $0[name] }.flatMap(\.deps) @@ -35,7 +24,7 @@ extension Target { }, deps: .build { frameworksLibrary - applicationHost + applicationHost(project: project) plugin builtins }, @@ -46,7 +35,7 @@ extension Target { xibs storyboards }, - defines: defines, + defines: defines(project: project), testonly: isTest, visibility: .private)) @@ -59,8 +48,8 @@ extension Target { // MARK: Private - private var defines: Starlark.Value { - select(\.swiftDefine).map { text -> [String] in + private func defines(project: Project) -> Starlark.Value { + select(\.swiftDefine, project: project).map { text -> [String] in let flags: [String] = (text ?? "").split(separator: " ").map(String.init) var isPreviousDefine = false @@ -89,10 +78,10 @@ extension Target { /// TEST_HOST /// $(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example /// build/Debug-iphoneos/Example.app//Example - private var applicationHost: String? { + private func applicationHost(project _: Project) -> String? { guard let host = prefer(\.testHost) else { return nil } guard let _ = prefer(\.bundleLoader) else { return nil } guard let targetName = host.components(separatedBy: "/").last else { return nil } - return "//\(targetName):\(targetName)_library" + return "//Targets/\(targetName):\(targetName)_library" } } diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift index 3fd8026..0453399 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift @@ -1,15 +1,3 @@ -// -// Asset.swift -// -// -// Created by Yume on 2023/1/9. -// - -import BazelRules -import Foundation -import Starlark -import XCode - extension Target { /// https://thanhvu.dev/en/2021/07/16/migrating-ios-project-to-bazel-part-2-2/ /// filegroup( @@ -29,7 +17,7 @@ extension Target { /// Assets.xcassets/** let files = assets .map { label in - "\(label.delete(prefix: "//\(name):"))/**" + "\(label)/**" } .map { (label: String) in // if label.hasPrefix("//:") { diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift index c0bf354..f02a41b 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift @@ -8,7 +8,6 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { func generateStrings(_ builder: CodeBuilder, _: Kit) { diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index ccbf957..9ae50b9 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -5,11 +5,8 @@ // Created by Yume on 2022/4/29. // -import Foundation -import PathKit import PluginLoader import Util -import XCode import XcodeProj import Yams @@ -17,19 +14,23 @@ import Yams public final class Kit { let project: Project - - lazy var module = Bazel.Module(project.workspacePath) - lazy var workspace = Bazel.Workspace(project.workspacePath) - lazy var build = Bazel.RootBuild(project.workspacePath) - lazy var config = Bazel.BazelRC(project.workspacePath) + let outputRoot: Path + + private lazy var roadmap = Bazel.Roadmap(output: outputRoot, project: project) + lazy var version = Bazel.Version(outputRoot) + lazy var module = Bazel.Module(outputRoot) + lazy var build = Bazel.RootBuild(outputRoot) + lazy var config = Bazel.BazelRC(outputRoot) + lazy var prebuilt = Bazel.PrebuiltBuild(outputRoot) lazy var targetsBuild = project.targets.map { target in - Bazel.TargetBuild(project.workspacePath, target) + Bazel.TargetBuild(outputRoot, target) } /// plugins... var plugins: [Plugin] private lazy var pluginSPM = PluginSwiftPM(self) + lazy var builtinPlugins: [PluginBuiltin] = [ PluginHttpArchive(self), PluginGitRepository(self), @@ -39,13 +40,13 @@ public final class Kit { PluginXCodeProj(self), PluginPlistFragment(self), PluginLinker(self), - PluginImported(self), ] // MARK: Lifecycle - public init(_ projPath: Path, _ preferConfig: String?) async throws { - project = try await Project(projPath, preferConfig) + public init(_ projPath: Path, _ preferConfig: String?, outputPath: Path? = nil) async throws { + project = try Project.load(path: projPath, preferConfig: preferConfig) + outputRoot = outputPath ?? Path(project.workspacePath) plugins = [] try await pluginSPM.loadPackageNames(projPath: projPath) @@ -57,8 +58,7 @@ public final class Kit { defer { tips() } // try await loadPlugins(mainfest) - - generate() + try generate() } public final func dump() throws { @@ -90,83 +90,92 @@ extension Kit { // MARK: - Generate extension Kit { - private final func generate() { - generateModule() - generateWorkspace() - generateBuild() - generateConfig() - generateTargetBuild() - generatePluginExtraFile() + private final func generate() throws { + try generateRoadmap() + try generateVersion() + try generateModule() + try generateBuild() + try generateConfig() + try generatePrebuiltBuild() + try generateTargetBuild() + try generatePluginExtraFile() + } + + private func generateRoadmap() throws { + try roadmap.prepare() + } + + private func generateVersion() throws { + try version.path.write(version.code) } /// {WORKSPACE}/MODULE.bazel - private func generateModule() { + private func generateModule() throws { for plugin in builtinPlugins { plugin.module(module.builder) } - try? module.write() + try module.write() let path = module.path Log.codeGenerate.info("Create `Workspace` at \(path, privacy: .public)") } - /// {WORKSPACE}/WORKSPACE - private final func generateWorkspace() { -// for plugin in builtinPlugins { -// plugin.workspace(workspace.builder) -// } -// try? workspace.write() -// -// let path = workspace.path -// Log.codeGenerate.info("Create `Workspace` at \(path, privacy: .public)") - } - /// {WORKSPACE}/BUILD - private final func generateBuild() { + private final func generateBuild() throws { build.setup(config: project.config) + // build.exportUncategorizedFiles(self) for plugin in builtinPlugins { plugin.build(build.builder) } - try? build.write() + try build.write() let path = build.path Log.codeGenerate.info("Create `BUILD` at \(path, privacy: .public)") } /// {WORKSPACE}/config.bazelrc - private final func generateConfig() { + private final func generateConfig() throws { config.setup(config: project.config) - try? config.write() + try config.write() let path = config.path Log.codeGenerate.info("Create `config.bazelrc` at \(path, privacy: .public)") } + private final func generatePrebuiltBuild() throws { + prebuilt.setup(self) + try prebuilt.path.parent().mkpath() + try prebuilt.write() + + let path = prebuilt.path + Log.codeGenerate.info("Create `Prebuilt/BUILD` at \(path, privacy: .public)") + } + /// {WORKSPACE}/Target/BUILD - private final func generateTargetBuild() { + private final func generateTargetBuild() throws { for build in targetsBuild { var build = build - try? build.mkpath() + try build.mkpath() build.setup(self) - try? build.write() + try build.write() let path = build.path Log.codeGenerate.info("Create BUILD at \(path, privacy: .public)") } } - private final func generatePluginExtraFile() { - builtinPlugins.compactMap(\.custom).flatMap { $0 }.forEach { custom in - let path = Path(custom.path) - try? path.parent().mkpath() - try? path.write(custom.content) + private final func generatePluginExtraFile() throws { + try builtinPlugins.compactMap(\.custom).flatMap { $0 }.forEach { custom in + let path = resolvedOutputPath(custom.path) + try path.parent().mkpath() + try path.write(custom.content) } - plugins.forEach { plugin in - try? plugin.generateFile(project.workspacePath) + try plugins.forEach { plugin in + try plugin.generateFile(outputRoot) } } } @@ -178,9 +187,9 @@ extension Kit { public final func clear() { clearModule() - clearWorkspace() clearBuild() clearConfig() + clearPrebuiltBuild() clearTargetBuild() clearPluginExtraFile() } @@ -192,11 +201,6 @@ extension Kit { try? module.clear() } - /// {WORKSPACE}/WORKSPACE - private final func clearWorkspace() { - try? workspace.clear() - } - /// {WORKSPACE}/BUILD private final func clearBuild() { try? build.clear() @@ -207,6 +211,10 @@ extension Kit { try? config.clear() } + private final func clearPrebuiltBuild() { + try? prebuilt.clear() + } + /// {WORKSPACE}/Target/BUILD private final func clearTargetBuild() { for build in targetsBuild { @@ -216,8 +224,13 @@ extension Kit { private final func clearPluginExtraFile() { builtinPlugins.compactMap(\.custom).flatMap { $0 }.forEach { custom in - let path = Path(custom.path) + let path = resolvedOutputPath(custom.path) try? path.delete() } } + + private func resolvedOutputPath(_ path: String) -> Path { + let custom = Path(path) + return custom.isAbsolute ? custom : outputRoot + custom + } } diff --git a/Sources/BazelizeKit/Module.swift b/Sources/BazelizeKit/Module.swift new file mode 100644 index 0000000..5022501 --- /dev/null +++ b/Sources/BazelizeKit/Module.swift @@ -0,0 +1,5 @@ +@_exported import BazelRules +@_exported import Foundation +@_exported import PathKit +@_exported import Starlark +@_exported import XCode2 diff --git a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift index 7c4d0d8..2ee88cc 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift @@ -11,7 +11,7 @@ import Foundation /// https://github.com/bazelbuild/rules_apple final class PluginApple: PluginBuiltin { - let repo: Repo.Apple = .v4_3_3 + let repo: Repo.Apple = .v4_5_3 override func module(_ builder: CodeBuilder) { builder.bazel_dep( diff --git a/Sources/BazelizeKit/Plugin/Plugin+Imported.swift b/Sources/BazelizeKit/Plugin/Plugin+Imported.swift deleted file mode 100644 index 8f3857c..0000000 --- a/Sources/BazelizeKit/Plugin/Plugin+Imported.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// Plugin+Imported.swift -// -// -// Created by Yume on 2023/2/7. -// - -import BazelRules -import Foundation -import PathKit -import Starlark -import XCode - -// TODO: check static imported (xc)framework -final class PluginImported: PluginBuiltin { - private lazy var _target: [String : [String]]? = kit.project.targets - .map { target in - (target.name, target) - } - .toDictionary() - .mapValues { target in - target.importFrameworks.map { relativePath in - let name = Path(relativePath).lastComponentWithoutExtension - let label = "//:\(name)" - return label - } - } - - override var target: [String : [String]]? { - _target - } - - override func build(_ builder: CodeBuilder) { - let imported = kit.project.frameworks.filter { file in - file.relativePath != nil - } - - let frameworks = imported.filter { file in - file.lastKnownFileType == .framework - } - - let xcframeworks = imported.filter { file in - file.lastKnownFileType == .xcframework - } - framework(builder, frameworks) - xcframework(builder, xcframeworks) - } - - private func xcframework(_ builder: CodeBuilder, _ files: [File]) { - guard !files.isEmpty else { return } - builder.load(.apple_dynamic_xcframework_import) - - for file in files { - guard let relativePath = file.relativePath else { continue } - let name = Path(relativePath).lastComponentWithoutExtension - builder.call( - Rules.Apple.General.Call.apple_dynamic_xcframework_import( - name: name, - xcframework_imports: Starlark.glob([ - "\(relativePath)/**", - ]), - visibility: .public)) - } - } - - private func framework(_ builder: CodeBuilder, _ files: [File]) { - guard !files.isEmpty else { return } - builder.load(.apple_dynamic_framework_import) - - for file in files { - guard let relativePath = file.relativePath else { continue } - let name = Path(relativePath).lastComponentWithoutExtension - builder.call( - Rules.Apple.General.Call.apple_dynamic_framework_import( - name: name, - framework_imports: Starlark.glob([ - "\(relativePath)/**", - ]), - visibility: .public)) - } - } -} diff --git a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift index b911ab6..6c3f68b 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift @@ -13,10 +13,11 @@ import Foundation /// https://github.com/keith/rules_apple_linker class PluginLinker: PluginBuiltin { + let repo: Repo.AppleLinker = .v0_7_0 + override func module(_ builder: CodeBuilder) { - builder.custom( - """ - bazel_dep(name = "rules_apple_linker", version = "0.3.0") - """) + builder.bazel_dep( + name: "rules_apple_linker", + version: repo.rawValue) } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift index ee190de..e639ef3 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift @@ -11,7 +11,7 @@ import Foundation /// https://github.com/bazelbuild/rules_swift final class PluginSwift: PluginBuiltin { - let repo: Repo.Swift = .v3_4_1 + let repo: Repo.Swift = .v3_6_1 override func module(_ builder: CodeBuilder) { builder.bazel_dep( diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index b48976c..e9fcb2d 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -7,33 +7,46 @@ import Foundation import PathKit -import XCode -import XcodeProj // MARK: - PluginSPM /// http://github.com/cgrindel/rules_swift_package_manager final class PluginSwiftPM: PluginBuiltin { - private let repo: Repo.SPM = .v1_13_0 - let remotes: [XCodeRemoteSPM] - let locals: [XCodeLocalSPM] + private let repo: Repo.SwiftPM = .v1_15_0 + let remotes: [RemotePackage] + let locals: [LocalPackage] private var packages: [String] = [] func loadPackageNames(projPath: Path) async throws { let packageSwift = package - let path = Path(packageSwift.path) + let workspace = projPath.parent() + let path = workspace + packageSwift.path + let hadExistingManifest = path.exists + let originalContent = hadExistingManifest ? (try? path.read()) : nil + try path.write(packageSwift.content) - packages = try await SPMParser - .allPackageNames(path: projPath.parent().string) + defer { + if hadExistingManifest { + if let originalContent { + try? path.write(originalContent) + } + } else { + try? path.delete() + } + } + + packages = packageRepositories } override init(_ kit: Kit) { - remotes = kit.project.remoteSPM - locals = kit.project.localSPM + remotes = kit.project.packages.remote + locals = kit.project.packages.local super.init(kit) } override func module(_ builder: CodeBuilder) { - builder.bazel_dep(name: "rules_swift_package_manager", version: repo.rawValue) + builder.bazel_dep( + name: "rules_swift_package_manager", + version: repo.rawValue) builder.custom(""" swift_deps = use_extension( "@rules_swift_package_manager//:extensions.bzl", @@ -57,8 +70,8 @@ final class PluginSwiftPM: PluginBuiltin { """) } - private func transformRemote(_ product: XCSwiftPackageProductDependency) -> String? { - guard let url = product.package?.repositoryURL else { return nil } + private func transformRemote(_ product: PackageProductDependency) -> String? { + guard let url = product.package else { return nil } /// https://github.com/apple/swift-nio.git let path = Path(url) @@ -74,16 +87,18 @@ final class PluginSwiftPM: PluginBuiltin { """.replacingOccurrences(of: "-", with: "_") } - private func transformLocal(_ product: XCSwiftPackageProductDependency) -> String? { + private func transformLocal(_ product: PackageProductDependency) -> String? { let product = product.productName - let local = locals.first { spm in - spm.products.keys.contains(product) + let path: String + if let packagePath = kit.project.localPackagePathByProduct[product] { + path = Path(packagePath).lastComponent.lowercased() + } else if let packagePath = kit.project.localPackageRepoByProduct[product] { + path = packagePath.replacingOccurrences(of: "swiftpkg_", with: "") + } else { + return nil } - guard let local = local else { return nil } - let path = Path(local.path).lastComponent.lowercased() - return """ @swiftpkg_\(path)//:\(product) """ @@ -93,7 +108,7 @@ final class PluginSwiftPM: PluginBuiltin { let targets = kit.project.targets return targets.map { target -> (String, [String]) in - let deps = target.native.packageProductDependencies ?? [] + let deps = target.dependencies.packageProducts let remote = deps.compactMap(transformRemote) let local = deps.compactMap(transformLocal) @@ -103,8 +118,29 @@ final class PluginSwiftPM: PluginBuiltin { } private var package: PluginBuiltin.Custom { - let spms = remotes.map(\.package) + - locals.map(\.package) + let spms = remotes.compactMap { remote -> String? in + guard let url = remote.repositoryURL else { return nil } + if let version = remote.version { + switch version { + case .upToNextMajorVersion(let version): + return #" .package(url: "\#(url)", from: "\#(version)"),"# + case .upToNextMinorVersion(let version): + return #" .package(url: "\#(url)", .upToNextMinor(from: "\#(version)")),"# + case .exact(let version): + return #" .package(url: "\#(url)", exact: "\#(version)"),"# + case .branch(let branch): + return #" .package(url: "\#(url)", branch: "\#(branch)"),"# + case .revision(let revision): + return #" .package(url: "\#(url)", revision: "\#(revision)"),"# + case .range(let from, let to): + return #" .package(url: "\#(url)", "\#(from)"..."\#(to)"),"# + } + } + return #" .package(url: "\#(url)", from: "0.0.1"),"# + } + + locals.map { local in + #" .package(path: "\#(localPackagePath(local))"),"# + } let deps = spms.joined(separator: "\n").indent(2) return .init( path: "Package.swift", @@ -121,6 +157,10 @@ final class PluginSwiftPM: PluginBuiltin { """) } + override var custom: [PluginBuiltin.Custom]? { + [package] + } + override var tip: String? { if remotes.isEmpty, locals.isEmpty { return nil } return """ @@ -130,11 +170,17 @@ final class PluginSwiftPM: PluginBuiltin { } private var packageRepositories: [String] { - let remoteRepos = remotes.map(\.url).map(Self.repositoryName(url:)) - let localRepos = locals.map(\.path).map(Self.repositoryName(path:)) + let remoteRepos = remotes.compactMap(\.repositoryURL).map(Self.repositoryName(url:)) + let localRepos = locals.map(\.relativePath).map(Self.repositoryName(path:)) return Set(remoteRepos + localRepos).sorted() } + private func localPackagePath(_ local: LocalPackage) -> String { + let source = (kit.project.workspaceRoot + local.relativePath).absolute() + let base = kit.outputRoot.absolute() + return Self.relativePath(from: base.string, to: source.string) + } + private static func repositoryName(url: String) -> String { repositoryName(module: Path(url).lastComponentWithoutExtension) } @@ -150,4 +196,24 @@ final class PluginSwiftPM: PluginBuiltin { private static func sanitize(_ value: String) -> String { value.replacingOccurrences(of: "-", with: "_") } + + private static func relativePath(from base: String, to target: String) -> String { + let baseURL = URL(fileURLWithPath: base, isDirectory: true).standardized + let targetURL = URL(fileURLWithPath: target, isDirectory: true).standardized + + let baseComponents = baseURL.pathComponents + let targetComponents = targetURL.pathComponents + + var commonCount = 0 + while + commonCount < min(baseComponents.count, targetComponents.count), + baseComponents[commonCount] == targetComponents[commonCount] + { + commonCount += 1 + } + + let upward = Array(repeating: "..", count: baseComponents.count - commonCount) + let downward = Array(targetComponents.dropFirst(commonCount)) + return (upward + downward).joined(separator: "/") + } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift index 6bfcd4b..71d5865 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift @@ -6,7 +6,6 @@ // import Foundation -import XCode import XcodeProj // MARK: - PluginXCodeProj @@ -32,7 +31,7 @@ final class PluginXCodeProj: PluginBuiltin { .sorted() .map { name in """ - "//\(name):\(name)", + "//Targets/\(name):\(name)", """ }.withNewLine.indent(2) diff --git a/Sources/BazelizeKit/Repo/Repo+Apple.swift b/Sources/BazelizeKit/Repo/Repo+Apple.swift index 19c29f8..7c90f71 100644 --- a/Sources/BazelizeKit/Repo/Repo+Apple.swift +++ b/Sources/BazelizeKit/Repo/Repo+Apple.swift @@ -1,29 +1,105 @@ - extension Repo { /// https://github.com/bazelbuild/rules_apple enum Apple: String { + case v4_5_3 = "4.5.3" + case v4_5_2 = "4.5.2" case v4_5_1 = "4.5.1" case v4_5_0 = "4.5.0" case v4_4_0 = "4.4.0" case v4_3_3 = "4.3.3" case v4_3_2 = "4.3.2" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v4_5_1: return "0831b6b305e22e007c561f8e48f618244091c9b34ff7aa571de66ddb0de6fdbe" - case .v4_5_0: return "34953c6c5666f2bd864a4a2a27599eb6630a42fde18ba57292fa0a7fcb3d851c" - case .v4_4_0: return "c6d8d0361cd7e48067a2cb3bb6bb295182f8e44ee66905f3d578d5a96bcac18c" - case .v4_3_3: return "fad623b4d0dbe7883fffc95a3275eaabfd13bd9336fca6788cb40bee96e5f131" - case .v4_3_2: return "f2b4117fe17b0f1f8a3769e6d760d433fcbf97a8b6ff1797077ec106ccfbe2f2" - } - } + case v4_3_1 = "4.3.1" + case v4_3_0 = "4.3.0" + case v4_2_0 = "4.2.0" + case v4_1_2 = "4.1.2" + case v4_1_1 = "4.1.1" + case v4_1_0 = "4.1.0" + case v4_0_1 = "4.0.1" + case v4_0_0 = "4.0.0" + case v3_22_0 = "3.22.0" + case v3_21_1 = "3.21.1" + case v3_21_0 = "3.21.0" + case v3_20_1 = "3.20.1" + case v3_20_0 = "3.20.0" + case v3_19_1 = "3.19.1" + case v3_19_0 = "3.19.0" + case v3_18_0 = "3.18.0" + case v3_17_1 = "3.17.1" + case v3_17_0 = "3.17.0" + case v3_16_1 = "3.16.1" + case v3_16_0 = "3.16.0" + case v3_15_0 = "3.15.0" + case v3_14_0 = "3.14.0" + case v3_13_0 = "3.13.0" + case v3_12_0 = "3.12.0" + case v3_11_2 = "3.11.2" + case v3_11_1 = "3.11.1" + case v3_11_0 = "3.11.0" + case v3_10_0 = "3.10.0" + case v3_9_2 = "3.9.2" + case v3_9_1 = "3.9.1" + case v3_9_0 = "3.9.0" + case v3_8_0 = "3.8.0" + case v3_7_0 = "3.7.0" + case v3_6_0 = "3.6.0" + case v3_5_1 = "3.5.1" + case v3_5_0 = "3.5.0" + case v3_4_0 = "3.4.0" + case v3_3_0 = "3.3.0" + case v3_2_1 = "3.2.1" + case v3_2_0 = "3.2.0" + case v3_1_1 = "3.1.1" + case v3_1_0 = "3.1.0" + case v3_0_0 = "3.0.0" + case v2_5_0 = "2.5.0" + case v2_4_1 = "2.4.1" + case v2_4_0 = "2.4.0" + case v2_3_0 = "2.3.0" + case v2_2_0 = "2.2.0" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + case v1_1_3 = "1.1.3" + case v1_1_2 = "1.1.2" + case v1_1_1 = "1.1.1" + case v1_1_0 = "1.1.0" + case v1_0_1 = "1.0.1" + case v1_0_0 = "1.0.0" + case v0_34_2 = "0.34.2" + case v0_34_1 = "0.34.1" + case v0_34_0 = "0.34.0" + case v0_33_0 = "0.33.0" + case v0_32_0 = "0.32.0" + case v0_31_3 = "0.31.3" + case v0_31_2 = "0.31.2" + case v0_31_1 = "0.31.1" + case v0_31_0 = "0.31.0" + case v0_30_0 = "0.30.0" + case v0_21_2 = "0.21.2" + case v0_21_1 = "0.21.1" + case v0_21_0 = "0.21.0" + case v0_20_0 = "0.20.0" + case v0_19_0 = "0.19.0" + case v0_18_0 = "0.18.0" + case v0_17_2 = "0.17.2" + case v0_17_1 = "0.17.1" + case v0_17_0 = "0.17.0" + case v0_16_1 = "0.16.1" + case v0_15_0 = "0.15.0" + case v0_14_0 = "0.14.0" + case v0_13_0 = "0.13.0" + case v0_12_0 = "0.12.0" + case v0_11_1 = "0.11.1" + case v0_11_0 = "0.11.0" + case v0_10_0 = "0.10.0" + case v0_9_0 = "0.9.0" + case v0_8_0 = "0.8.0" + case v0_7_0 = "0.7.0" + case v0_6_0 = "0.6.0" + case v0_5_0 = "0.5.0" + case v0_4_0 = "0.4.0" + case v0_3_0 = "0.3.0" + case v0_2_0 = "0.2.0" + case v0_1_0 = "0.1.0" + case v0_0_1 = "0.0.1" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift b/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift new file mode 100644 index 0000000..3e1fb2a --- /dev/null +++ b/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift @@ -0,0 +1,31 @@ +extension Repo { + /// https://github.com/keith/rules_apple_linker + enum AppleLinker: String { + case v0_7_0 = "0.7.0" + case v0_6_3 = "0.6.3" + case v0_6_2 = "0.6.2" + case v0_6_1 = "0.6.1" + case v0_6_0 = "0.6.0" + case v0_5_4 = "0.5.4" + case v0_5_3 = "0.5.3" + case v0_5_2 = "0.5.2" + case v0_5_1 = "0.5.1" + case v0_5_0 = "0.5.0" + case v0_4_0 = "0.4.0" + case v0_3_1 = "0.3.1" + case v0_3_0 = "0.3.0" + case v0_2_4 = "0.2.4" + case v0_2_3 = "0.2.3" + case v0_2_2 = "0.2.2" + case v0_2_1 = "0.2.1" + case v0_2_0 = "0.2.0" + case v0_1_7 = "0.1.7" + case v0_1_6 = "0.1.6" + case v0_1_5 = "0.1.5" + case v0_1_4 = "0.1.4" + case v0_1_3 = "0.1.3" + case v0_1_2 = "0.1.2" + case v0_1_1 = "0.1.1" + case v0_1_0 = "0.1.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Bazel.swift b/Sources/BazelizeKit/Repo/Repo+Bazel.swift new file mode 100644 index 0000000..48a9f54 --- /dev/null +++ b/Sources/BazelizeKit/Repo/Repo+Bazel.swift @@ -0,0 +1,165 @@ +extension Repo { + /// https://github.com/bazelbuild/bazel + enum Bazel: String { + case v9_1_0 = "9.1.0" + case v9_0_2 = "9.0.2" + case v9_0_1 = "9.0.1" + case v9_0_0 = "9.0.0" + case v8_6_0 = "8.6.0" + case v8_5_1 = "8.5.1" + case v8_5_0 = "8.5.0" + case v8_4_2 = "8.4.2" + case v8_4_1 = "8.4.1" + case v8_4_0 = "8.4.0" + case v8_3_1 = "8.3.1" + case v8_3_0 = "8.3.0" + case v8_2_1 = "8.2.1" + case v8_2_0 = "8.2.0" + case v8_1_1 = "8.1.1" + case v8_1_0 = "8.1.0" + case v8_0_1 = "8.0.1" + case v8_0_0 = "8.0.0" + case v7_7_1 = "7.7.1" + case v7_7_0 = "7.7.0" + case v7_6_2 = "7.6.2" + case v7_6_1 = "7.6.1" + case v7_6_0 = "7.6.0" + case v7_5_0 = "7.5.0" + case v7_4_1 = "7.4.1" + case v7_4_0 = "7.4.0" + case v7_3_2 = "7.3.2" + case v7_3_1 = "7.3.1" + case v7_3_0 = "7.3.0" + case v7_2_1 = "7.2.1" + case v7_2_0 = "7.2.0" + case v7_1_2 = "7.1.2" + case v7_1_1 = "7.1.1" + case v7_1_0 = "7.1.0" + case v7_0_2 = "7.0.2" + case v7_0_1 = "7.0.1" + case v7_0_0 = "7.0.0" + case v6_6_0 = "6.6.0" + case v6_5_0 = "6.5.0" + case v6_4_0 = "6.4.0" + case v6_3_2 = "6.3.2" + case v6_3_1 = "6.3.1" + case v6_3_0 = "6.3.0" + case v6_2_1 = "6.2.1" + case v6_2_0 = "6.2.0" + case v6_1_2 = "6.1.2" + case v6_1_1 = "6.1.1" + case v6_1_0 = "6.1.0" + case v6_0_0 = "6.0.0" + case v5_4_1 = "5.4.1" + case v5_4_0 = "5.4.0" + case v5_3_2 = "5.3.2" + case v5_3_1 = "5.3.1" + case v5_3_0 = "5.3.0" + case v5_2_0 = "5.2.0" + case v5_1_1 = "5.1.1" + case v5_1_0 = "5.1.0" + case v5_0_0 = "5.0.0" + case v4_2_4 = "4.2.4" + case v4_2_3 = "4.2.3" + case v4_2_2 = "4.2.2" + case v4_2_1 = "4.2.1" + case v4_2_0 = "4.2.0" + case v4_1_0 = "4.1.0" + case v4_0_0 = "4.0.0" + case v3_7_2 = "3.7.2" + case v3_7_1 = "3.7.1" + case v3_7_0 = "3.7.0" + case v3_6_0 = "3.6.0" + case v3_5_1 = "3.5.1" + case v3_5_0 = "3.5.0" + case v3_4_1 = "3.4.1" + case v3_4_0 = "3.4.0" + case v3_3_1 = "3.3.1" + case v3_3_0 = "3.3.0" + case v3_2_0 = "3.2.0" + case v3_1_0 = "3.1.0" + case v3_0_0 = "3.0.0" + case v2_2_0 = "2.2.0" + case v2_1_1 = "2.1.1" + case v2_1_0 = "2.1.0" + case v2_0_1 = "2.0.1" + case v2_0_0 = "2.0.0" + case v1_2_1 = "1.2.1" + case v1_2_0 = "1.2.0" + case v1_1_0 = "1.1.0" + case v1_0_1 = "1.0.1" + case v1_0_0 = "1.0.0" + case v0_29_1 = "0.29.1" + case v0_29_0 = "0.29.0" + case v0_28_1 = "0.28.1" + case v0_28_0 = "0.28.0" + case v0_27_2 = "0.27.2" + case v0_27_1 = "0.27.1" + case v0_27_0 = "0.27.0" + case v0_26_1 = "0.26.1" + case v0_26_0 = "0.26.0" + case v0_25_3 = "0.25.3" + case v0_25_2 = "0.25.2" + case v0_25_1 = "0.25.1" + case v0_25_0 = "0.25.0" + case v0_24_1 = "0.24.1" + case v0_24_0 = "0.24.0" + case v0_23_2 = "0.23.2" + case v0_23_1 = "0.23.1" + case v0_23_0 = "0.23.0" + case v0_22_0 = "0.22.0" + case v0_21_0 = "0.21.0" + case v0_20_0 = "0.20.0" + case v0_19_2 = "0.19.2" + case v0_19_1 = "0.19.1" + case v0_19_0 = "0.19.0" + case v0_18_1 = "0.18.1" + case v0_18_0 = "0.18.0" + case v0_17_2 = "0.17.2" + case v0_17_1 = "0.17.1" + case v0_16_1 = "0.16.1" + case v0_16_0 = "0.16.0" + case v0_15_2 = "0.15.2" + case v0_15_1 = "0.15.1" + case v0_15_0 = "0.15.0" + case v0_14_1 = "0.14.1" + case v0_14_0 = "0.14.0" + case v0_13_1 = "0.13.1" + case v0_13_0 = "0.13.0" + case v0_12_0 = "0.12.0" + case v0_11_1 = "0.11.1" + case v0_11_0 = "0.11.0" + case v0_10_1 = "0.10.1" + case v0_10_0 = "0.10.0" + case v0_9_0 = "0.9.0" + case v0_8_1 = "0.8.1" + case v0_8_0 = "0.8.0" + case v0_7_0 = "0.7.0" + case v0_6_1 = "0.6.1" + case v0_6_0 = "0.6.0" + case v0_5_4 = "0.5.4" + case v0_5_3 = "0.5.3" + case v0_5_2 = "0.5.2" + case v0_5_1 = "0.5.1" + case v0_5_0 = "0.5.0" + case v0_4_5 = "0.4.5" + case v0_4_4 = "0.4.4" + case v0_4_3 = "0.4.3" + case v0_4_2 = "0.4.2" + case v0_4_1 = "0.4.1" + case v0_4_0 = "0.4.0" + case v0_3_2 = "0.3.2" + case v0_3_1 = "0.3.1" + case v0_3_0 = "0.3.0" + case v0_2_3 = "0.2.3" + case v0_2_2 = "0.2.2" + case v0_2_1 = "0.2.1" + case v0_2_0 = "0.2.0" + case v0_1_5 = "0.1.5" + case v0_1_4 = "0.1.4" + case v0_1_3 = "0.1.3" + case v0_1_2 = "0.1.2" + case v0_1_1 = "0.1.1" + case v0_1_0 = "0.1.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift b/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift new file mode 100644 index 0000000..2a8ad8f --- /dev/null +++ b/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift @@ -0,0 +1,38 @@ +extension Repo { + /// https://github.com/bazelbuild/bazel-skylib + enum BazelSkylib: String { + case v1_9_1 = "1.9.1" + case v1_9_0 = "1.9.0" + case v1_8_2 = "1.8.2" + case v1_8_1 = "1.8.1" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_1 = "1.6.1" + case v1_6_0 = "1.6.0" + case v1_5_0 = "1.5.0" + case v1_4_2 = "1.4.2" + case v1_4_1 = "1.4.1" + case v1_4_0 = "1.4.0" + case v1_3_0 = "1.3.0" + case v1_2_1 = "1.2.1" + case v1_2_0 = "1.2.0" + case v1_1_1 = "1.1.1" + case v1_1_0 = "1.1.0" + case v1_0_3 = "1.0.3" + case v1_0_2 = "1.0.2" + case v1_0_1 = "1.0.1" + case v1_0_0 = "1.0.0" + case v0_9_0 = "0.9.0" + case v0_8_0 = "0.8.0" + case v0_7_0 = "0.7.0" + case v0_6_0 = "0.6.0" + case v0_5_0 = "0.5.0" + case v0_4_0 = "0.4.0" + case v0_3_1 = "0.3.1" + case v0_3_0 = "0.3.0" + case v0_2_0 = "0.2.0" + case v0_1_1 = "0.1.1" + case v0_1_0 = "0.1.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Hammer.swift b/Sources/BazelizeKit/Repo/Repo+Hammer.swift deleted file mode 100644 index c58e282..0000000 --- a/Sources/BazelizeKit/Repo/Repo+Hammer.swift +++ /dev/null @@ -1,27 +0,0 @@ - -extension Repo { - /// https://github.com/pinterest/xchammer - enum Hammer: String { - case v3_4_3_3 = "v3.4.3.3" - case v3_4_3_2 = "v3.4.3.2" - case v3_4_3_1 = "v3.4.3.1" - case v3_4_2_2 = "v3.4.2.2" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v3_4_3_3: return "1fe8c3a283f3cfc3c7a3765e185103949bfa889c0e806897ac7ac247582d9a80" - case .v3_4_3_2: return "cddf5fd1d0b6015a03a0b6eacd675093c9e0175c25357ab35de2e9a928d60fa5" - case .v3_4_3_1: return "725d55d3f62e82c14544d479877862a4c2b1d4d7e903b38feb239c5a65aaa4c9" - case .v3_4_2_2: return "20892993972a0a1b8dae305eb4f822d6374848a5e5631a811b88d73a0faa038a" - } - } - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Pod.swift b/Sources/BazelizeKit/Repo/Repo+Pod.swift deleted file mode 100644 index 6e55fc1..0000000 --- a/Sources/BazelizeKit/Repo/Repo+Pod.swift +++ /dev/null @@ -1,29 +0,0 @@ - -extension Repo { - /// https://github.com/pinterest/PodToBUILD - enum Pod: String { - case v4_1_0_412495 = "4.1.0-412495" - case v4_0_0_5787125 = "4.0.0-5787125" - case v4_0_0_2096f5c = "4.0.0-2096f5c" - case v4_0_0_7673f06 = "4.0.0-7673f06" - case v4_0_0_f96b657 = "4.0.0-f96b657" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v4_1_0_412495: return "c96bbfb6364a76e09d8239914990328e66074096038728f1a1b26c62d9081af6" - case .v4_0_0_5787125: return "d697642a6ca9d4d0441a5a6132e9f2bf70e8e9ee0080c3c780fe57e698e79d82" - case .v4_0_0_2096f5c: return "27e168882f74adc33c901d4c930bddbdc38282185bedb8737290022891737f02" - case .v4_0_0_7673f06: return "92eccc22950dcc86e86f4cbc3fb538b4b927da2cd765627ba099f30aa7dbf73b" - case .v4_0_0_f96b657: return "1faf148ba6f0e494d5ccd730ae26130a5966161be034e775f76317897cf68aad" - } - } - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+RulesCC.swift b/Sources/BazelizeKit/Repo/Repo+RulesCC.swift new file mode 100644 index 0000000..6779e76 --- /dev/null +++ b/Sources/BazelizeKit/Repo/Repo+RulesCC.swift @@ -0,0 +1,47 @@ +extension Repo { + /// https://github.com/bazelbuild/rules_cc + enum RulesCC: String { + case v0_2_18 = "0.2.18" + case v0_2_17 = "0.2.17" + case v0_2_16 = "0.2.16" + case v0_2_15 = "0.2.15" + case v0_2_14 = "0.2.14" + case v0_2_13 = "0.2.13" + case v0_2_12 = "0.2.12" + case v0_2_11 = "0.2.11" + case v0_2_10 = "0.2.10" + case v0_2_9 = "0.2.9" + case v0_2_8 = "0.2.8" + case v0_2_7 = "0.2.7" + case v0_2_6 = "0.2.6" + case v0_2_5 = "0.2.5" + case v0_2_4 = "0.2.4" + case v0_2_3 = "0.2.3" + case v0_2_2 = "0.2.2" + case v0_2_1 = "0.2.1" + case v0_2_0 = "0.2.0" + case v0_1_5 = "0.1.5" + case v0_1_4 = "0.1.4" + case v0_1_3 = "0.1.3" + case v0_1_2 = "0.1.2" + case v0_1_1 = "0.1.1" + case v0_1_0 = "0.1.0" + case v0_0_17 = "0.0.17" + case v0_0_16 = "0.0.16" + case v0_0_15 = "0.0.15" + case v0_0_14 = "0.0.14" + case v0_0_13 = "0.0.13" + case v0_0_12 = "0.0.12" + case v0_0_11 = "0.0.11" + case v0_0_10 = "0.0.10" + case v0_0_9 = "0.0.9" + case v0_0_8 = "0.0.8" + case v0_0_7 = "0.0.7" + case v0_0_6 = "0.0.6" + case v0_0_5 = "0.0.5" + case v0_0_4 = "0.0.4" + case v0_0_3 = "0.0.3" + case v0_0_2 = "0.0.2" + case v0_0_1 = "0.0.1" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Swift.swift b/Sources/BazelizeKit/Repo/Repo+Swift.swift index d9c191f..ec90bcc 100644 --- a/Sources/BazelizeKit/Repo/Repo+Swift.swift +++ b/Sources/BazelizeKit/Repo/Repo+Swift.swift @@ -1,29 +1,94 @@ - extension Repo { /// https://github.com/bazelbuild/rules_swift enum Swift: String { + case v3_6_1 = "3.6.1" + case v3_6_0 = "3.6.0" case v3_5_0 = "3.5.0" case v3_4_2 = "3.4.2" case v3_4_1 = "3.4.1" case v3_4_0 = "3.4.0" case v3_3_0 = "3.3.0" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v3_5_0: return "c98f201bc217d2ce28e01afc78b410d05c67b846c04a7095e4e701b37422ecb2" - case .v3_4_2: return "03a5c2a93398f2fc4d6ddfb76cf80cd957483ec286d34f50cc22cda002aab445" - case .v3_4_1: return "6309d226474c6b9293f790d3da43d3b04dc0a71b75b87df3107871a0ea59d5f6" - case .v3_4_0: return "13219bde174594c7af5403c7f3f41c37d1a62041294a0fd14c0834ca472fa8dc" - case .v3_3_0: return "94136edf1ccdc7b9bb68ff85e006fe698ea161a02fbee55ba1feb4ce71522cfb" - } - } + case v3_2_0 = "3.2.0" + case v3_1_2 = "3.1.2" + case v3_1_1 = "3.1.1" + case v3_1_0 = "3.1.0" + case v3_0_2 = "3.0.2" + case v3_0_0 = "3.0.0" + case v2_9_0 = "2.9.0" + case v2_8_2 = "2.8.2" + case v2_8_1 = "2.8.1" + case v2_8_0 = "2.8.0" + case v2_7_0 = "2.7.0" + case v2_6_0 = "2.6.0" + case v2_5_0 = "2.5.0" + case v2_4_0 = "2.4.0" + case v2_3_1 = "2.3.1" + case v2_3_0 = "2.3.0" + case v2_2_4 = "2.2.4" + case v2_2_3 = "2.2.3" + case v2_2_2 = "2.2.2" + case v2_2_1 = "2.2.1" + case v2_2_0 = "2.2.0" + case v2_1_1 = "2.1.1" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + case v1_18_0 = "1.18.0" + case v1_17_0 = "1.17.0" + case v1_16_0 = "1.16.0" + case v1_15_1 = "1.15.1" + case v1_15_0 = "1.15.0" + case v1_14_0 = "1.14.0" + case v1_13_0 = "1.13.0" + case v1_12_0 = "1.12.0" + case v1_11_0 = "1.11.0" + case v1_10_1 = "1.10.1" + case v1_10_0 = "1.10.0" + case v1_9_1 = "1.9.1" + case v1_9_0 = "1.9.0" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_0 = "1.6.0" + case v1_5_1 = "1.5.1" + case v1_5_0 = "1.5.0" + case v1_4_0 = "1.4.0" + case v1_3_0 = "1.3.0" + case v1_2_0 = "1.2.0" + case v1_1_1 = "1.1.1" + case v1_1_0 = "1.1.0" + case v1_0_0 = "1.0.0" + case v0_27_0 = "0.27.0" + case v0_26_0 = "0.26.0" + case v0_25_0 = "0.25.0" + case v0_24_0 = "0.24.0" + case v0_23_0 = "0.23.0" + case v0_22_0 = "0.22.0" + case v0_21_0 = "0.21.0" + case v0_20_0 = "0.20.0" + case v0_19_0 = "0.19.0" + case v0_18_0 = "0.18.0" + case v0_17_0 = "0.17.0" + case v0_16_1 = "0.16.1" + case v0_16_0 = "0.16.0" + case v0_15_0 = "0.15.0" + case v0_14_0 = "0.14.0" + case v0_13_0 = "0.13.0" + case v0_12_1 = "0.12.1" + case v0_12_0 = "0.12.0" + case v0_11_1 = "0.11.1" + case v0_11_0 = "0.11.0" + case v0_10_1 = "0.10.1" + case v0_9_0 = "0.9.0" + case v0_8_0 = "0.8.0" + case v0_7_0 = "0.7.0" + case v0_6_0 = "0.6.0" + case v0_5_0 = "0.5.0" + case v0_4_0 = "0.4.0" + case v0_3_1 = "0.3.1" + case v0_3_0 = "0.3.0" + case v0_2_0 = "0.2.0" + case v0_1_3 = "0.1.3" + case v0_1_1 = "0.1.1" + case v0_1_0 = "0.1.0" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift b/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift index f54db48..539d1eb 100644 --- a/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift +++ b/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift @@ -1,19 +1,100 @@ extension Repo { - /// http://github.com/cgrindel/rules_swift_package_manager - enum SPM: String { + /// https://github.com/cgrindel/rules_swift_package_manager + enum SwiftPM: String { + case v1_15_0 = "1.15.0" + case v1_14_0 = "1.14.0" case v1_13_0 = "1.13.0" - - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - "" - } + case v1_12_0 = "1.12.0" + case v1_11_3 = "1.11.3" + case v1_11_2 = "1.11.2" + case v1_11_1 = "1.11.1" + case v1_11_0 = "1.11.0" + case v1_10_0 = "1.10.0" + case v1_9_0 = "1.9.0" + case v1_8_0 = "1.8.0" + case v1_7_0 = "1.7.0" + case v1_6_0 = "1.6.0" + case v1_5_0 = "1.5.0" + case v1_4_0 = "1.4.0" + case v1_3_0 = "1.3.0" + case v1_2_0 = "1.2.0" + case v1_1_0 = "1.1.0" + case v1_0_0 = "1.0.0" + case v0_47_2 = "0.47.2" + case v0_47_1 = "0.47.1" + case v0_47_0 = "0.47.0" + case v0_46_0 = "0.46.0" + case v0_45_0 = "0.45.0" + case v0_44_0 = "0.44.0" + case v0_43_0 = "0.43.0" + case v0_42_0 = "0.42.0" + case v0_41_0 = "0.41.0" + case v0_40_1 = "0.40.1" + case v0_40_0 = "0.40.0" + case v0_39_0 = "0.39.0" + case v0_38_2 = "0.38.2" + case v0_38_1 = "0.38.1" + case v0_38_0 = "0.38.0" + case v0_37_0 = "0.37.0" + case v0_36_0 = "0.36.0" + case v0_35_1 = "0.35.1" + case v0_35_0 = "0.35.0" + case v0_34_1 = "0.34.1" + case v0_34_0 = "0.34.0" + case v0_33_0 = "0.33.0" + case v0_32_0 = "0.32.0" + case v0_31_1 = "0.31.1" + case v0_31_0 = "0.31.0" + case v0_30_0 = "0.30.0" + case v0_29_2 = "0.29.2" + case v0_29_1 = "0.29.1" + case v0_29_0 = "0.29.0" + case v0_28_0 = "0.28.0" + case v0_27_0 = "0.27.0" + case v0_26_2 = "0.26.2" + case v0_26_1 = "0.26.1" + case v0_26_0 = "0.26.0" + case v0_25_0 = "0.25.0" + case v0_24_0 = "0.24.0" + case v0_23_0 = "0.23.0" + case v0_22_0 = "0.22.0" + case v0_21_0 = "0.21.0" + case v0_20_0 = "0.20.0" + case v0_19_0 = "0.19.0" + case v0_18_2 = "0.18.2" + case v0_18_1 = "0.18.1" + case v0_18_0 = "0.18.0" + case v0_17_0 = "0.17.0" + case v0_16_0 = "0.16.0" + case v0_15_0 = "0.15.0" + case v0_14_0 = "0.14.0" + case v0_13_1 = "0.13.1" + case v0_13_0 = "0.13.0" + case v0_12_1 = "0.12.1" + case v0_12_0 = "0.12.0" + case v0_11_1 = "0.11.1" + case v0_11_0 = "0.11.0" + case v0_10_0 = "0.10.0" + case v0_9_0 = "0.9.0" + case v0_8_0 = "0.8.0" + case v0_7_1 = "0.7.1" + case v0_7_0 = "0.7.0" + case v0_6_0 = "0.6.0" + case v0_5_0 = "0.5.0" + case v0_4_4 = "0.4.4" + case v0_4_3 = "0.4.3" + case v0_4_2 = "0.4.2" + case v0_4_1 = "0.4.1" + case v0_4_0 = "0.4.0" + case v0_3_3 = "0.3.3" + case v0_3_2 = "0.3.2" + case v0_3_1 = "0.3.1" + case v0_3_0 = "0.3.0" + case v0_2_2 = "0.2.2" + case v0_2_1 = "0.2.1" + case v0_2_0 = "0.2.0" + case v0_1_0 = "0.1.0" + case v0_0_2 = "0.0.2" + case v0_0_1 = "0.0.1" } -} +} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift b/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift index a1371a6..0181ad6 100644 --- a/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift +++ b/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift @@ -1,29 +1,92 @@ - extension Repo { - /// https://github.com/buildbuddy-io/rules_xcodeproj + /// https://github.com/MobileNativeFoundation/rules_xcodeproj enum XCodeProj: String { + case v4_0_1 = "4.0.1" + case v4_0_0 = "4.0.0" case v3_6_0 = "3.6.0" case v3_5_1 = "3.5.1" case v3_4_1 = "3.4.1" case v3_4_0 = "3.4.0" case v3_3_0 = "3.3.0" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v3_6_0: return "207fc87aa2573c9c942d247c1db2416d6feaefd2e5c730ec1d7e640018fb4ca0" - case .v3_5_1: return "dc3872fb50d16bbe7df035ea22eac4f79d0957036e226d6df9de5de389434393" - case .v3_4_1: return "b25cb08c7c6f0c813984ed029f97b40357453d359b5fe494170dbaf46cc9c7db" - case .v3_4_0: return "34473ab1756b357393ac45737718147a8a9b4a5607664bcc1b57fc203ffbf249" - case .v3_3_0: return "78e17fd58175334abf1cdec46caa09b60da9e6cf3a07d51a9ee540f1ba712799" - } - } + case v3_2_0 = "3.2.0" + case v3_1_2 = "3.1.2" + case v3_1_1 = "3.1.1" + case v3_1_0 = "3.1.0" + case v3_0_0 = "3.0.0" + case v2_12_1 = "2.12.1" + case v2_12_0 = "2.12.0" + case v2_11_2 = "2.11.2" + case v2_11_1 = "2.11.1" + case v2_11_0 = "2.11.0" + case v2_10_0 = "2.10.0" + case v2_9_2 = "2.9.2" + case v2_9_1 = "2.9.1" + case v2_9_0 = "2.9.0" + case v2_8_1 = "2.8.1" + case v2_8_0 = "2.8.0" + case v2_7_0 = "2.7.0" + case v2_6_1 = "2.6.1" + case v2_6_0 = "2.6.0" + case v2_5_2 = "2.5.2" + case v2_5_1 = "2.5.1" + case v2_5_0 = "2.5.0" + case v2_4_0 = "2.4.0" + case v2_3_1 = "2.3.1" + case v2_3_0 = "2.3.0" + case v2_2_0 = "2.2.0" + case v2_1_1 = "2.1.1" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + case v1_18_0 = "1.18.0" + case v1_17_0 = "1.17.0" + case v1_16_0 = "1.16.0" + case v1_15_0 = "1.15.0" + case v1_14_2 = "1.14.2" + case v1_14_1 = "1.14.1" + case v1_14_0 = "1.14.0" + case v1_13_0 = "1.13.0" + case v1_12_1 = "1.12.1" + case v1_12_0 = "1.12.0" + case v1_11_0 = "1.11.0" + case v1_10_1 = "1.10.1" + case v1_10_0 = "1.10.0" + case v1_9_1 = "1.9.1" + case v1_9_0 = "1.9.0" + case v1_8_1 = "1.8.1" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_0 = "1.6.0" + case v1_5_1 = "1.5.1" + case v1_5_0 = "1.5.0" + case v1_4_0 = "1.4.0" + case v1_3_3 = "1.3.3" + case v1_3_2 = "1.3.2" + case v1_3_1 = "1.3.1" + case v1_3_0 = "1.3.0" + case v1_2_0 = "1.2.0" + case v1_1_0 = "1.1.0" + case v1_0_1 = "1.0.1" + case v0_12_3 = "0.12.3" + case v0_12_2 = "0.12.2" + case v0_12_1 = "0.12.1" + case v0_12_0 = "0.12.0" + case v0_11_0 = "0.11.0" + case v0_10_2 = "0.10.2" + case v0_10_1 = "0.10.1" + case v0_10_0 = "0.10.0" + case v0_9_0 = "0.9.0" + case v0_8_0 = "0.8.0" + case v0_7_1 = "0.7.1" + case v0_7_0 = "0.7.0" + case v0_6_0 = "0.6.0" + case v0_5_1 = "0.5.1" + case v0_5_0 = "0.5.0" + case v0_4_2 = "0.4.2" + case v0_4_1 = "0.4.1" + case v0_4_0 = "0.4.0" + case v0_3_0 = "0.3.0" + case v0_2_0 = "0.2.0" + case v0_1_0 = "0.1.0" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift new file mode 100644 index 0000000..e61c36a --- /dev/null +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -0,0 +1,175 @@ +import Foundation + +// MARK: - Bazel.Roadmap + +extension Bazel { + struct Roadmap { + let output: Path + let project: Project + + func prepare() throws { + try? output.delete() + try output.mkpath() + try linkPackageResolvedIfPresent(project: project) + try preparePrebuiltFiles(project: project) + + let targetsRoot = output + "Targets" + try targetsRoot.mkpath() + + for target in project.targets { + try prepare(target: target, project: project, targetsRoot: targetsRoot) + } + } + + private func prepare( + target: Target, + project: Project, + targetsRoot: Path) throws + { + let targetRoot = targetsRoot + target.name + let sourcesRoot = targetRoot + "Sources" + let generatedRoot = targetRoot + "Generated" + + try sourcesRoot.mkpath() + try generatedRoot.mkpath() + + var materializedDirectories = Set() + for relativePath in target.pathsForRoadmapTree { + let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let hasMaterializedAncestor = materializedDirectories.contains { existing in + normalizedPath == existing || normalizedPath.hasPrefix(existing + "/") + } + guard !hasMaterializedAncestor else { continue } + + let source = Path(project.workspacePath) + relativePath + guard source.exists else { continue } + guard !source.isSelfReferentialSymlink else { continue } + + let destination = sourcesRoot + relativePath + try materialize(source: source, destination: destination) + if source.isDirectory { + materializedDirectories.insert(normalizedPath) + } + } + } + + private func preparePrebuiltFiles(project: XCode2.XCode.Project) throws { + let prebuiltRoot = output + "Prebuilt" + try prebuiltRoot.mkpath() + + for file in project.prebuiltFiles { + guard let relativePath = file.path, !relativePath.isEmpty else { continue } + let source = Path(project.workspacePath) + relativePath + guard source.exists else { continue } + guard !source.isSelfReferentialSymlink else { continue } + + let destination = prebuiltRoot + Path(relativePath).lastComponent + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + } + + private func linkPackageResolvedIfPresent(project: XCode2.XCode.Project) throws { + let source = Path(project.workspacePath) + "Package.resolved" + guard source.exists else { return } + + let destination = output + "Package.resolved" + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + + private func replaceIfNeeded(at path: Path) throws { + guard path.exists || path.isSymlink else { return } + try path.delete() + } + + private func materialize(source: Path, destination: Path) throws { + guard !source.isRoadmapIgnoredFile else { return } + + if source.isDirectory { + if destination.isSymlink { + try destination.delete() + } + if !destination.exists { + try destination.mkpath() + } + for child in try source.children() { + guard !child.isSelfReferentialSymlink else { continue } + try materialize(source: child, destination: destination + child.lastComponent) + } + return + } + + try destination.parent().mkpath() + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + } +} + +extension XCode2.XCode.Target { + fileprivate var pathsForRoadmapTree: [String] { + let allFiles = files.sources + files.headers + files.resources + files.others + let candidates = allFiles.compactMap(\.roadmapRelativePath).sorted { + let lhsDepth = $0.split(separator: "/").count + let rhsDepth = $1.split(separator: "/").count + if lhsDepth == rhsDepth { + return $0 < $1 + } + return lhsDepth < rhsDepth + } + + var result: [String] = [] + var seen = Set() + + for path in candidates where seen.insert(path).inserted { + let hasAncestor = result.contains { existing in + path == existing || path.hasPrefix(existing + "/") + } + guard !hasAncestor else { continue } + result.append(path) + } + + return result + } +} + +extension XCode2.XCode.Project { + fileprivate var prebuiltFiles: [XCode2.XCode.File] { + let all = targets.flatMap { target in + target.files.frameworks.filter { $0.label?.hasPrefix("//Prebuilt:") == true } + } + + var seen = Set() + return all.filter { file in + guard let path = file.path else { return false } + return seen.insert(path).inserted + } + } +} + +extension XCode2.XCode.File { + fileprivate var roadmapRelativePath: String? { + if let path, !path.isEmpty { + return path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + return nil + } +} + +extension Path { + fileprivate var isSelfReferentialSymlink: Bool { + guard isSymlink else { return false } + guard let destination = try? symlinkDestination().absolute() else { return false } + return destination == absolute() + } + + fileprivate var isRoadmapIgnoredFile: Bool { + switch lastComponent { + case "BUILD", "BUILD.bazel": + return true + default: + return false + } + } +} diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XCode2Compat.swift new file mode 100644 index 0000000..a96ff50 --- /dev/null +++ b/Sources/BazelizeKit/XCode2Compat.swift @@ -0,0 +1,59 @@ +import Foundation +import PathKit +import Starlark + +typealias Project = XCode2.XCode.Project +typealias Target = XCode2.XCode.Target +typealias BuildSettings = XCode2.XCode.BuildSettings +typealias File = XCode2.XCode.File +typealias RemotePackage = XCode2.XCode.RemotePackage +typealias LocalPackage = XCode2.XCode.LocalPackage +typealias PackageProductDependency = XCode2.XCode.PackageProductDependency +typealias DeviceFamily = XCode2.XCode.DeviceFamily + +extension Dictionary where Key == String, Value == BuildSettings { + func select(_ keypath: KeyPath) -> Starlark.Select { + let values = map { _, setting in + setting[keyPath: keypath] + } + + if Set(values).count == 1, let first = first?.value[keyPath: keypath] { + return .same(first) + } + + let result: [Starlark.Label: T] = reduce(into: [:]) { partialResult, entry in + partialResult[.config(entry.key)] = entry.value[keyPath: keypath] + } + return .various(result) + } +} + +extension Target { + func select(_ keyPath: KeyPath, project _: Project) -> Starlark.Select { + configs.select(keyPath) + } + + var frameworksLibrary: [Starlark.Label] { + let targetLabels = dependencies.targets + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)_library") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } + + var frameworks: [Starlark.Label] { + let targetLabels = dependencies.targets + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } +} diff --git a/Sources/RepoEnumCore/RepoEnumCore.swift b/Sources/RepoEnumCore/RepoEnumCore.swift new file mode 100644 index 0000000..81235f9 --- /dev/null +++ b/Sources/RepoEnumCore/RepoEnumCore.swift @@ -0,0 +1,238 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Yams + +// MARK: - RepoSource + +public struct RepoSource: Codable, Sendable, Equatable { + public let name: String + public let url: String + + public init(name: String, url: String) { + self.name = name + self.url = url + } +} + +// MARK: - RepoVersionTag + +public struct RepoVersionTag: Sendable, Equatable { + public let normalizedVersion: String + public let caseName: String + private let components: [Int] + + public init?(rawTag: String) { + let pattern = #"^v?(\d+)\.(\d+)\.(\d+)$"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(rawTag.startIndex.. Int? in + guard let range = Range(match.range(at: index), in: rawTag) else { return nil } + return Int(rawTag[range]) + } + + guard values.count == 3 else { return nil } + + components = values + normalizedVersion = values.map(String.init).joined(separator: ".") + caseName = "v" + normalizedVersion.replacingOccurrences(of: ".", with: "_") + } + + public static func sortDescending(_ lhs: RepoVersionTag, _ rhs: RepoVersionTag) -> Bool { + lhs.components.lexicographicallyPrecedes(rhs.components) == false && lhs.components != rhs.components + ? true + : lhs.components == rhs.components ? lhs.normalizedVersion > rhs.normalizedVersion : false + } + + public static func sortedDescending(_ tags: [RepoVersionTag]) -> [RepoVersionTag] { + tags.sorted { lhs, rhs in + for (left, right) in zip(lhs.components, rhs.components) { + if left != right { + return left > right + } + } + return lhs.normalizedVersion > rhs.normalizedVersion + } + } +} + +// MARK: - GitHubTagFetching + +public protocol GitHubTagFetching: Sendable { + func tags(for repositoryURL: String) async throws -> [String] +} + +// MARK: - RepoEnumGeneratorError + +public enum RepoEnumGeneratorError: LocalizedError { + case invalidArguments(String) + case invalidGitHubURL(String) + case githubRequestFailed(statusCode: Int, message: String) + + public var errorDescription: String? { + switch self { + case .invalidArguments(let message): + return message + case .invalidGitHubURL(let url): + return "Invalid GitHub repository URL: \(url)" + case .githubRequestFailed(let statusCode, let message): + return "GitHub API request failed (\(statusCode)): \(message)" + } + } +} + +// MARK: - RepoEnumFile + +public struct RepoEnumFile: Equatable { + public let source: RepoSource + public let tags: [RepoVersionTag] + + public init(source: RepoSource, tags: [RepoVersionTag]) { + var deduplicated: [String: RepoVersionTag] = [:] + for tag in tags { + deduplicated[tag.normalizedVersion] = tag + } + + self.source = source + self.tags = RepoVersionTag.sortedDescending(Array(deduplicated.values)) + } + + public var filename: String { + "Repo+\(source.name).swift" + } + + public var content: String { + let cases = tags.map { #" case \#($0.caseName) = "\#($0.normalizedVersion)""# } + .joined(separator: "\n") + + let body = cases.isEmpty ? "" : "\(cases)\n" + return """ + extension Repo { + /// \(source.url) + enum \(source.name): String { + \(body) } + } + """ + } +} + +// MARK: - GitHubTagClient + +public struct GitHubTagClient: GitHubTagFetching { + private struct ResponseTag: Decodable { + let name: String + } + + private struct ErrorResponse: Decodable { + let message: String + } + + private let session: URLSession + private let token: String? + + public init( + session: URLSession = .shared, + token: String? = nil) + { + self.session = session + self.token = token ?? ProcessInfo.processInfo.environment["GITHUB_TOKEN"]?.nilIfEmpty + } + + public func tags(for repositoryURL: String) async throws -> [String] { + let repositoryPath = try Self.repositoryPath(from: repositoryURL) + var page = 1 + var allTags: [String] = [] + + while true { + let url = URL(string: "https://api.github.com/repos/\(repositoryPath)/tags?per_page=100&page=\(page)")! + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("Bazelize RepoEnumPlugin", forHTTPHeaderField: "User-Agent") + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + let message = (try? JSONDecoder().decode(ErrorResponse.self, from: data).message) + ?? HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) + throw RepoEnumGeneratorError.githubRequestFailed( + statusCode: httpResponse.statusCode, + message: message) + } + + let tags = try JSONDecoder().decode([ResponseTag].self, from: data) + if tags.isEmpty { + break + } + + allTags.append(contentsOf: tags.map(\.name)) + page += 1 + } + + return allTags + } + + static func repositoryPath(from repositoryURL: String) throws -> String { + guard let url = URL(string: repositoryURL), let host = url.host?.lowercased(), host == "github.com" else { + throw RepoEnumGeneratorError.invalidGitHubURL(repositoryURL) + } + + let parts = url.pathComponents.filter { $0 != "/" } + guard parts.count >= 2 else { + throw RepoEnumGeneratorError.invalidGitHubURL(repositoryURL) + } + + let owner = parts[0] + let repo = parts[1].replacingOccurrences(of: ".git", with: "") + return "\(owner)/\(repo)" + } +} + +// MARK: - RepoEnumGeneratorService + +public struct RepoEnumGeneratorService { + private let client: GitHubTagFetching + private let decoder = YAMLDecoder() + private let fileManager = FileManager.default + + public init(client: GitHubTagFetching = GitHubTagClient()) { + self.client = client + } + + public func generate(configFile: URL, outputDirectory: URL) async throws { + let data = try Data(contentsOf: configFile) + let sources = try decoder.decode([RepoSource].self, from: String(decoding: data, as: UTF8.self)) + + try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + + for source in sources { + let rawTags = try await client.tags(for: source.url) + let tags = rawTags.compactMap(RepoVersionTag.init(rawTag:)) + let file = RepoEnumFile(source: source, tags: tags) + let fileURL = outputDirectory.appendingPathComponent(file.filename) + try file.content.write(to: fileURL, atomically: true, encoding: .utf8) + } + } +} + +// MARK: - RepoEnumPaths + +public enum RepoEnumPaths { + public static func resolve(_ path: String, from base: URL, isDirectory: Bool = false) -> URL { + let url = URL(fileURLWithPath: path, isDirectory: isDirectory) + return url.path.hasPrefix("/") ? url : base.appendingPathComponent(path, isDirectory: isDirectory) + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Sources/RepoEnumGenerator/Entry.swift b/Sources/RepoEnumGenerator/Entry.swift new file mode 100644 index 0000000..336e4c9 --- /dev/null +++ b/Sources/RepoEnumGenerator/Entry.swift @@ -0,0 +1,23 @@ +import ArgumentParser +import Foundation +import RepoEnumCore + +@main +struct RepoEnumGeneratorCommand: AsyncParsableCommand { + @Option(name: .long, help: "YAML config file listing repo enum sources.") + var config = "RepoSources.yml" + + @Option(name: .long, help: "Directory where generated Repo+*.swift files will be written.") + var output = "Generated" + + mutating func run() async throws { + let currentDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let configPath = RepoEnumPaths.resolve(config, from: currentDirectory) + let outputPath = RepoEnumPaths.resolve(output, from: currentDirectory, isDirectory: true) + + let service = RepoEnumGeneratorService() + try await service.generate( + configFile: configPath, + outputDirectory: outputPath) + } +} diff --git a/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift b/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift index 0efbcee..4ff4926 100644 --- a/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift @@ -7,8 +7,7 @@ struct ConfigListLoader: Hashable { (native?.buildConfigurations ?? []).map { config in ( config.name, - .init(config) - ) + .init(config)) }.toDictionary() } @@ -21,8 +20,7 @@ struct ConfigListLoader: Hashable { return configs.map { name, current in ( name, - current.merged(with: defaults[name]) - ) + current.merged(with: defaults[name])) }.toDictionary() } diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index 6ef6f56..37a76df 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -2,6 +2,8 @@ import Foundation import PathKit import XcodeProj +// MARK: - KnownFileType + enum KnownFileType: String { case swift = "sourcecode.swift" case objc = "sourcecode.c.objc" @@ -45,6 +47,8 @@ enum KnownFileType: String { } } +// MARK: - FileLoader + struct FileLoader { let native: PBXFileElement unowned let project: ProjectLoader @@ -118,8 +122,7 @@ struct FileLoader { sourceTree: sourceTree, buildPhase: buildPhase, compilerFlags: compilerFlags, - attributes: attributes - ) + attributes: attributes) } func label(buildPhase: String?) -> String? { @@ -128,8 +131,7 @@ struct FileLoader { } return project.transformToLabel( relativePath, - .source(packageName: packageName) - ) + .source(packageName: packageName)) } private var canUsePrebuiltLabel: Bool { @@ -146,6 +148,8 @@ struct FileLoader { } } +// MARK: - SynchronizedFile + struct SynchronizedFile { enum Category { case source @@ -181,8 +185,7 @@ struct SynchronizedFile { sourceTree: "", buildPhase: buildPhase, compilerFlags: compilerFlags, - attributes: [] - ) + attributes: []) } private var buildPhase: String? { @@ -200,8 +203,8 @@ struct SynchronizedFile { } } -private extension KnownFileType { - var category: SynchronizedFile.Category { +extension KnownFileType { + fileprivate var category: SynchronizedFile.Category { switch self { case .swift, .objc, .objcxx, .c, .cpp, .metal: return .source @@ -214,7 +217,7 @@ private extension KnownFileType { } } - var isBinaryArtifact: Bool { + fileprivate var isBinaryArtifact: Bool { switch self { case .staticLibrary, .xcframework, .framework: return true diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index 5a764b4..430c928 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -9,29 +9,31 @@ import Foundation import PathKit import XcodeProj +// MARK: - ProjectLoader + final class ProjectLoader { private let xcodeProj: XcodeProj private let native: PBXProj private let path: Path let preferConfig: String? - + init(path: Path, preferConfig: String?) throws { self.path = path self.preferConfig = preferConfig xcodeProj = try XcodeProj(path: path) native = xcodeProj.pbxproj } - + var rootProject: PBXProject? { native.rootObject } - + var workspacePath: Path { path.parent() } - + func model() throws -> XCode.Project { - return XCode.Project( + XCode.Project( name: rootProject?.name ?? path.lastComponentWithoutExtension, workspacePath: workspacePath.string, projectPath: path.string, @@ -39,32 +41,25 @@ final class ProjectLoader { configs: defaultConfigList?.configs ?? [:], packages: .init( remote: remotePackages, - local: localPackages - ), - targets: targets.map(\.model) - ) + local: localPackages), + targets: targets.map(\.model)) + } + + private lazy var allFiles: [PBXFileElement] = (try? native.rootGroup()?.flatten()) ?? [] + + private lazy var targets: [TargetLoader] = native.nativeTargets.map { + TargetLoader( + native: $0, + project: self, + defaultConfigList: defaultConfigList) } - private lazy var allFiles: [PBXFileElement] = { - (try? native.rootGroup()?.flatten()) ?? [] - }() - - private lazy var targets: [TargetLoader] = { - native.nativeTargets.map { - TargetLoader( - native: $0, - project: self, - defaultConfigList: defaultConfigList - ) - } - }() - private lazy var defaultConfigList: ConfigListLoader? = { let all = Set(native.configurationLists.map { ConfigListLoader(native: $0) }) let targetLists = native.nativeTargets.map { ConfigListLoader(native: $0.buildConfigurationList) } - + return all.subtracting(targetLists).first }() } @@ -76,8 +71,7 @@ extension ProjectLoader { .init( name: package.name, repositoryURL: package.repositoryURL, - requirement: package.versionRequirement?.stringValue - ) + version: package.versionRequirement?.requirementValue) } } @@ -85,13 +79,11 @@ extension ProjectLoader { let explicit = (rootProject?.localPackages ?? []).map { package in XCode.LocalPackage( name: package.name, - relativePath: package.relativePath - ) + relativePath: package.relativePath) } return Self.mergeLocalPackages( explicit: explicit, - discovered: discoveredLocalPackages - ) + discovered: discoveredLocalPackages) } private var discoveredLocalPackages: [XCode.LocalPackage] { @@ -107,8 +99,7 @@ extension ProjectLoader { return XCode.LocalPackage( name: file.name ?? packageRoot.lastComponent, - relativePath: relativePath - ) + relativePath: relativePath) } } @@ -149,7 +140,7 @@ extension ProjectLoader { enum LabelKind { case source(packageName: String?) case prebuilt - + var packageName: String { switch self { case .source(let packageName): @@ -159,20 +150,22 @@ extension ProjectLoader { } } } - + func transformToLabel( _ relativePath: String?, - _ kind: LabelKind - ) -> String? { + _ kind: LabelKind) + -> String? + { guard let path = relativePath else { return nil } - + return "//\(kind.packageName):\(path)" } static func mergeLocalPackages( explicit: [XCode.LocalPackage], - discovered: [XCode.LocalPackage] - ) -> [XCode.LocalPackage] { + discovered: [XCode.LocalPackage]) + -> [XCode.LocalPackage] + { var result: [XCode.LocalPackage] = [] var seen = Set() @@ -186,11 +179,13 @@ extension ProjectLoader { } } -private extension ProjectLoader { - func targetOwnsFile(target: PBXNativeTarget, file: PBXFileElement) -> Bool { - if target.buildPhases.contains(where: { phase in - phase.files?.contains(where: { $0.file === file }) == true - }) { +extension ProjectLoader { + private func targetOwnsFile(target: PBXNativeTarget, file: PBXFileElement) -> Bool { + if + target.buildPhases.contains(where: { phase in + phase.files?.contains(where: { $0.file === file }) == true + }) + { return true } @@ -207,27 +202,27 @@ private extension ProjectLoader { } } -private extension XCRemoteSwiftPackageReference.VersionRequirement { - var stringValue: String { +extension XCRemoteSwiftPackageReference.VersionRequirement { + fileprivate var requirementValue: XCode.RemotePackage.Requirement { switch self { case .upToNextMajorVersion(let version): - return "upToNextMajorVersion(\(version))" + return .upToNextMajorVersion(version) case .upToNextMinorVersion(let version): - return "upToNextMinorVersion(\(version))" + return .upToNextMinorVersion(version) case .range(let from, let to): - return "range(\(from)...\(to))" + return .range(from: from, to: to) case .exact(let version): - return "exact(\(version))" + return .exact(version) case .branch(let branch): - return "branch(\(branch))" + return .branch(branch) case .revision(let revision): - return "revision(\(revision))" + return .revision(revision) } } } -private extension String { - var swiftPackageProductNames: [String] { +extension String { + fileprivate var swiftPackageProductNames: [String] { let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(startIndex..., in: self) diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index 22f71e0..2a875af 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -2,15 +2,19 @@ import Foundation import PathKit import XcodeProj +// MARK: - TargetLoader + struct TargetLoader { let native: PBXNativeTarget unowned let project: ProjectLoader + let preferConfig: String? let configList: ConfigListLoader let mergedConfig: [String: XCode.BuildSettings] init(native: PBXNativeTarget, project: ProjectLoader, defaultConfigList: ConfigListLoader?) { self.native = native self.project = project + preferConfig = project.preferConfig configList = ConfigListLoader(native: native.buildConfigurationList) mergedConfig = configList.merge(defaultConfigList) } @@ -25,28 +29,24 @@ struct TargetLoader { fileModels(from: sourceBuildFiles, buildPhase: .sources) + synchronizedFiles.filter { file in file.category == .source - }.map(\.file) - ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + }.map(\.file)) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } let headerFiles = unique( fileModels(from: headerBuildFiles, buildPhase: .headers) + packageHeaders + synchronizedFiles.filter { file in file.category == .header - }.map(\.file) - ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + }.map(\.file)) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } let resourceFiles = unique( fileModels(from: resourceBuildFiles, buildPhase: .resources) + synchronizedFiles.filter { file in file.category == .resource - }.map(\.file) - ) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + }.map(\.file)) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } let frameworkFiles = fileModels(from: frameworkBuildFiles, buildPhase: .frameworks) let copyFiles = fileModels(from: copyBuildFiles, buildPhase: .copyFiles) let knownPaths = Set( (sourceFiles + headerFiles + resourceFiles + frameworkFiles + copyFiles) - .compactMap(\.path) - ) + .compactMap(\.path)) let otherFiles = project.packageFiles(targetName: name) .filter { file in @@ -62,6 +62,7 @@ struct TargetLoader { name: name, productName: native.productName, productType: native.productType?.rawValue, + preferConfig: preferConfig, configs: mergedConfig, metadata: metadata, buildPhases: buildPhases, @@ -71,10 +72,8 @@ struct TargetLoader { resources: resourceFiles, frameworks: frameworkFiles, copyFiles: copyFiles, - others: unique(otherFiles) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } - ), - dependencies: dependencies - ) + others: unique(otherFiles) { "\($0.path ?? "")|\($0.buildPhase ?? "")" }), + dependencies: dependencies) } private var metadata: XCode.TargetMetadata { @@ -88,9 +87,7 @@ struct TargetLoader { codeSign: .init( developmentTeam: settings.metadata.developmentTeam, codeSignStyle: settings.metadata.codeSignStyle, - codeSignIdentity: settings.metadata.codeSignIdentity - ) - ) + codeSignIdentity: settings.metadata.codeSignIdentity)) } private var dependencies: XCode.Dependencies { @@ -108,8 +105,10 @@ struct TargetLoader { return nil } - if let label = wrapped.label(buildPhase: BuildPhase.frameworks.rawValue), - label.hasPrefix("//Prebuilt:") { + if + let label = wrapped.label(buildPhase: BuildPhase.frameworks.rawValue), + label.hasPrefix("//Prebuilt:") + { return label } @@ -127,16 +126,14 @@ struct TargetLoader { XCode.PackageProductDependency( productName: dependency.productName, package: dependency.package?.repositoryURL, - packagePath: project.localPackagePathByProduct[dependency.productName] - ) + packagePath: project.localPackagePathByProduct[dependency.productName]) } return .init( targets: Set(targetDependencies).sorted(), packageProducts: unique(packageProducts) { "\($0.productName)|\($0.package ?? "")" }, frameworks: Set(frameworks.compactMap { $0 }).sorted(), - sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted() - ) + sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted()) } private var selectedConfig: XCode.BuildSettings? { @@ -212,8 +209,7 @@ struct TargetLoader { return SynchronizedFile( path: relative, fullPath: file.string, - compilerFlags: compilerFlags[pathInGroup] ?? compilerFlags[relative] - ) + compilerFlags: compilerFlags[pathInGroup] ?? compilerFlags[relative]) } ?? [] } @@ -251,21 +247,19 @@ struct TargetLoader { return FileLoader(native: file, project: project).file( buildPhase: buildPhase.rawValue, compilerFlags: buildFile.compilerFlags, - attributes: buildFile.attributes ?? [] - ) + attributes: buildFile.attributes ?? []) } } } -private extension XCode.BuildPhase { - init(phase: PBXBuildPhase) { +extension XCode.BuildPhase { + fileprivate init(phase: PBXBuildPhase) { let destination: XCode.CopyFilesDestination? if let copyPhase = phase as? PBXCopyFilesBuildPhase { destination = .init( path: copyPhase.dstPath, subfolder: copyPhase.dstSubfolder?.rawValue, - subfolderSpec: copyPhase.dstSubfolderSpec?.rawValue - ) + subfolderSpec: copyPhase.dstSubfolderSpec?.rawValue) } else { destination = nil } @@ -281,15 +275,13 @@ private extension XCode.BuildPhase { path: buildFile.file?.path, fileType: (buildFile.file as? PBXFileReference)?.lastKnownFileType, compilerFlags: buildFile.compilerFlags, - attributes: buildFile.attributes ?? [] - ) + attributes: buildFile.attributes ?? []) }, inputPaths: (phase as? PBXShellScriptBuildPhase)?.inputPaths ?? [], outputPaths: (phase as? PBXShellScriptBuildPhase)?.outputPaths ?? [], inputFileListPaths: phase.inputFileListPaths ?? [], outputFileListPaths: phase.outputFileListPaths ?? [], shellScript: (phase as? PBXShellScriptBuildPhase)?.shellScript, - destination: destination - ) + destination: destination) } } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift index 2a6c082..569526b 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift @@ -1,11 +1,11 @@ import Foundation -public extension XCode.BuildSettings { - var metadata: Metadata { +extension XCode.BuildSettings { + public var metadata: Metadata { .init(settings: self) } - struct Metadata { + public struct Metadata { fileprivate let settings: XCode.BuildSettings public var bundleID: String? { diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift index 00e3962..5d5585b 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift @@ -2,18 +2,18 @@ import Foundation private let plistPrefix = "INFOPLIST_KEY_" -public extension XCode.BuildSettings { +extension XCode.BuildSettings { // MARK: Info.plist - var plist: Plist { + public var plist: Plist { .init(settings: self) } - var generatedPlist: GeneratedPlist { + public var generatedPlist: GeneratedPlist { .init(settings: self) } - struct Plist { + public struct Plist { fileprivate let settings: XCode.BuildSettings /// "ABCDEF/Info.plist" @@ -45,7 +45,7 @@ public extension XCode.BuildSettings { } } - struct GeneratedPlist { + public struct GeneratedPlist { fileprivate let settings: XCode.BuildSettings /// "YES" @@ -155,6 +155,8 @@ public extension XCode.BuildSettings { } } +// MARK: - PlistDecision + private enum PlistDecision { case string case stringArray diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift index 0f15db5..97c283b 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift @@ -1,13 +1,28 @@ import Foundation -public extension XCode.BuildSettings { - var platform: Platform { +// MARK: - SDK + +public enum SDK: String, Hashable { + case iOS = "iphoneos" + case macOS = "macosx" + case tvOS = "appletvos" + case watchOS = "watchos" + case driverKit = "driverkit" + case auto +} + +extension XCode.BuildSettings { + public var platform: Platform { .init(settings: self) } - struct Platform { + public struct Platform { fileprivate let settings: XCode.BuildSettings + public var sdk: SDK? { + SDK(rawValue: settings["SDKROOT"] ?? "") + } + public var iOS: String? { settings["IPHONEOS_DEPLOYMENT_TARGET"] } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 4bfa1b0..27ab271 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -12,8 +12,10 @@ extension BuildSetting { } } -public extension XCode { - struct BuildSettings: Encodable { +// MARK: - XCode.BuildSettings + +extension XCode { + public struct BuildSettings: Encodable { public let name: String private let setting: [String: String] @@ -25,8 +27,7 @@ public extension XCode { init(_ config: XCBuildConfiguration) { self.init( name: config.name, - setting: config.buildSettings.mapValues(\.value) - ) + setting: config.buildSettings.mapValues(\.value)) } func merged(with defaults: BuildSettings?) -> BuildSettings { @@ -38,8 +39,7 @@ public extension XCode { name: name, setting: setting.merging(defaults.setting) { current, _ in current - } - ) + }) } public subscript(key: String) -> String? { @@ -51,3 +51,12 @@ public extension XCode { } } } + +extension XCode.BuildSettings { + public var swiftVersion: String? { self["SWIFT_VERSION"] } + public var swiftDefine: String? { self["OTHER_SWIFT_FLAGS"] } + public var testTargetName: String? { self["TEST_TARGET_NAME"] } + public var testHost: String? { self["TEST_HOST"] } + public var bundleLoader: String? { self["BUNDLE_LOADER"] } + public var enableModules: Bool { self["CLANG_ENABLE_MODULES"] == "YES" } +} diff --git a/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift b/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift index ae6fd70..80b38eb 100644 --- a/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift +++ b/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift @@ -1,7 +1,7 @@ import Foundation -public extension XCode { - enum DeviceFamily: String { +extension XCode { + public enum DeviceFamily: String { case iphone = "1" case ipad = "2" case appletv = "3" diff --git a/Sources/Xcode2/Model/File/XCode+File.swift b/Sources/Xcode2/Model/File/XCode+File.swift index 0e2894c..a68a1d6 100644 --- a/Sources/Xcode2/Model/File/XCode+File.swift +++ b/Sources/Xcode2/Model/File/XCode+File.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct File: Codable { +extension XCode { + public struct File: Codable { public let name: String? public let path: String? public let fullPath: String? diff --git a/Sources/Xcode2/Model/File/XCode+Files.swift b/Sources/Xcode2/Model/File/XCode+Files.swift index f08eec1..d700684 100644 --- a/Sources/Xcode2/Model/File/XCode+Files.swift +++ b/Sources/Xcode2/Model/File/XCode+Files.swift @@ -1,5 +1,7 @@ -public extension XCode { - struct Files: Codable { +// MARK: - XCode.Files + +extension XCode { + public struct Files: Codable { public let sources: [File] public let headers: [File] public let resources: [File] diff --git a/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift b/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift index a404906..083f8cb 100644 --- a/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift +++ b/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift @@ -1,5 +1,7 @@ -public extension XCode { - struct BuildPhase: Codable { +// MARK: - XCode.BuildPhase + +extension XCode { + public struct BuildPhase: Codable { public let type: String public let name: String? public let files: [BuildPhaseFile] diff --git a/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift b/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift index fef4ce4..0f090ce 100644 --- a/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift +++ b/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct BuildPhaseFile: Codable { +extension XCode { + public struct BuildPhaseFile: Codable { public let name: String? public let path: String? public let fileType: String? diff --git a/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift b/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift index 97affed..06cc849 100644 --- a/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift +++ b/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct CopyFilesDestination: Codable { +extension XCode { + public struct CopyFilesDestination: Codable { public let path: String? public let subfolder: String? public let subfolderSpec: UInt? diff --git a/Sources/Xcode2/Model/Project/XCode+Project.swift b/Sources/Xcode2/Model/Project/XCode+Project.swift index 25def8f..4cca783 100644 --- a/Sources/Xcode2/Model/Project/XCode+Project.swift +++ b/Sources/Xcode2/Model/Project/XCode+Project.swift @@ -1,7 +1,10 @@ +import Foundation import PathKit -public extension XCode { - struct Project: Encodable { +// MARK: - XCode.Project + +extension XCode { + public struct Project: Encodable { public let name: String public let workspacePath: String public let projectPath: String @@ -15,3 +18,58 @@ public extension XCode { } } } + +extension XCode.Project { + public var config: [String: XCode.BuildSettings]? { + configs + } + + public var workspaceRoot: Path { + Path(workspacePath) + } + + public var localPackageRepoByProduct: [String: String] { + var result: [String: String] = [:] + + for package in packages.local { + let packagePath = workspaceRoot + package.relativePath + let manifest = packagePath + "Package.swift" + guard let content = try? String(contentsOfFile: manifest.string) else { continue } + + let repo = "swiftpkg_" + Path(package.relativePath).lastComponent.lowercased().replacingOccurrences( + of: "-", + with: "_") + for product in content.swiftPackageProductNames { + result[product] = repo + } + } + + return result + } + + public var localPackagePathByProduct: [String: String] { + var result: [String: String] = [:] + + for target in targets { + for product in target.dependencies.packageProducts { + if let packagePath = product.packagePath { + result[product.productName] = packagePath + } + } + } + + return result + } +} + +extension String { + fileprivate var swiftPackageProductNames: [String] { + let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(startIndex..., in: self) + return regex.matches(in: self, range: range).compactMap { match in + guard let capture = Range(match.range(at: 1), in: self) else { return nil } + return String(self[capture]) + } + } +} diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift b/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift index c30c438..c3b0ba0 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift +++ b/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct LocalPackage: Codable { +extension XCode { + public struct LocalPackage: Codable { public let name: String? public let relativePath: String } diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift b/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift index b2c2e0c..6088042 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift +++ b/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct PackageProductDependency: Codable { +extension XCode { + public struct PackageProductDependency: Codable { public let productName: String public let package: String? public let packagePath: String? diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift b/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift index 73fd210..fecbac1 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift +++ b/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct Packages: Codable { +extension XCode { + public struct Packages: Codable { public let remote: [RemotePackage] public let local: [LocalPackage] } diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift b/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift index e2d0f0e..1d7df5f 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift +++ b/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift @@ -1,7 +1,16 @@ -public extension XCode { - struct RemotePackage: Codable { +extension XCode { + public struct RemotePackage: Codable { + public enum Requirement: Codable, Equatable { + case upToNextMajorVersion(String) + case upToNextMinorVersion(String) + case range(from: String, to: String) + case exact(String) + case branch(String) + case revision(String) + } + public let name: String? public let repositoryURL: String? - public let requirement: String? + public let version: Requirement? } } diff --git a/Sources/Xcode2/Model/Target/XCode+CodeSign.swift b/Sources/Xcode2/Model/Target/XCode+CodeSign.swift index 38583fc..27b0fce 100644 --- a/Sources/Xcode2/Model/Target/XCode+CodeSign.swift +++ b/Sources/Xcode2/Model/Target/XCode+CodeSign.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct CodeSign: Codable { +extension XCode { + public struct CodeSign: Codable { public let developmentTeam: String? public let codeSignStyle: String? public let codeSignIdentity: String? diff --git a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift index 300c317..0204521 100644 --- a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift +++ b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift @@ -1,5 +1,7 @@ -public extension XCode { - struct Dependencies: Codable { +// MARK: - XCode.Dependencies + +extension XCode { + public struct Dependencies: Codable { public let targets: [String] public let packageProducts: [PackageProductDependency] public let frameworks: [String] diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift index ee70a7e..5387ded 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -1,8 +1,13 @@ -public extension XCode { - struct Target: Encodable { +import Foundation + +// MARK: - XCode.Target + +extension XCode { + public struct Target: Encodable { public let name: String public let productName: String? public let productType: String? + public let preferConfig: String? public let configs: [String: BuildSettings] public let metadata: TargetMetadata public let buildPhases: [BuildPhase] @@ -10,3 +15,139 @@ public extension XCode { public let dependencies: Dependencies } } + +extension Dictionary where Key == String, Value == XCode.BuildSettings { + fileprivate var sortedByKey: [(key: Key, value: Value)] { + sorted { lhs, rhs in + lhs.key < rhs.key + } + } + + func prefer(config: String?, _ keyPath: KeyPath) -> T? { + let firstValue = sortedByKey.first?.value[keyPath: keyPath] + guard let config else { return firstValue } + return self[config]?[keyPath: keyPath] ?? firstValue + } + + func prefer(config: String?, _ keyPath: KeyPath) -> T? { + let firstValue = sortedByKey.first?.value[keyPath: keyPath] + guard let config else { return firstValue } + return self[config]?[keyPath: keyPath] ?? firstValue + } +} + +extension XCode.Target { + public func prefer(_ keyPath: KeyPath) -> T? { + configs.prefer(config: preferConfig, keyPath) + } + + public func prefer(_ keyPath: KeyPath) -> T? { + configs.prefer(config: preferConfig, keyPath) + } + + public var isTest: Bool { + switch productType { + case "com.apple.product-type.bundle.unit-test", + "com.apple.product-type.bundle.ui-testing": + return true + default: + return false + } + } + + public var headers: [String] { + filePaths(files.headers) + } + + public var hpps: [String] { + headers.filter { $0.hasSuffix(".hpp") || $0.hasSuffix(".hh") || $0.hasSuffix(".hxx") } + } + + public var srcs: [String] { + filePaths(files.sources) + } + + public var srcs_c: [String] { + srcs.filter { $0.hasSuffix(".c") } + } + + public var srcs_objc: [String] { + srcs.filter { $0.hasSuffix(".m") } + } + + public var srcs_cpp: [String] { + srcs.filter { [".cc", ".cp", ".cpp", ".cxx"].contains(where: $0.hasSuffix) } + } + + public var srcs_objcpp: [String] { + srcs.filter { $0.hasSuffix(".mm") } + } + + public var srcs_swift: [String] { + srcs.filter { $0.hasSuffix(".swift") } + } + + public var srcs_metal: [String] { + srcs.filter { $0.hasSuffix(".metal") } + } + + public var resources: [String] { + filePaths(files.resources) + } + + public var xibs: [String] { + resources.filter { $0.hasSuffix(".xib") } + } + + public var storyboards: [String] { + resources.filter { $0.hasSuffix(".storyboard") } + } + + public var assets: [String] { + resources.filter { $0.hasSuffix(".xcassets") } + } + + public var strings: [String] { + resources.filter { $0.hasSuffix(".strings") } + } + + public var stringsdict: [String] { + resources.filter { $0.hasSuffix(".stringsdict") } + } + + public var allStrings: [String] { + strings + stringsdict + } + + public var importFrameworks: [String] { + files.frameworks.compactMap(\.path) + } + + public var frameworksSDK: [String] { + dependencies.sdkFrameworks + } + + public var selectedSettings: XCode.BuildSettings { + if let preferConfig, let settings = configs[preferConfig] { + return settings + } + if let debug = configs["Debug"] { + return debug + } + if let first = configs.keys.sorted().first, let settings = configs[first] { + return settings + } + return .init(name: "", setting: [:]) + } + + private func filePath(_ file: XCode.File) -> String? { + guard let path = file.path?.trimmingCharacters(in: CharacterSet(charactersIn: "/")), !path.isEmpty else { + return nil + } + return "Sources/\(path)" + } + + private func filePaths(_ files: [XCode.File]) -> [String] { + files.compactMap(filePath) + } +} diff --git a/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift b/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift index 4378cd8..5202a38 100644 --- a/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift +++ b/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift @@ -1,5 +1,5 @@ -public extension XCode { - struct TargetMetadata: Codable { +extension XCode { + public struct TargetMetadata: Codable { public let bundleID: String? public let moduleName: String? public let infoPlist: String? diff --git a/Sources/Xcode2/RoadmapTreeBuilder.swift b/Sources/Xcode2/RoadmapTreeBuilder.swift index 90cc44f..d40a3d5 100644 --- a/Sources/Xcode2/RoadmapTreeBuilder.swift +++ b/Sources/Xcode2/RoadmapTreeBuilder.swift @@ -1,8 +1,10 @@ import Foundation import PathKit -public extension XCode { - struct RoadmapTreeBuilder { +// MARK: - XCode.RoadmapTreeBuilder + +extension XCode { + public struct RoadmapTreeBuilder { public let output: Path public init(output: Path) { @@ -11,30 +13,28 @@ public extension XCode { public func build(project: XCode.Project) throws { try output.mkpath() - try write(path: output + "BUILD", contents: rootBuildContents(project: project)) - try write(path: output + "MODULE.bazel", contents: moduleContents(project: project)) - try write(path: output + "Package.swift", contents: packageSwiftContents(project: project)) try linkPackageResolvedIfPresent(project: project) + try materializePrebuiltFiles(project: project) - let prebuilt = output + "Prebuilt" - try prebuilt.mkpath() - try materializePrebuiltFiles(project: project, prebuiltRoot: prebuilt) - try write(path: prebuilt + "BUILD", contents: prebuiltBuildContents(project: project)) + let targetsRoot = output + "Targets" + try targetsRoot.mkpath() for target in project.targets { - try build(target: target, project: project) + try prepare(target: target, project: project, targetsRoot: targetsRoot) } } - private func build(target: XCode.Target, project: XCode.Project) throws { - let targetRoot = output + target.name + private func prepare( + target: XCode.Target, + project: XCode.Project, + targetsRoot: Path) throws + { + let targetRoot = targetsRoot + target.name let sourcesRoot = targetRoot + "Sources" let generatedRoot = targetRoot + "Generated" try sourcesRoot.mkpath() try generatedRoot.mkpath() - try writeGeneratedFiles(target: target, generatedRoot: generatedRoot) - try write(path: targetRoot + "BUILD", contents: buildFileContents(target: target, project: project)) var materializedDirectories = Set() for relativePath in target.pathsForRoadmapTree { @@ -56,272 +56,20 @@ public extension XCode { } } - private func rootBuildContents(project: XCode.Project) -> String { - if project.configs.isEmpty { - return "" - } - - let configSettings = project.configs.keys.sorted().map { config in - """ - config_setting( - name = "\(config)", - values = {"compilation_mode": "\(config.lowercased())"}, - ) - """ - }.joined(separator: "\n\n") - - return configSettings + "\n" - } - - private func prebuiltBuildContents(project: XCode.Project) -> String { - let xcframeworks = project.prebuiltXcframeworks - guard !xcframeworks.isEmpty else { return "" } - - let load = #"load("@build_bazel_rules_apple//apple:apple.bzl", "apple_dynamic_xcframework_import")"# - let rules = xcframeworks.compactMap { file -> String? in - guard let path = file.path, !path.isEmpty else { return nil } - let name = Path(path).lastComponentWithoutExtension - return """ - apple_dynamic_xcframework_import( - name = "\(name)", - xcframework_imports = glob([ - "\(path)/**", - ]), - visibility = ["//visibility:public"], - ) - """ - }.joined(separator: "\n\n") - - return load + "\n\n" + rules + "\n" - } - - private func moduleContents(project: XCode.Project) -> String { - let repos = swiftPackageRepoNames(project: project) - let useRepoItems = (["swift_deps"] + repos.map { #""\#($0)""# }).joined(separator: ",\n ") - - return """ - module(name = "example", version = "0.0.1") - - bazel_dep(name = "bazel_skylib", version = "1.9.0") - bazel_dep(name = "rules_cc", version = "0.2.17") - bazel_dep(name = "rules_apple", version = "4.5.0", repo_name = "build_bazel_rules_apple") - bazel_dep(name = "rules_swift", version = "3.5.0", repo_name = "build_bazel_rules_swift") - bazel_dep(name = "rules_swift_package_manager", version = "1.13.0") - - swift_deps = use_extension( - "@rules_swift_package_manager//:extensions.bzl", - "swift_deps", - ) - swift_deps.from_package( - declare_swift_deps_info = True, - resolved = "//:Package.resolved", - swift = "//:Package.swift", - ) - use_repo( - \(useRepoItems) - ) - """ - } - - private func packageSwiftContents(project: XCode.Project) -> String { - let remoteDeps = project.packages.remote.compactMap(packageDependencyLine(remote:)) - let localDeps = project.roadmapLocalPackages.map { local in - let path = local.packagePath.absolute().string - return #" .package(path: "\#(path)"),"# - } - let deps = (remoteDeps + localDeps).joined(separator: "\n") - - return """ - // swift-tools-version: 5.7 - import PackageDescription - - let package = Package( - name: "RoadmapPackages", - dependencies: [ - \(deps) - ] - ) - """ - } - - private func buildFileContents(target: XCode.Target, project: XCode.Project) -> String { - var sections: [String] = [] - var loads: [String: Set] = [:] - - if target.hasSwiftSources { - loads["@build_bazel_rules_swift//swift:swift.bzl", default: []].insert("swift_library") - sections.append(swiftLibraryContents(target: target, project: project)) - } else if target.hasObjcSources || target.hasHeaders { - loads["@rules_cc//cc:defs.bzl", default: []].insert("objc_library") - sections.append(objcLibraryContents(target: target, project: project)) - } - - switch target.roadmapKind { - case .application: - loads["@build_bazel_rules_apple//apple:ios.bzl", default: []].insert("ios_application") - sections.append(iosApplicationContents(target: target, project: project)) - case .framework: - loads["@build_bazel_rules_apple//apple:ios.bzl", default: []].insert("ios_framework") - sections.append(iosFrameworkContents(target: target, project: project)) - case .staticLibrary: - sections.append(staticLibraryAliasContents(target: target)) - case .other: - if sections.isEmpty { - sections.append("# Unsupported target type: \(target.productType ?? "unknown")") - } - } - - let loadLines = loads.keys.sorted().map { label in - let rules = loads[label, default: []].sorted().map { #""\#($0)""# }.joined(separator: ", ") - return #"load("\#(label)", \#(rules))"# - } - - return (loadLines + sections).joined(separator: "\n\n") + "\n" - } - - private func swiftLibraryContents(target: XCode.Target, project: XCode.Project) -> String { - let deps = quotedList( - target.targetLibraryDeps(project: project) + - target.swiftPackageProductLabels(project: project) + - target.prebuiltDependencyLabels - ) - return """ - swift_library( - name = "\(target.name)_library", - module_name = "\(target.moduleNameForRoadmap)", - srcs = glob(["Sources/**/*.swift"], allow_empty = True), - deps = \(deps), - visibility = ["//visibility:public"], - ) - """ - } - - private func objcLibraryContents(target: XCode.Target, project: XCode.Project) -> String { - let hdrs = #"glob(["Sources/**/*.h", "Sources/**/*.hpp"], allow_empty = True)"# - let srcs = #"glob(["Sources/**/*.m", "Sources/**/*.mm", "Sources/**/*.c", "Sources/**/*.cc", "Sources/**/*.cpp"], allow_empty = True)"# - let deps = quotedList( - target.targetLibraryDeps(project: project) + - target.swiftPackageProductLabels(project: project) + - target.prebuiltDependencyLabels - ) - - return """ - objc_library( - name = "\(target.name)_objc", - module_name = "\(target.moduleNameForRoadmap)", - srcs = \(srcs), - hdrs = \(hdrs), - includes = ["."], - deps = \(deps), - visibility = ["//visibility:private"], - ) - - alias( - name = "\(target.name)_library", - actual = ":\(target.name)_objc", - visibility = ["//visibility:public"], - ) - """ - } - - private func iosApplicationContents(target: XCode.Target, project: XCode.Project) -> String { - let deps = quotedList([":\(target.name)_library"] + target.prebuiltDependencyLabels) - let sdkFrameworks = quotedList(target.dependencies.sdkFrameworks) - let resources = #"glob(["Sources/**"], exclude = ["Sources/**/*.swift", "Sources/**/*.h", "Sources/**/*.hpp", "Sources/**/*.m", "Sources/**/*.mm", "Sources/**/*.c", "Sources/**/*.cc", "Sources/**/*.cpp"], allow_empty = True)"# - - var lines: [String] = [ - "ios_application(", - #" name = "\#(target.name)","#, - #" bundle_id = "\#(target.metadata.bundleID ?? "com.example.\(target.name)")","#, - ] - - if let minimumOS = target.metadata.deploymentTargets["iOS"] { - lines.append(#" minimum_os_version = "\#(minimumOS)","#) - } - if let families = target.appleFamiliesLiteral { - lines.append(" families = \(families),") - } - - lines.append(" deps = \(deps),") - lines.append(#" infoplists = ["Generated/Info.plist"],"#) - if sdkFrameworks != "[]" { - lines.append(" sdk_frameworks = \(sdkFrameworks),") - } - lines.append(" resources = \(resources),") - lines.append(#" visibility = ["//visibility:public"],"#) - lines.append(")") - return lines.joined(separator: "\n") - } - - private func iosFrameworkContents(target: XCode.Target, project: XCode.Project) -> String { - let deps = quotedList([":\(target.name)_library"] + target.prebuiltDependencyLabels) - let resources = #"glob(["Sources/**"], exclude = ["Sources/**/*.swift", "Sources/**/*.h", "Sources/**/*.hpp", "Sources/**/*.m", "Sources/**/*.mm", "Sources/**/*.c", "Sources/**/*.cc", "Sources/**/*.cpp"], allow_empty = True)"# + private func materializePrebuiltFiles(project: XCode.Project) throws { + let prebuiltRoot = output + "Prebuilt" + try prebuiltRoot.mkpath() - var lines: [String] = [ - "ios_framework(", - #" name = "\#(target.name)","#, - ] - - if let bundleID = target.metadata.bundleID { - lines.append(#" bundle_id = "\#(bundleID)","#) - } - if let minimumOS = target.metadata.deploymentTargets["iOS"] { - lines.append(#" minimum_os_version = "\#(minimumOS)","#) - } - if let families = target.appleFamiliesLiteral { - lines.append(" families = \(families),") - } - - lines.append(" deps = \(deps),") - lines.append(#" infoplists = ["Generated/Info.plist"],"#) - lines.append(" resources = \(resources),") - lines.append(#" visibility = ["//visibility:public"],"#) - lines.append(")") - return lines.joined(separator: "\n") - } - - private func staticLibraryAliasContents(target: XCode.Target) -> String { - """ - alias( - name = "\(target.name)", - actual = ":\(target.name)_library", - visibility = ["//visibility:public"], - ) - """ - } - - private func packageDependencyLine(remote: XCode.RemotePackage) -> String? { - guard let url = remote.repositoryURL else { return nil } - - if let requirement = remote.requirement { - if let version = requirement.wrappedValue(prefix: "upToNextMajorVersion(") { - return #" .package(url: "\#(url)", from: "\#(version)"),"# - } - if let version = requirement.wrappedValue(prefix: "upToNextMinorVersion(") { - return #" .package(url: "\#(url)", .upToNextMinor(from: "\#(version)")),"# - } - if let version = requirement.wrappedValue(prefix: "exact(") { - return #" .package(url: "\#(url)", exact: "\#(version)"),"# - } - if let branch = requirement.wrappedValue(prefix: "branch(") { - return #" .package(url: "\#(url)", branch: "\#(branch)"),"# - } - if let revision = requirement.wrappedValue(prefix: "revision(") { - return #" .package(url: "\#(url)", revision: "\#(revision)"),"# - } - } - - return #" .package(url: "\#(url)", from: "0.0.1"),"# - } + for file in project.prebuiltFiles { + guard let relativePath = file.path, !relativePath.isEmpty else { continue } + let source = Path(project.workspacePath) + relativePath + guard source.exists else { continue } + guard !source.isSelfReferentialSymlink else { continue } - private func swiftPackageRepoNames(project: XCode.Project) -> [String] { - let remote = project.packages.remote.compactMap { package in - package.repositoryURL.map(repositoryName(url:)) - } - let local = project.roadmapLocalPackages.map { package in - repositoryName(path: package.packagePath.lastComponent) + let destination = prebuiltRoot + Path(relativePath).lastComponent + try replaceIfNeeded(at: destination) + try destination.symlink(source) } - return Array(Set(remote + local)).sorted() } private func linkPackageResolvedIfPresent(project: XCode.Project) throws { @@ -333,17 +81,14 @@ public extension XCode { try destination.symlink(source) } - private func write(path: Path, contents: String) throws { - try path.parent().mkpath() - try contents.write(toFile: path.string, atomically: true, encoding: .utf8) - } - private func replaceIfNeeded(at path: Path) throws { guard path.exists || path.isSymlink else { return } try path.delete() } private func materialize(source: Path, destination: Path) throws { + guard !source.isRoadmapIgnoredFile else { return } + if source.isDirectory { if destination.isSymlink { try destination.delete() @@ -362,195 +107,11 @@ public extension XCode { try replaceIfNeeded(at: destination) try destination.symlink(source) } - - private func materializePrebuiltFiles(project: XCode.Project, prebuiltRoot: Path) throws { - for file in project.prebuiltXcframeworks { - guard let relativePath = file.path, !relativePath.isEmpty else { continue } - let source = Path(project.workspacePath) + relativePath - guard source.exists else { continue } - guard !source.isSelfReferentialSymlink else { continue } - - let destination = prebuiltRoot + relativePath - try materialize(source: source, destination: destination) - } - } - - private func repositoryName(url: String) -> String { - repositoryName(module: Path(url).lastComponentWithoutExtension) - } - - private func repositoryName(path: String) -> String { - repositoryName(module: Path(path).lastComponent) - } - - private func repositoryName(module: String) -> String { - "swiftpkg_" + sanitize(module.lowercased()) - } - - private func sanitize(_ value: String) -> String { - value.replacingOccurrences(of: "-", with: "_") - } - - private func quotedList(_ values: [String]) -> String { - let all = Array(Set(values)).sorted() - return "[" + all.map { #""\#($0)""# }.joined(separator: ", ") + "]" - } - - private func writeGeneratedFiles(target: XCode.Target, generatedRoot: Path) throws { - switch target.roadmapKind { - case .application, .framework: - try write(path: generatedRoot + "Info.plist", contents: generatedInfoPlist(target: target)) - case .staticLibrary, .other: - break - } - } - - private func generatedInfoPlist(target: XCode.Target) -> String { - let settings = target.selectedSettings - let bundleID = target.metadata.bundleID ?? "com.example.\(target.name)" - let bundleName = target.name - let shortVersion = settings.generatedPlist.marketingVersion ?? "1.0" - let bundleVersion = settings.generatedPlist.currentProjectVersion ?? "1" - let packageType: String = switch target.roadmapKind { - case .application: "APPL" - case .framework: "FMWK" - case .staticLibrary, .other: "BNDL" - } - let extraEntries = settings.generatedPlist.entries - .map { " \($0.replacingOccurrences(of: "\n", with: "\n "))" } - .joined(separator: "\n") - let extraBlock = extraEntries.isEmpty ? "" : "\n\(extraEntries)" - return """ - - - - - CFBundleIdentifier - \(bundleID) - CFBundleName - \(bundleName) - CFBundleExecutable - \(bundleName) - CFBundleShortVersionString - \(shortVersion) - CFBundlePackageType - \(packageType) - CFBundleVersion - \(bundleVersion)\(extraBlock) - - - """ - } } } -private extension XCode.Target { - enum RoadmapKind { - case application - case framework - case staticLibrary - case other - } - - var roadmapKind: RoadmapKind { - switch productType ?? "" { - case "com.apple.product-type.application": - return .application - case "com.apple.product-type.framework": - return .framework - case "com.apple.product-type.library.static": - return .staticLibrary - default: - return .other - } - } - - var hasSwiftSources: Bool { - files.sources.contains { $0.fileType == "sourcecode.swift" || ($0.path?.hasSuffix(".swift") ?? false) } - } - - var hasObjcSources: Bool { - files.sources.contains { - let path = $0.path ?? "" - return path.hasSuffix(".m") || path.hasSuffix(".mm") || path.hasSuffix(".c") || path.hasSuffix(".cc") || path.hasSuffix(".cpp") - } - } - - var hasHeaders: Bool { - !files.headers.isEmpty - } - - var moduleNameForRoadmap: String { - let moduleName = metadata.moduleName ?? name - if moduleName.contains("$(") || moduleName.isEmpty { - return name - } - return moduleName - } - - var appleFamiliesLiteral: String? { - selectedSettings.platform.appleFamiliesLiteral - } - - var selectedSettings: XCode.BuildSettings { - if let debug = configs["Debug"] { - return debug - } - if let first = configs.keys.sorted().first, let value = configs[first] { - return value - } - return .init(name: "", setting: [:]) - } - - func targetLibraryDeps(project: XCode.Project) -> [String] { - dependencies.targets.compactMap { dep in - guard project.targets.contains(where: { $0.name == dep }) else { return nil } - return "//\(dep):\(dep)_library" - } - } - - func targetBundleDeps(project: XCode.Project) -> [String] { - dependencies.targets.compactMap { dep in - guard project.targets.contains(where: { $0.name == dep }) else { return nil } - return "//\(dep):\(dep)" - } - } - - func swiftPackageProductLabels(project: XCode.Project) -> [String] { - let localRepos = project.localPackageRepoByProduct - - return dependencies.packageProducts.compactMap { product in - if let package = product.package, !package.isEmpty { - let repo = "swiftpkg_" + sanitizeRepo(Path(package).lastComponentWithoutExtension.lowercased()) - return "@\(repo)//:\(product.productName)" - } - - if let packagePath = product.packagePath, !packagePath.isEmpty { - let repo = "swiftpkg_" + sanitizeRepo(Path(packagePath).lastComponent.lowercased()) - return "@\(repo)//:\(product.productName)" - } - - if let repo = localRepos[product.productName] { - return "@\(repo)//:\(product.productName)" - } - - return nil - } - } - - private func sanitizeRepo(_ value: String) -> String { - value.replacingOccurrences(of: "-", with: "_") - } - - var prebuiltDependencyLabels: [String] { - files.frameworks.compactMap { file in - guard file.fileType == "wrapper.xcframework", let path = file.path, !path.isEmpty else { return nil } - let name = Path(path).lastComponentWithoutExtension - return "//Prebuilt:\(name)" - } - } - - var pathsForRoadmapTree: [String] { +extension XCode.Target { + fileprivate var pathsForRoadmapTree: [String] { let allFiles = files.sources + files.headers + files.resources + files.others let candidates = allFiles.compactMap(\.roadmapRelativePath).sorted { let lhsDepth = $0.split(separator: "/").count @@ -576,73 +137,10 @@ private extension XCode.Target { } } -private extension XCode.Project { - struct RoadmapLocalPackage { - let packagePath: Path - let repoName: String - let products: [String] - } - - var roadmapLocalPackages: [RoadmapLocalPackage] { - let usedLocalProducts = Set( - targets.flatMap { target -> [String] in - target.dependencies.packageProducts.compactMap { product in - guard product.package == nil else { return nil } - return product.productName - } - } - ) - - let explicit = packages.local.compactMap { package -> RoadmapLocalPackage? in - let packagePath = Path(workspacePath) + package.relativePath - let manifest = packagePath + "Package.swift" - guard let content = try? String(contentsOfFile: manifest.string) else { return nil } - let localPackage = RoadmapLocalPackage( - packagePath: packagePath, - repoName: "swiftpkg_" + package.relativePath.packageRepoBasename, - products: content.swiftPackageProductNames - ) - guard !usedLocalProducts.isDisjoint(with: localPackage.products) else { return nil } - return localPackage - } - - if !explicit.isEmpty { - return explicit - } - - let workspace = Path(workspacePath) - let children = (try? workspace.children()) ?? [] - return children - .filter(\.isDirectory) - .filter { ($0 + "Package.swift").exists } - .compactMap { directory -> RoadmapLocalPackage? in - let manifest = directory + "Package.swift" - guard let content = try? String(contentsOfFile: manifest.string) else { return nil } - let localPackage = RoadmapLocalPackage( - packagePath: directory, - repoName: "swiftpkg_" + directory.lastComponent.lowercased().replacingOccurrences(of: "-", with: "_"), - products: content.swiftPackageProductNames - ) - guard !usedLocalProducts.isDisjoint(with: localPackage.products) else { return nil } - return localPackage - } - } - - var localPackageRepoByProduct: [String: String] { - var result: [String: String] = [:] - - for package in roadmapLocalPackages { - for product in package.products { - result[product] = package.repoName - } - } - - return result - } - - var prebuiltXcframeworks: [XCode.File] { +extension XCode.Project { + fileprivate var prebuiltFiles: [XCode.File] { let all = targets.flatMap { target in - target.files.frameworks.filter { $0.fileType == "wrapper.xcframework" } + target.files.frameworks.filter { $0.label?.hasPrefix("//Prebuilt:") == true } } var seen = Set() @@ -653,8 +151,8 @@ private extension XCode.Project { } } -private extension XCode.File { - var roadmapRelativePath: String? { +extension XCode.File { + fileprivate var roadmapRelativePath: String? { if let path, !path.isEmpty { return path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } @@ -662,31 +160,19 @@ private extension XCode.File { } } -private extension Path { - var isSelfReferentialSymlink: Bool { +extension Path { + fileprivate var isSelfReferentialSymlink: Bool { guard isSymlink else { return false } guard let destination = try? symlinkDestination().absolute() else { return false } return destination == absolute() } -} -private extension String { - var swiftPackageProductNames: [String] { - let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } - let range = NSRange(startIndex..., in: self) - return regex.matches(in: self, range: range).compactMap { match in - guard let capture = Range(match.range(at: 1), in: self) else { return nil } - return String(self[capture]) + fileprivate var isRoadmapIgnoredFile: Bool { + switch lastComponent { + case "BUILD", "BUILD.bazel": + return true + default: + return false } } - - var packageRepoBasename: String { - Path(self).lastComponent.lowercased().replacingOccurrences(of: "-", with: "_") - } - - func wrappedValue(prefix: String) -> String? { - guard hasPrefix(prefix), hasSuffix(")") else { return nil } - return String(dropFirst(prefix.count).dropLast()) - } } diff --git a/Sources/Xcode2/TargetSummaryFormatter.swift b/Sources/Xcode2/TargetSummaryFormatter.swift index beec9fb..7fcd1fc 100644 --- a/Sources/Xcode2/TargetSummaryFormatter.swift +++ b/Sources/Xcode2/TargetSummaryFormatter.swift @@ -1,7 +1,9 @@ import Foundation -public extension XCode { - enum TargetSummaryFormatter { +// MARK: - XCode.TargetSummaryFormatter + +extension XCode { + public enum TargetSummaryFormatter { public static func format(project: XCode.Project, target: XCode.Target) -> String { var lines: [String] = [] @@ -84,14 +86,14 @@ public extension XCode { } } -private extension XCode.File { - var summaryPath: String { +extension XCode.File { + fileprivate var summaryPath: String { path ?? name ?? fullPath ?? label ?? "" } } -private extension XCode.PackageProductDependency { - var summaryText: String { +extension XCode.PackageProductDependency { + fileprivate var summaryText: String { if let package, !package.isEmpty { return "\(package) / \(productName)" } diff --git a/Sources/Xcode2/XCode.swift b/Sources/Xcode2/XCode.swift index 4e76ca6..a6adabd 100644 --- a/Sources/Xcode2/XCode.swift +++ b/Sources/Xcode2/XCode.swift @@ -1 +1 @@ -public enum XCode {} +public enum XCode { } diff --git a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift new file mode 100644 index 0000000..8fb729c --- /dev/null +++ b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift @@ -0,0 +1,99 @@ +import Foundation +import RepoEnumCore +import Testing + +@Test +func parsesOnlyVersionTags() { + #expect(RepoVersionTag(rawTag: "1.2.3")?.normalizedVersion == "1.2.3") + #expect(RepoVersionTag(rawTag: "v4.0.1")?.caseName == "v4_0_1") + #expect(RepoVersionTag(rawTag: "4.0") == nil) + #expect(RepoVersionTag(rawTag: "release-4.0.1") == nil) + #expect(RepoVersionTag(rawTag: "4.0.1-beta.1") == nil) +} + +@Test +func rendersDescendingAndDeduplicatedEnumCases() throws { + let file = RepoEnumFile( + source: .init(name: "XCodeProj", url: "https://github.com/MobileNativeFoundation/rules_xcodeproj"), + tags: [ + try #require(RepoVersionTag(rawTag: "v4.0.1")), + try #require(RepoVersionTag(rawTag: "4.0.0")), + try #require(RepoVersionTag(rawTag: "v4.0.1")), + try #require(RepoVersionTag(rawTag: "3.6.0")), + ]) + + #expect(file.filename == "Repo+XCodeProj.swift") + #expect(file.content.contains(#"case v4_0_1 = "4.0.1""#)) + #expect(file.content.contains(#"case v4_0_0 = "4.0.0""#)) + #expect(file.content.contains(#"case v3_6_0 = "3.6.0""#)) + #expect(file.content.firstRange(of: #"case v4_0_1 = "4.0.1""#)?.lowerBound ?? file.content.startIndex < + file.content.firstRange(of: #"case v4_0_0 = "4.0.0""#)?.lowerBound ?? file.content.endIndex) +} + +@Test +func githubErrorIsReadable() async { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: config) + + await MockURLProtocolStorage.shared.setHandler { request in + let body = #"{"message":"API rate limit exceeded"}"#.data(using: .utf8)! + let response = HTTPURLResponse( + url: try #require(request.url), + statusCode: 403, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, body) + } + + let client = GitHubTagClient(session: session, token: nil) + + await #expect(throws: RepoEnumGeneratorError.self) { + _ = try await client.tags(for: "https://github.com/bazelbuild/rules_apple") + } +} + +// MARK: - MockURLProtocol + +private final class MockURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Task { + do { + let (response, data) = try await MockURLProtocolStorage.shared.handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + } + + override func stopLoading() { } +} + +// MARK: - MockURLProtocolStorage + +private actor MockURLProtocolStorage { + static let shared = MockURLProtocolStorage() + + private var currentHandler: @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) = { _ in + fatalError("Handler not set") + } + + func setHandler(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) { + currentHandler = handler + } + + func handler(_ request: URLRequest) throws -> (HTTPURLResponse, Data) { + try currentHandler(request) + } +} diff --git a/Tests/XCode2Tests/BuildSettingsTests.swift b/Tests/XCode2Tests/BuildSettingsTests.swift index 6910959..076ee1c 100644 --- a/Tests/XCode2Tests/BuildSettingsTests.swift +++ b/Tests/XCode2Tests/BuildSettingsTests.swift @@ -9,8 +9,7 @@ struct BuildSettingsTests { setting: [ "PRODUCT_BUNDLE_IDENTIFIER": "com.example.app", "TARGETED_DEVICE_FAMILY": "1 2", - ] - ) + ]) #expect(settings.metadata.bundleID == "com.example.app") #expect(settings.platform.deviceFamily.map(\.code) == ["iphone", "ipad"]) @@ -28,8 +27,7 @@ struct BuildSettingsTests { "INFOPLIST_KEY_UIMainStoryboardFile": "Main", "CURRENT_PROJECT_VERSION": "42", "MARKETING_VERSION": "2.3", - ] - ) + ]) #expect(settings.generatedPlist.enabled) #expect(settings.plist.infoPlist == "App/Info.plist") @@ -47,16 +45,14 @@ struct BuildSettingsTests { "IPHONEOS_DEPLOYMENT_TARGET": "16.0", "MACOSX_DEPLOYMENT_TARGET": "14.0", "WATCHOS_DEPLOYMENT_TARGET": "10.0", - ] - ) + ]) #expect(settings.platform.iOS == "16.0") #expect(settings.platform.macOS == "14.0") #expect(settings.platform.tvOS == nil) #expect( settings.platform.deploymentTargets == - ["iOS": "16.0", "macOS": "14.0", "watchOS": "10.0"] - ) + ["iOS": "16.0", "macOS": "14.0", "watchOS": "10.0"]) } @Test @@ -65,8 +61,7 @@ struct BuildSettingsTests { name: "Release", setting: [ "TARGETED_DEVICE_FAMILY": "1 2", - ] - ) + ]) #expect(settings.platform.deviceFamily.map(\.code) == ["iphone", "ipad"]) #expect(settings.platform.appleFamiliesLiteral == #"["iphone", "ipad"]"#) @@ -83,8 +78,7 @@ struct BuildSettingsTests { "DEVELOPMENT_TEAM": "TEAM123", "CODE_SIGN_STYLE": "Automatic", "CODE_SIGN_IDENTITY": "Apple Development", - ] - ) + ]) #expect(settings.metadata.bundleID == "com.example.app") #expect(settings.metadata.moduleName == "ExampleModule") @@ -103,8 +97,7 @@ struct BuildSettingsTests { "INFOPLIST_KEY_UILaunchStoryboardName": "LaunchScreen", "INFOPLIST_KEY_UISupportedInterfaceOrientations": "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft", "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": "YES", - ] - ) + ]) let plist = settings.generatedPlist.entries.joined(separator: "\n") diff --git a/Tests/XCode2Tests/EncodingTests.swift b/Tests/XCode2Tests/EncodingTests.swift index 414a722..47a8768 100644 --- a/Tests/XCode2Tests/EncodingTests.swift +++ b/Tests/XCode2Tests/EncodingTests.swift @@ -11,8 +11,7 @@ struct EncodingTests { resources: [], frameworks: [], copyFiles: [], - others: [] - ) + others: []) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift index 35e907e..0e2fd7a 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -15,8 +15,7 @@ struct ProjectLoaderTests { let merged = ProjectLoader.mergeLocalPackages( explicit: explicit, - discovered: discovered - ) + discovered: discovered) #expect(merged.map(\.relativePath) == ["Local1", "Local2", "Local3"]) #expect(merged.first?.name == "Local1") diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 6d00efc..8b9717a 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -1,3 +1,4 @@ +import BazelizeKit import Foundation import PathKit import Testing @@ -5,18 +6,18 @@ import Testing struct RoadmapTreeBuilderTests { @Test - func buildCreatesTargetTreeAndSymlinks() throws { + func buildCreatesTargetTreeAndSymlinks() async throws { let current = Path(#filePath) .parent() .parent() .parent() let projectPath = current + "fixture/iOS/Example.xcodeproj" - let project = try XCode.Project.load(path: projectPath, preferConfig: nil) let output = Path(NSTemporaryDirectory()) + UUID().uuidString defer { try? output.delete() } - try XCode.RoadmapTreeBuilder(output: output).build(project: project) + let kit = try await Kit(projectPath, nil, outputPath: output) + try await kit.run(projectPath) #expect((output + "BUILD").exists) #expect((output + "MODULE.bazel").exists) @@ -24,46 +25,48 @@ struct RoadmapTreeBuilderTests { #expect((output + "Prebuilt").exists) #expect((output + "Prebuilt/BUILD").exists) #expect((output + "Prebuilt/SVProgressHUD.xcframework").exists) - #expect((output + "Example/Sources").exists) - #expect((output + "Example/Generated").exists) - #expect((output + "Example/BUILD").exists) - #expect((output + "Framework1/BUILD").exists) - #expect((output + "Static2/BUILD").exists) + #expect((output + "Targets/Example/Sources").exists) + #expect((output + "Targets/Example/Generated").exists) + #expect((output + "Targets/Example/BUILD").exists) + #expect((output + "Targets/Framework1/BUILD").exists) + #expect((output + "Targets/Static2/BUILD").exists) - let exampleDir = output + "Example/Sources/Example" + let exampleDir = output + "Targets/Example/Sources/Example" #expect(exampleDir.isDirectory) #expect(!exampleDir.isSymlink) + #expect(!(exampleDir + "BUILD").exists) - let exampleApp = output + "Example/Sources/Example/ExampleApp.swift" + let exampleApp = output + "Targets/Example/Sources/Example/ExampleApp.swift" #expect(exampleApp.isSymlink) #expect( try exampleApp.symlinkDestination().absolute().string == - (projectPath.parent() + "Example/ExampleApp.swift").absolute().string - ) + (projectPath.parent() + "Example/ExampleApp.swift").absolute().string) - let previewAsset = output + "Example/Sources/Example/Preview Content/Preview Assets.xcassets/Contents.json" + let previewAsset = output + "Targets/Example/Sources/Example/Preview Content/Preview Assets.xcassets/Contents.json" #expect(previewAsset.isSymlink) #expect( try previewAsset.symlinkDestination().absolute().string == - (projectPath.parent() + "Example/Preview Content/Preview Assets.xcassets/Contents.json").absolute().string - ) + (projectPath.parent() + "Example/Preview Content/Preview Assets.xcassets/Contents.json").absolute().string) - let exampleBuild = try String(contentsOfFile: (output + "Example/BUILD").string) + let exampleBuild = try String(contentsOfFile: (output + "Targets/Example/BUILD").string) #expect(exampleBuild.contains("ios_application(")) #expect(exampleBuild.contains("name = \"Example\"")) #expect(exampleBuild.contains("swift_library(")) + #expect(exampleBuild.contains("name = \"Example_swift\"")) + #expect(exampleBuild.contains("alias(")) #expect(exampleBuild.contains("name = \"Example_library\"")) - #expect(exampleBuild.contains("//Framework1:Framework1")) + #expect(exampleBuild.contains("//Targets/Framework1:Framework1_library")) #expect(exampleBuild.contains("//Prebuilt:SVProgressHUD")) #expect(exampleBuild.contains("@swiftpkg_anycodable//:AnyCodable")) #expect(exampleBuild.contains("@swiftpkg_local1//:LocalLib1")) #expect(exampleBuild.contains("@swiftpkg_local1//:LocalLib2")) + #expect(exampleBuild.contains("plist_fragment(")) - let frameworkBuild = try String(contentsOfFile: (output + "Framework1/BUILD").string) + let frameworkBuild = try String(contentsOfFile: (output + "Targets/Framework1/BUILD").string) #expect(frameworkBuild.contains("ios_framework(")) #expect(frameworkBuild.contains("name = \"Framework1\"")) - let static2Build = try String(contentsOfFile: (output + "Static2/BUILD").string) + let static2Build = try String(contentsOfFile: (output + "Targets/Static2/BUILD").string) #expect(static2Build.contains("objc_library(")) #expect(static2Build.contains("name = \"Static2_objc\"")) diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift index fdbf8b3..dba9d96 100644 --- a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift +++ b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift @@ -8,13 +8,13 @@ struct TargetSummaryFormatterTests { name: "Example", productName: "Example", productType: "com.apple.product-type.application", + preferConfig: "Release", configs: [ "Debug": .init( name: "Debug", setting: [ "SWIFT_VERSION": "5.9", - ] - ), + ]), "Release": .init( name: "Release", setting: [ @@ -23,8 +23,7 @@ struct TargetSummaryFormatterTests { "PRODUCT_BUNDLE_IDENTIFIER": "com.example.Example", "SWIFT_VERSION": "5.9", "TARGETED_DEVICE_FAMILY": "1 2", - ] - ), + ]), ], metadata: .init( bundleID: "com.example.Example", @@ -34,9 +33,7 @@ struct TargetSummaryFormatterTests { codeSign: .init( developmentTeam: nil, codeSignStyle: "Automatic", - codeSignIdentity: nil - ) - ), + codeSignIdentity: nil)), buildPhases: [], files: .init( sources: [ @@ -49,8 +46,7 @@ struct TargetSummaryFormatterTests { sourceTree: "", buildPhase: "sources", compilerFlags: nil, - attributes: [] - ), + attributes: []), ], headers: [], resources: [ @@ -63,8 +59,7 @@ struct TargetSummaryFormatterTests { sourceTree: "", buildPhase: "resources", compilerFlags: nil, - attributes: [] - ), + attributes: []), ], frameworks: [ .init( @@ -76,25 +71,20 @@ struct TargetSummaryFormatterTests { sourceTree: "", buildPhase: "frameworks", compilerFlags: nil, - attributes: [] - ), + attributes: []), ], copyFiles: [], - others: [] - ), + others: []), dependencies: .init( targets: ["Framework1"], packageProducts: [ .init( productName: "LocalLib1", package: nil, - packagePath: "../Local1" - ), + packagePath: "../Local1"), ], frameworks: ["//Prebuilt:SVProgressHUD"], - sdkFrameworks: ["SwiftUI", "UIKit"] - ) - ) + sdkFrameworks: ["SwiftUI", "UIKit"])) let project = XCode.Project( name: "Example", @@ -103,8 +93,7 @@ struct TargetSummaryFormatterTests { preferConfig: "Release", configs: [:], packages: .init(remote: [], local: []), - targets: [target] - ) + targets: [target]) let summary = XCode.TargetSummaryFormatter.format(project: project, target: target) diff --git a/git_release.py b/git_release.py deleted file mode 100644 index 5d2ac68..0000000 --- a/git_release.py +++ /dev/null @@ -1,92 +0,0 @@ -import requests -import sys -import hashlib - -if __name__ == "__main__": - print(sys.argv) - if (len(sys.argv) < 5): - print("please input with user/repo rule_name output_file_path") - exit(1) - - repo = sys.argv[1] - name = sys.argv[2] - path = sys.argv[3] - count = sys.argv[4] - isArchive = sys.argv[5] == 'archive' - - headers = { - 'Accept': 'application/vnd.github+json' - } - # https://docs.github.com/en/rest/releases/releases - url = 'https://api.github.com/repos/{0}/releases?per_page={1}'.format(repo, count) - print(url) - r = requests.get(url, headers = headers) - - if r.status_code != 200: - r.raise_for_status() - exit(1) - - with open(path, 'w') as file: - print( -''' -extension Repo {{ - /// https://github.com/{1} - enum {0}: String {{'''.format(name, repo), file=file, end='') - - for release in r.json(): - tag = release["tag_name"] - - if "dev" in tag or "alpha" in tag or "beta" in tag: - continue - - _tag_name = tag.replace(".", "_").replace("-", "_") - tag_name = 'v{0}'.format(_tag_name) if _tag_name[:1].isdigit() else _tag_name - print( -''' - case {0} = "{1}"'''.format(tag_name, tag), file=file, end='') - - print( -''' - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self {''', file=file, end='') - - for release in r.json(): - tag = release["tag_name"] - - if "dev" in tag or "alpha" in tag or "beta" in tag: - continue - _tag_name = tag.replace(".", "_").replace("-", "_") - tag_name = 'v{0}'.format(_tag_name) if _tag_name[:1].isdigit() else _tag_name - - tarURL = '' - if isArchive: - # v0.11.2 - tarURL = 'http://github.com/cgrindel/rules_spm/archive/{0}.tar.gz'.format(tag) - else: - tarURL = release.get("assets")[0].get("browser_download_url") - - if not tarURL is None: - print("compute sha256: {0}".format(tarURL)) - tar = requests.get(tarURL) - if tar.status_code != 200: - tar.raise_for_status() - exit(1) - print( -''' - case .{0}: return "{1}"'''.format(tag_name, hashlib.sha256(tar.content).hexdigest()), file=file, end='') - - print( -''' - } - } - } -}''', file=file, end='') From 1382a3a293d754e63f59fa348d7498fc4a04382b Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:22:53 +0800 Subject: [PATCH 008/173] Add apple_support module and bump repo pins --- RepoSources.yml | 4 +++- Sources/BazelizeKit/Bazel/Bazel+Module.swift | 6 +++++- Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift | 2 +- Sources/BazelizeKit/Repo/Repo+AppleSupport.swift | 6 ++++++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 Sources/BazelizeKit/Repo/Repo+AppleSupport.swift diff --git a/RepoSources.yml b/RepoSources.yml index 83ed3bd..b3c6ff0 100644 --- a/RepoSources.yml +++ b/RepoSources.yml @@ -13,4 +13,6 @@ - name: BazelSkylib url: https://github.com/bazelbuild/bazel-skylib - name: RulesCC - url: https://github.com/bazelbuild/rules_cc \ No newline at end of file + url: https://github.com/bazelbuild/rules_cc +- name: AppleSupport + url: https://github.com/bazelbuild/apple_support \ No newline at end of file diff --git a/Sources/BazelizeKit/Bazel/Bazel+Module.swift b/Sources/BazelizeKit/Bazel/Bazel+Module.swift index 6ebed05..9dfd1d6 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Module.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Module.swift @@ -14,8 +14,9 @@ extension Bazel { struct Module: BazelFile { let path: Path public let builder = CodeBuilder() - private let skylib: Repo.BazelSkylib = .v1_9_1 + private let skylib: Repo.BazelSkylib = .v1_9_0 private let cc: Repo.RulesCC = .v0_2_18 + private let appleSupport: Repo.AppleSupport = .v2_5_4 init(_ root: Path) { path = root + "MODULE.bazel" @@ -35,6 +36,9 @@ extension Bazel { builder.bazel_dep( name: "bazel_skylib", version: skylib.rawValue) + builder.bazel_dep( + name: "apple_support", + version: appleSupport.rawValue) builder.bazel_dep( name: "rules_cc", version: cc.rawValue) diff --git a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift index 71d5865..d888c2e 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift @@ -12,7 +12,7 @@ import XcodeProj /// https://github.com/MobileNativeFoundation/rules_xcodeproj final class PluginXCodeProj: PluginBuiltin { - let repo: Repo.XCodeProj = .v3_6_0 + let repo: Repo.XCodeProj = .v4_0_1 override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_xcodeproj", diff --git a/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift b/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift new file mode 100644 index 0000000..5df817d --- /dev/null +++ b/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift @@ -0,0 +1,6 @@ +extension Repo { + /// https://github.com/bazelbuild/apple_support + enum AppleSupport: String { + case v2_5_4 = "2.5.4" + } +} From 484b0921346569223efde5590c31e746613de563 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:22:56 +0800 Subject: [PATCH 009/173] Derive SwiftPM repository names from git URLs --- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index e9fcb2d..5f9cd21 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -59,9 +59,7 @@ final class PluginSwiftPM: PluginBuiltin { ) """) - let names = packages.map { - "\(Self.repositoryName(module: $0))".quoted - }.joined(separator: ",") + let names = packages.map(\.quoted).joined(separator: ",") builder.custom(""" use_repo( swift_deps, @@ -72,18 +70,12 @@ final class PluginSwiftPM: PluginBuiltin { private func transformRemote(_ product: PackageProductDependency) -> String? { guard let url = product.package else { return nil } - /// https://github.com/apple/swift-nio.git - let path = Path(url) - - /// swift-nio - let repo = path.lastComponentWithoutExtension.lowercased() - /// NIO let product = product.productName /// @swiftpkg_swift_nio//:NIO return """ - @\(Self.repositoryName(module: repo))//:\(product) + @\(Self.repositoryName(url: url))//:\(product) """.replacingOccurrences(of: "-", with: "_") } @@ -182,7 +174,7 @@ final class PluginSwiftPM: PluginBuiltin { } private static func repositoryName(url: String) -> String { - repositoryName(module: Path(url).lastComponentWithoutExtension) + repositoryName(module: repositoryModuleName(url: url)) } private static func repositoryName(path: String) -> String { @@ -197,6 +189,14 @@ final class PluginSwiftPM: PluginBuiltin { value.replacingOccurrences(of: "-", with: "_") } + private static func repositoryModuleName(url: String) -> String { + let component = Path(url).lastComponent + if component.hasSuffix(".git") { + return String(component.dropLast(4)) + } + return component + } + private static func relativePath(from base: String, to target: String) -> String { let baseURL = URL(fileURLWithPath: base, isDirectory: true).standardized let targetURL = URL(fileURLWithPath: target, isDirectory: true).standardized From cf769bcb7d5d3c0b921f0940a84f6d666f329812 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:23:25 +0800 Subject: [PATCH 010/173] Resolve xcconfig files and nested build setting variables --- .../Loader/XCode+ConfigListLoader.swift | 61 ++++++++++++++++++- .../Xcode2/Loader/XCode+ProjectLoader.swift | 4 +- .../Xcode2/Loader/XCode+TargetLoader.swift | 8 ++- .../Model/Config/XCode+BuildSettings.swift | 44 ++++++++++++- Tests/XCode2Tests/BuildSettingsTests.swift | 12 ++++ Tests/XCode2Tests/ProjectLoaderTests.swift | 18 ++++++ 6 files changed, 141 insertions(+), 6 deletions(-) diff --git a/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift b/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift index 4ff4926..22d377c 100644 --- a/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift @@ -1,13 +1,18 @@ +import Foundation +import PathKit import XcodeProj struct ConfigListLoader: Hashable { let native: XCConfigurationList? + let sourceRoot: Path var configs: [String: XCode.BuildSettings] { (native?.buildConfigurations ?? []).map { config in ( config.name, - .init(config)) + .init( + name: config.name, + setting: resolvedSettings(for: config))) }.toDictionary() } @@ -31,4 +36,58 @@ struct ConfigListLoader: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(native?.uuid) } + + private func resolvedSettings(for config: XCBuildConfiguration) -> [String: String] { + let fileSettings = resolvedXCConfigSettings(for: config.baseConfiguration) + let inlineSettings = config.buildSettings.mapValues(\.value) + return fileSettings.merging(inlineSettings) { _, current in current } + } + + private func resolvedXCConfigSettings( + for file: PBXFileReference?, + visited: inout Set) + -> [String: String] + { + guard let file else { return [:] } + guard let fullPath = try? file.fullPath(sourceRoot: sourceRoot.string) else { return [:] } + guard visited.insert(fullPath).inserted else { return [:] } + return resolvedXCConfigSettings(at: Path(fullPath), visited: &visited) + } + + private func resolvedXCConfigSettings(for file: PBXFileReference?) -> [String: String] { + var visited: Set = [] + return resolvedXCConfigSettings(for: file, visited: &visited) + } + + private func resolvedXCConfigSettings(at path: Path, visited: inout Set) -> [String: String] { + guard let content = try? String(contentsOfFile: path.string) else { return [:] } + + var result: [String: String] = [:] + for rawLine in content.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("//") else { continue } + + if + line.hasPrefix("#include"), + let start = line.firstIndex(of: "\""), + let end = line[line.index(after: start)...].firstIndex(of: "\"") + { + let includePath = String(line[line.index(after: start).. BuildSettings { + .init( + name: name, + setting: setting.merging(overrides) { _, new in + new + }) + } + public subscript(key: String) -> String? { - setting[key] + resolved(setting[key], visited: [key]) } var keys: [String] { @@ -60,3 +68,37 @@ extension XCode.BuildSettings { public var bundleLoader: String? { self["BUNDLE_LOADER"] } public var enableModules: Bool { self["CLANG_ENABLE_MODULES"] == "YES" } } + +extension XCode.BuildSettings { + private func resolved(_ value: String?, visited: Set) -> String? { + guard let value else { return nil } + + let pattern = #"\$\(([A-Za-z0-9_]+)\)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return value } + + let matches = regex.matches( + in: value, + range: NSRange(value.startIndex..., in: value)) + guard !matches.isEmpty else { return value } + + var result = value + for match in matches.reversed() { + guard + match.numberOfRanges == 2, + let wholeRange = Range(match.range(at: 0), in: value), + let keyRange = Range(match.range(at: 1), in: value) + else { + continue + } + + let key = String(value[keyRange]) + guard !visited.contains(key), let replacement = resolved(setting[key], visited: visited.union([key])) else { + continue + } + + result.replaceSubrange(wholeRange, with: replacement) + } + + return result + } +} diff --git a/Tests/XCode2Tests/BuildSettingsTests.swift b/Tests/XCode2Tests/BuildSettingsTests.swift index 076ee1c..93908b1 100644 --- a/Tests/XCode2Tests/BuildSettingsTests.swift +++ b/Tests/XCode2Tests/BuildSettingsTests.swift @@ -88,6 +88,18 @@ struct BuildSettingsTests { #expect(settings.metadata.codeSignIdentity == "Apple Development") } + @Test + func buildSettingsResolveNestedVariables() { + let settings = XCode.BuildSettings( + name: "Release", + setting: [ + "TARGET_NAME": "iina", + "PRODUCT_BUNDLE_IDENTIFIER": "com.colliderli.$(TARGET_NAME)", + ]) + + #expect(settings.metadata.bundleID == "com.colliderli.iina") + } + @Test func buildSettingsPlistEntriesRenderCommonInfoPlistKeys() { let settings = XCode.BuildSettings( diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift index 0e2fd7a..75ca025 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -1,3 +1,4 @@ +import PathKit import Testing @testable import XCode2 @@ -21,4 +22,21 @@ struct ProjectLoaderTests { #expect(merged.first?.name == "Local1") #expect(merged.last?.name == "Local3") } + + + @Test + func resolvesXCConfigSettingsForIINACommandLineTarget() throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/iina/IINA.xcodeproj" + + let project = try XCode.Project.load(path: projectPath, preferConfig: "Release") + let target = try #require(project.targets.first { $0.name == "iina-cli" }) + + #expect(target.prefer(\.platform.sdk) == .macOS) + #expect(target.prefer(\.platform.macOS) == "11") + } + } From 8fea221c84e03040066f679779178450881bd405 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:23:28 +0800 Subject: [PATCH 011/173] Infer synchronized group membership from build exceptions --- .../Xcode2/Loader/XCode+ProjectLoader.swift | 19 +++++++++- .../Xcode2/Loader/XCode+TargetLoader.swift | 35 +++++++++++++++---- Tests/XCode2Tests/ProjectLoaderTests.swift | 14 ++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index 1955f11..f160efc 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -180,6 +180,21 @@ extension ProjectLoader { } extension ProjectLoader { + func explicitSynchronizedGroups(for target: PBXNativeTarget) -> [PBXFileSystemSynchronizedRootGroup] { + target.fileSystemSynchronizedGroups ?? [] + } + + func inferredSynchronizedGroups(for target: PBXNativeTarget) -> [PBXFileSystemSynchronizedRootGroup] { + let explicitGroups = explicitSynchronizedGroups(for: target) + + return native.fileSystemSynchronizedRootGroups.filter { group in + guard !explicitGroups.contains(where: { $0 === group }) else { return false } + return (group.exceptions ?? []) + .compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + .contains { $0.target?.name == target.name } + } + } + private func targetOwnsFile(target: PBXNativeTarget, file: PBXFileElement) -> Bool { if target.buildPhases.contains(where: { phase in @@ -193,7 +208,9 @@ extension ProjectLoader { return false } - return (target.fileSystemSynchronizedGroups ?? []).contains(where: { group in + let synchronizedGroups = explicitSynchronizedGroups(for: target) + inferredSynchronizedGroups(for: target) + + return synchronizedGroups.contains(where: { group in guard let root = try? group.fullPath(sourceRoot: workspacePath.string) else { return false } diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index 38315df..a4974b6 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -186,17 +186,26 @@ struct TargetLoader { } private var synchronizedGroupFiles: [SynchronizedFile] { - (native.fileSystemSynchronizedGroups ?? []).flatMap { group in - synchronizedFiles(in: group) + let explicit = project.explicitSynchronizedGroups(for: native).flatMap { group in + synchronizedFiles(in: group, membershipMode: .excludeListed) } + let inferred = project.inferredSynchronizedGroups(for: native).flatMap { group in + synchronizedFiles(in: group, membershipMode: .includeListed) + } + + return unique(explicit + inferred) { "\($0.path)|\($0.fullPath)|\($0.compilerFlags ?? "")" } } - private func synchronizedFiles(in group: PBXFileSystemSynchronizedRootGroup) -> [SynchronizedFile] { + private func synchronizedFiles( + in group: PBXFileSystemSynchronizedRootGroup, + membershipMode: SynchronizedMembershipMode) + -> [SynchronizedFile] + { guard let relativeRoot = group.path else { return [] } let root = project.workspacePath + relativeRoot guard root.exists else { return [] } - let excluded = synchronizedExcludedPaths(group) + let membershipPaths = synchronizedMembershipPaths(group) let compilerFlags = synchronizedCompilerFlags(group) return (try? root.recursiveChildren())? @@ -206,8 +215,15 @@ struct TargetLoader { guard let relative else { return nil } let pathInGroup = relative.delete(prefix: relativeRoot + "/") ?? "" - guard !excluded.contains(pathInGroup), !excluded.contains(relative) else { - return nil + switch membershipMode { + case .excludeListed: + guard !membershipPaths.contains(pathInGroup), !membershipPaths.contains(relative) else { + return nil + } + case .includeListed: + guard membershipPaths.contains(pathInGroup) || membershipPaths.contains(relative) else { + return nil + } } return SynchronizedFile( @@ -217,7 +233,7 @@ struct TargetLoader { } ?? [] } - private func synchronizedExcludedPaths(_ group: PBXFileSystemSynchronizedRootGroup) -> Set { + private func synchronizedMembershipPaths(_ group: PBXFileSystemSynchronizedRootGroup) -> Set { let buildExceptions = (group.exceptions ?? []).compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }.filter { exception in @@ -245,6 +261,11 @@ struct TargetLoader { } } + private enum SynchronizedMembershipMode { + case excludeListed + case includeListed + } + private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [XCode.File] { buildFiles.compactMap { buildFile in guard let file = buildFile.file else { return nil } diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift index 75ca025..7fe8985 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -23,6 +23,20 @@ struct ProjectLoaderTests { #expect(merged.last?.name == "Local3") } + @Test + func synchronizedExtensionTargetIncludesExpectedSourceFiles() throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/IceCubesApp/IceCubesApp.xcodeproj" + + let project = try XCode.Project.load(path: projectPath, preferConfig: nil) + let target = try #require(project.targets.first { $0.name == "IceCubesShareExtension" }) + + #expect(target.files.sources.contains { $0.path == "IceCubesShareExtension/ShareViewController.swift" }) + #expect(!target.files.sources.isEmpty) + } @Test func resolvesXCConfigSettingsForIINACommandLineTarget() throws { From 48fcd6ba6f2c3b8b91c54ce46a9654bba0223bc8 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:23:47 +0800 Subject: [PATCH 012/173] Split SDK frameworks and dylibs out of prebuilt dependencies --- .../Bazel/Bazel+PrebuiltBUILD.swift | 6 ++- .../Language/Codegen+ObjcLibrary.swift | 5 ++- Sources/Xcode2/Loader/XCode+FileLoader.swift | 39 ++++++++++++++++--- .../Xcode2/Loader/XCode+ProjectLoader.swift | 10 ++++- .../Xcode2/Loader/XCode+TargetLoader.swift | 25 ++++++++++-- .../Model/Target/XCode+Dependencies.swift | 6 +++ .../Xcode2/Model/Target/XCode+Target.swift | 8 ++++ Tests/XCode2Tests/ProjectLoaderTests.swift | 20 ++++++++++ .../TargetSummaryFormatterTests.swift | 4 +- 9 files changed, 111 insertions(+), 12 deletions(-) diff --git a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift index a07184c..ddc0181 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift @@ -16,7 +16,11 @@ extension Bazel { } mutating func setup(_ kit: Kit) { - let imported = kit.project.targets.flatMap(\.files.frameworks) + let imported = kit.project.targets + .flatMap(\.files.frameworks) + .filter { file in + file.label?.hasPrefix("//Prebuilt:") == true + } let frameworks = imported.filter { file in file.fileType == "wrapper.framework" diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index a571c48..05f6faf 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -44,8 +44,11 @@ extension Target { ".", ], module_name: name, + sdk_dylibs: dylibsSDK, + sdk_frameworks: frameworksSDK, testonly: isTest, - visibility: .private)) + visibility: .private, + weak_sdk_frameworks: weakFrameworksSDK)) builder.call( Rules.Builtin.Call.alias( diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index 37a76df..e18446c 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -14,6 +14,7 @@ enum KnownFileType: String { case cppHeader = "sourcecode.cpp.h" case metal = "sourcecode.metal" case staticLibrary = "archive.ar" + case dynamicLibrary = "compiled.mach-o.dylib" case xib = "file.xib" case storyboard = "file.storyboard" case xcassets = "folder.assetcatalog" @@ -34,6 +35,7 @@ enum KnownFileType: String { case "hh", "hpp", "hxx": self = .cppHeader case "metal": self = .metal case "a": self = .staticLibrary + case "dylib": self = .dynamicLibrary case "xib": self = .xib case "storyboard": self = .storyboard case "xcassets": self = .xcassets @@ -85,6 +87,16 @@ struct FileLoader { .replacingOccurrences(of: ".xcframework", with: "") } + var sdkFrameworkName: String? { + guard isSDKFramework else { return nil } + return frameworkName + } + + var sdkDylibName: String? { + guard isSDKDylib, let name else { return nil } + return Path(name).lastComponentWithoutExtension + } + var frameworkIdentity: String? { guard let name else { return nil } @@ -105,7 +117,18 @@ struct FileLoader { var isSDKFramework: Bool { sourceTree == PBXSourceTree.sdkRoot.description || - sourceTree == PBXSourceTree.developerDir.description + sourceTree == PBXSourceTree.developerDir.description || + fullPath?.hasPrefix("/System/Library/Frameworks/") == true || + fullPath?.hasPrefix("/System/Library/PrivateFrameworks/") == true + } + + var isSDKDylib: Bool { + typedFileType == .dynamicLibrary && ( + sourceTree == PBXSourceTree.sdkRoot.description || + sourceTree == PBXSourceTree.developerDir.description || + fullPath?.hasPrefix("/usr/lib/") == true || + fullPath?.hasPrefix("/System/iOSSupport/usr/lib/") == true + ) } private var ref: PBXFileReference? { @@ -126,7 +149,13 @@ struct FileLoader { } func label(buildPhase: String?) -> String? { - if buildPhase == BuildPhase.frameworks.rawValue, canUsePrebuiltLabel { + if + buildPhase == BuildPhase.frameworks.rawValue, + canUsePrebuiltLabel, + typedFileType != .dynamicLibrary, + !isSDKFramework, + !isSDKDylib + { return project.transformToLabel(relativePath, .prebuilt) } return project.transformToLabel( @@ -140,7 +169,7 @@ struct FileLoader { } guard let name else { return false } - return name.hasSuffix(".a") + return name.hasSuffix(".a") || name.hasSuffix(".dylib") } private var typedFileType: KnownFileType? { @@ -212,14 +241,14 @@ extension KnownFileType { return .header case .xib, .storyboard, .xcassets, .strings, .stringsdict, .plist: return .resource - case .staticLibrary, .xcframework, .framework: + case .staticLibrary, .dynamicLibrary, .xcframework, .framework: return .binary } } fileprivate var isBinaryArtifact: Bool { switch self { - case .staticLibrary, .xcframework, .framework: + case .staticLibrary, .dynamicLibrary, .xcframework, .framework: return true default: return false diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index f160efc..36bef9f 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -158,7 +158,15 @@ extension ProjectLoader { { guard let path = relativePath else { return nil } - return "//\(kind.packageName):\(path)" + let targetName: String + switch kind { + case .source: + targetName = path + case .prebuilt: + targetName = Path(path).lastComponentWithoutExtension + } + + return "//\(kind.packageName):\(targetName)" } static func mergeLocalPackages( diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index a4974b6..ff092e1 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -103,7 +103,8 @@ struct TargetLoader { let frameworks = frameworkBuildFiles.compactMap { buildFile -> String? in guard let file = buildFile.file else { return nil } let wrapped = FileLoader(native: file, project: project) - guard !wrapped.isSDKFramework else { return nil } + guard !wrapped.isSDKFramework, !wrapped.isSDKDylib else { return nil } + guard wrapped.fileType != "compiled.mach-o.dylib" else { return nil } if let identity = wrapped.frameworkIdentity, targetDependencyIdentities.contains(identity) { return nil @@ -123,7 +124,23 @@ struct TargetLoader { guard let file = buildFile.file else { return nil } let wrapped = FileLoader(native: file, project: project) guard wrapped.isSDKFramework else { return nil } - return wrapped.frameworkName + guard !(buildFile.attributes ?? []).contains("Weak") else { return nil } + return wrapped.sdkFrameworkName + } + + let weakSDKFrameworks = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.isSDKFramework else { return nil } + guard (buildFile.attributes ?? []).contains("Weak") else { return nil } + return wrapped.sdkFrameworkName + } + + let sdkDylibs = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.fileType == KnownFileType.dynamicLibrary.rawValue else { return nil } + return wrapped.sdkDylibName ?? wrapped.name.flatMap { Path($0).lastComponentWithoutExtension } } let packageProducts = (native.packageProductDependencies ?? []).map { dependency in @@ -137,7 +154,9 @@ struct TargetLoader { targets: Set(targetDependencies).sorted(), packageProducts: unique(packageProducts) { "\($0.productName)|\($0.package ?? "")" }, frameworks: Set(frameworks.compactMap { $0 }).sorted(), - sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted()) + sdkDylibs: Set(sdkDylibs.compactMap { $0 }).sorted(), + sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted(), + weakSDKFrameworks: Set(weakSDKFrameworks.compactMap { $0 }).sorted()) } private var selectedConfig: XCode.BuildSettings? { diff --git a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift index 0204521..bbe44bb 100644 --- a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift +++ b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift @@ -5,7 +5,9 @@ extension XCode { public let targets: [String] public let packageProducts: [PackageProductDependency] public let frameworks: [String] + public let sdkDylibs: [String] public let sdkFrameworks: [String] + public let weakSDKFrameworks: [String] } } @@ -14,7 +16,9 @@ extension XCode.Dependencies { case targets case packageProducts case frameworks + case sdkDylibs case sdkFrameworks + case weakSDKFrameworks } public func encode(to encoder: Encoder) throws { @@ -22,6 +26,8 @@ extension XCode.Dependencies { try container.encodeIfPresent(targets.nonEmpty, forKey: .targets) try container.encodeIfPresent(packageProducts.nonEmpty, forKey: .packageProducts) try container.encodeIfPresent(frameworks.nonEmpty, forKey: .frameworks) + try container.encodeIfPresent(sdkDylibs.nonEmpty, forKey: .sdkDylibs) try container.encodeIfPresent(sdkFrameworks.nonEmpty, forKey: .sdkFrameworks) + try container.encodeIfPresent(weakSDKFrameworks.nonEmpty, forKey: .weakSDKFrameworks) } } diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift index 5387ded..c16608b 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -127,6 +127,14 @@ extension XCode.Target { dependencies.sdkFrameworks } + public var dylibsSDK: [String] { + dependencies.sdkDylibs + } + + public var weakFrameworksSDK: [String] { + dependencies.weakSDKFrameworks + } + public var selectedSettings: XCode.BuildSettings { if let preferConfig, let settings = configs[preferConfig] { return settings diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift index 7fe8985..1d8fe0e 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -53,4 +53,24 @@ struct ProjectLoaderTests { #expect(target.prefer(\.platform.macOS) == "11") } + @Test + func classifiesIINAFrameworkDependencies() throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/iina/IINA.xcodeproj" + + let project = try XCode.Project.load(path: projectPath, preferConfig: "Release") + let target = try #require(project.targets.first { $0.name == "iina" }) + + #expect(target.dependencies.sdkFrameworks.contains("CoreDisplay")) + #expect(target.dependencies.sdkFrameworks.contains("PIP")) + #expect(!target.dependencies.frameworks.contains("CoreDisplay.framework")) + #expect(!target.dependencies.frameworks.contains("PIP.framework")) + #expect(!target.dependencies.frameworks.contains("//Prebuilt:libX11.6")) + #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libX11.6.dylib" }) + #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libXau.6.dylib" }) + #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libXdmcp.6.dylib" }) + } } diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift index dba9d96..ea23bc4 100644 --- a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift +++ b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift @@ -84,7 +84,9 @@ struct TargetSummaryFormatterTests { packagePath: "../Local1"), ], frameworks: ["//Prebuilt:SVProgressHUD"], - sdkFrameworks: ["SwiftUI", "UIKit"])) + sdkDylibs: [], + sdkFrameworks: ["SwiftUI", "UIKit"], + weakSDKFrameworks: [])) let project = XCode.Project( name: "Example", From 1cf82949b00079ffe1121bc5cac2e25f601aa43b Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:24:01 +0800 Subject: [PATCH 013/173] Add cc_import rule binding --- Sources/BazelRules/Rules+Cc.swift | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Sources/BazelRules/Rules+Cc.swift diff --git a/Sources/BazelRules/Rules+Cc.swift b/Sources/BazelRules/Rules+Cc.swift new file mode 100644 index 0000000..4c82e59 --- /dev/null +++ b/Sources/BazelRules/Rules+Cc.swift @@ -0,0 +1,41 @@ +import Foundation +import Starlark + +// MARK: - Rules.Cc + +extension Rules { + public enum Cc: String, LoadableRule { + case cc_import + + public var module: String { + "@rules_cc//cc:defs.bzl" + } + } +} + +// MARK: - Rules.Cc.Call + +extension Rules.Cc { + public enum Call { + public static func cc_import( + name: String, + shared_library: Starlark.Label? = nil, + static_library: Starlark.Label? = nil, + interface_library: Starlark.Label? = nil, + hdrs: Starlark.Value? = nil, + system_provided: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Cc.cc_import.call { + "name" => name + if let shared_library { "shared_library" => shared_library } + if let static_library { "static_library" => static_library } + if let interface_library { "interface_library" => interface_library } + if let hdrs { "hdrs" => hdrs } + if let system_provided { "system_provided" => system_provided } + if let visibility { visibility } + } + } + } +} From 29ce1eb0bde46859d4401f6eb13796dedc67c431 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:24:02 +0800 Subject: [PATCH 014/173] Emit app icons from asset catalog build settings --- Sources/BazelRules/Rules+Apple.swift | 4 ++++ .../Codegen/Codegen+Application.swift | 11 +++++++++++ .../XCode+BuildSettings+AssetCatalog.swift | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index 1af0a08..4217601 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -149,6 +149,7 @@ extension Rules.Apple.IOS { /// Builds an `ios_application` target. public static func ios_application( name: String, + app_icons: Starlark.Value? = nil, bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, @@ -165,6 +166,7 @@ extension Rules.Apple.IOS { { Rules.Apple.IOS.ios_application.call { "name" => name + if let app_icons { "app_icons" => app_icons } if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } @@ -428,6 +430,7 @@ extension Rules.Apple.MacOS { /// Builds a `macos_application` target. public static func macos_application( name: String, + app_icons: Starlark.Value? = nil, bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, @@ -440,6 +443,7 @@ extension Rules.Apple.MacOS { { Rules.Apple.MacOS.macos_application.call { "name" => name + if let app_icons { "app_icons" => app_icons } if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 09d823d..0d67e9f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -66,6 +66,7 @@ extension Target { builder.call( Rules.Apple.IOS.Call.ios_application( name: name, + app_icons: appIcons, bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" @@ -93,6 +94,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_application( name: name, + app_icons: appIcons, bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" @@ -127,4 +129,13 @@ extension Target { }, visibility: .public)) } + + private var appIcons: Starlark.Value? { + guard let iconName = prefer(\.assetCatalog.appIconName) else { return nil } + let iconGlobs = assets.map { asset in + "\(asset)/\(iconName).appiconset/**" + } + guard !iconGlobs.isEmpty else { return nil } + return Starlark.glob(iconGlobs) + } } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift new file mode 100644 index 0000000..7009f3a --- /dev/null +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift @@ -0,0 +1,19 @@ +import Foundation + +extension XCode.BuildSettings { + public var assetCatalog: AssetCatalog { + .init(settings: self) + } + + public struct AssetCatalog { + fileprivate let settings: XCode.BuildSettings + + public var appIconName: String? { + settings["ASSETCATALOG_COMPILER_APPICON_NAME"] + } + + public var accentColorName: String? { + settings["ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME"] + } + } +} From 2976d462f06e60c1e05881f9cbfceb16a1f484bd Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:24:21 +0800 Subject: [PATCH 015/173] Embed app extensions into applications instead of linking them --- Sources/BazelRules/Rules+Apple.swift | 4 ++ .../Codegen/CodeGen+Extension.swift | 47 +++++++++++++++++ .../Codegen/Codegen+Application.swift | 6 ++- .../BazelizeKit/Codegen/Codegen+Target.swift | 2 + .../Language/Codegen+SwiftLibrary.swift | 2 +- Sources/BazelizeKit/XCode2Compat.swift | 51 +++++++++++++++++++ .../Xcode2/Loader/XCode+TargetLoader.swift | 1 + .../Config/XCode+BuildSettings+Metadata.swift | 4 ++ .../Model/Target/XCode+TargetMetadata.swift | 1 + Tests/BazelRulesTests/RulesAppleTests.swift | 7 +++ .../XCode2Tests/RoadmapTreeBuilderTests.swift | 29 ++++++++++- .../TargetSummaryFormatterTests.swift | 1 + 12 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 Sources/BazelizeKit/Codegen/CodeGen+Extension.swift diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index 4217601..da006cd 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -153,6 +153,7 @@ extension Rules.Apple.IOS { bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + extensions: [Starlark.Label]? = nil, families: [String]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, @@ -170,6 +171,7 @@ extension Rules.Apple.IOS { if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let extensions { "extensions" => extensions } if let families { "families" => families } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } @@ -275,6 +277,7 @@ extension Rules.Apple.IOS { bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, families: [String]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, @@ -288,6 +291,7 @@ extension Rules.Apple.IOS { if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } if let families { "families" => families } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift new file mode 100644 index 0000000..2360e6f --- /dev/null +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -0,0 +1,47 @@ +// +// File.swift +// Bazelize +// +// Created by 林煒峻 on 2026/4/30. +// + +import Foundation + +extension Target { + // MARK: Internal + + func generateExtension(_ builder: CodeBuilder, _ kit: Kit) { + switch prefer(\.platform.sdk) { + case .iOS: buildIOS(builder, kit) + default: break + } + } + + private func buildIOS(_ builder: CodeBuilder, _: Kit) { + builder.load(.ios_extension) + // families = ["iphone", "ipad"], + // provisioning_profile = ":ShareExtension.mobileprovision", # 若需要簽名 + builder.call( + Rules.Apple.IOS.Call.ios_extension( + name: name, + bundle_id: prefer(\.metadata.bundleID), + deps: .build { + ":\(name)_library" + frameworks + }, + entitlements: entitlementsLabel, + families: prefer(\.platform.deviceFamily)?.map(\.code), + infoplists: .build { + plist_file + plist_auto + plist_default + }, + minimum_os_version: prefer(\.platform.iOS), + visibility: .public)) + } + + private var entitlementsLabel: Starlark.Label? { + guard let entitlements = metadata.entitlements else { return nil } + return .named("Sources/\(entitlements)") + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 0d67e9f..1004c1f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -61,7 +61,8 @@ extension Target { visibility: .public)) } - private func buildIOS(_ builder: CodeBuilder, _: Kit) { + private func buildIOS(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project builder.load(.ios_application) builder.call( Rules.Apple.IOS.Call.ios_application( @@ -70,8 +71,9 @@ extension Target { bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" - frameworks + linkedFrameworks(project: project) }, + extensions: embeddedExtensions(project: project), families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { plist_file diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index f8e5e5c..2e5ed57 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -26,6 +26,8 @@ extension Target { generateUnitTest(builder, kit) case "com.apple.product-type.bundle.ui-testing": generateUITest(builder, kit) + case "com.apple.product-type.app-extension": + generateExtension(builder, kit) default: Log.codeGenerate.warning(""" Name: \(name, privacy: .public) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index ea163cb..8c3fe86 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -23,7 +23,7 @@ extension Target { srcs_swift }, deps: .build { - frameworksLibrary + linkedFrameworksLibrary(project: project) applicationHost(project: project) plugin builtins diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XCode2Compat.swift index a96ff50..5731082 100644 --- a/Sources/BazelizeKit/XCode2Compat.swift +++ b/Sources/BazelizeKit/XCode2Compat.swift @@ -28,11 +28,30 @@ extension Dictionary where Key == String, Value == BuildSettings { } } +extension Project { + fileprivate func target(named name: String) -> Target? { + targets.first { $0.name == name } + } +} + extension Target { func select(_ keyPath: KeyPath, project _: Project) -> Starlark.Select { configs.select(keyPath) } + fileprivate func isExtensionTarget(_ name: String, in project: Project) -> Bool { + guard let productType = project.target(named: name)?.productType else { return false } + return productType.contains("app-extension") + } + + fileprivate func linkedTargetDependencyNames(project: Project) -> [String] { + dependencies.targets.filter { !isExtensionTarget($0, in: project) } + } + + fileprivate func embeddedExtensionTargetNames(project: Project) -> [String] { + dependencies.targets.filter { isExtensionTarget($0, in: project) } + } + var frameworksLibrary: [Starlark.Label] { let targetLabels = dependencies.targets .sorted() @@ -56,4 +75,36 @@ extension Target { .map(Starlark.Label.named) return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } } + + func linkedFrameworksLibrary(project: Project) -> [Starlark.Label] { + let targetLabels = linkedTargetDependencyNames(project: project) + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)_library") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } + + func linkedFrameworks(project: Project) -> [Starlark.Label] { + let targetLabels = linkedTargetDependencyNames(project: project) + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } + + func embeddedExtensions(project: Project) -> [Starlark.Label] { + embeddedExtensionTargetNames(project: project) + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + } } diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index ff092e1..657e1bb 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -87,6 +87,7 @@ struct TargetLoader { bundleID: settings.metadata.bundleID, moduleName: settings.metadata.moduleName ?? settings.metadata.productName, infoPlist: settings.plist.infoPlist, + entitlements: settings.metadata.codeSignEntitlements, deploymentTargets: settings.platform.deploymentTargets, codeSign: .init( developmentTeam: settings.metadata.developmentTeam, diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift index 569526b..cb0eb86 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift @@ -31,5 +31,9 @@ extension XCode.BuildSettings { public var codeSignIdentity: String? { settings["CODE_SIGN_IDENTITY"] } + + public var codeSignEntitlements: String? { + settings["CODE_SIGN_ENTITLEMENTS"] + } } } diff --git a/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift b/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift index 5202a38..add0ec3 100644 --- a/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift +++ b/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift @@ -3,6 +3,7 @@ extension XCode { public let bundleID: String? public let moduleName: String? public let infoPlist: String? + public let entitlements: String? public let deploymentTargets: [String: String] public let codeSign: CodeSign } diff --git a/Tests/BazelRulesTests/RulesAppleTests.swift b/Tests/BazelRulesTests/RulesAppleTests.swift index ad59d47..f23ae84 100644 --- a/Tests/BazelRulesTests/RulesAppleTests.swift +++ b/Tests/BazelRulesTests/RulesAppleTests.swift @@ -340,6 +340,8 @@ struct RulesAppleTests { name: "ShareExt", bundle_id: "com.example.share", deps: [":ShareExt_library"], + entitlements: "Sources/ShareExt/ShareExt.entitlements", + families: ["iphone", "ipad"], minimum_os_version: "18.0") #expect( @@ -351,6 +353,11 @@ struct RulesAppleTests { deps = [ ":ShareExt_library", ], + entitlements = "Sources/ShareExt/ShareExt.entitlements", + families = [ + "iphone", + "ipad", + ], minimum_os_version = "18.0", ) """) diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 8b9717a..40c27a0 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -81,4 +81,31 @@ struct RoadmapTreeBuilderTests { #expect(module.contains("swift_deps = use_extension")) #expect(module.contains("swiftpkg_local1")) } -} + + @Test + func applicationEmbedsExtensionsInsteadOfLinkingThemAsRegularDeps() async throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/IceCubesApp/IceCubesApp.xcodeproj" + + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? output.delete() } + + let kit = try await Kit(projectPath, nil, outputPath: output) + try await kit.run(projectPath) + + let appBuild = try String(contentsOfFile: (output + "Targets/IceCubesApp/BUILD").string) + #expect(appBuild.contains("ios_application(")) + #expect(appBuild.contains("extensions = [")) + #expect(appBuild.contains("//Targets/IceCubesActionExtension:IceCubesActionExtension")) + #expect(appBuild.contains("//Targets/IceCubesAppWidgetsExtensionExtension:IceCubesAppWidgetsExtensionExtension")) + #expect(appBuild.contains("//Targets/IceCubesNotifications:IceCubesNotifications")) + #expect(appBuild.contains("//Targets/IceCubesShareExtension:IceCubesShareExtension")) + #expect(!appBuild.contains("//Targets/IceCubesActionExtension:IceCubesActionExtension_library")) + #expect(!appBuild.contains("//Targets/IceCubesAppWidgetsExtensionExtension:IceCubesAppWidgetsExtensionExtension_library")) + #expect(!appBuild.contains("//Targets/IceCubesNotifications:IceCubesNotifications_library")) + #expect(!appBuild.contains("//Targets/IceCubesShareExtension:IceCubesShareExtension_library")) + } + diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift index ea23bc4..4faab94 100644 --- a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift +++ b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift @@ -29,6 +29,7 @@ struct TargetSummaryFormatterTests { bundleID: "com.example.Example", moduleName: "Example", infoPlist: "Example/Info.plist", + entitlements: "Example/Example.entitlements", deploymentTargets: ["iOS": "16.0"], codeSign: .init( developmentTeam: nil, From 0c5ee0b50bfaa10cd188d8bb5d86ebff40e5de34 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:24:27 +0800 Subject: [PATCH 016/173] Generate mixed_language_library for mixed ObjC and Swift targets --- Sources/BazelRules/Rules+Swift.swift | 128 ++++++++++++++---- .../Codegen/Language/Codegen+Library.swift | 71 +++++++++- .../Language/Codegen+ObjcLibrary.swift | 18 +-- .../Language/Codegen+SwiftLibrary.swift | 13 +- Tests/BazelRulesTests/RulesSwiftTests.swift | 25 +++- .../XCode2Tests/RoadmapTreeBuilderTests.swift | 63 +++++++++ 6 files changed, 273 insertions(+), 45 deletions(-) diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index 916f432..b7977fc 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -38,7 +38,7 @@ extension Rules { case swift_compiler_plugin /// `universal_swift_compiler_plugin(name, plugin, toolchain_types)`. case universal_swift_compiler_plugin - /// `mixed_language_library(name, module_name, srcs, deps, data, defines, copts)`. + /// `mixed_language_library(name, module_name, clang_srcs, swift_srcs, deps, data)`. case mixed_language_library /// `swift_feature_allowlist(name, package_groups)`. case swift_feature_allowlist @@ -542,53 +542,131 @@ extension Rules.Swift { /// Parameters: /// - `name: String` /// The Bazel target name. - /// - `module_name: String?` - /// Swift module name exposed by the mixed-language target. - /// - `srcs: Starlark.Value?` - /// Swift and Objective-C source files in the target. - /// - `deps: Starlark.Value?` - /// Regular dependencies linked into the library. - /// - `data: Starlark.Value?` - /// Runtime data made available to the target. - /// - `defines: Starlark.Value?` - /// Compilation condition symbols, including `select(...)` expressions. - /// - `copts: [String]?` - /// C or Clang compilation flags. + /// - `additional_objc_compiler_inputs: Starlark.Value?` + /// Additional Objective-C compiler inputs. /// - `always_include_developer_search_paths: Bool?` /// Whether to include developer search paths when building. + /// - `alwayslink: Bool?` + /// Whether the library should always be linked. + /// - `clang_copts: [String]?` + /// C or Clang compilation flags. + /// - `clang_defines: Starlark.Value?` + /// Preprocessor definitions for Clang compilation. /// - `clang_deps: Starlark.Value?` /// Additional Clang-specific dependencies. + /// - `clang_srcs: Starlark.Value?` + /// C-family sources compiled by Clang. + /// - `data: Starlark.Value?` + /// Runtime data made available to the target. + /// - `enable_modules: Bool?` + /// Whether Clang modules are enabled for the target. + /// - `hdrs: Starlark.Value?` + /// Public C-family headers published by this mixed-language target. + /// - `includes: [String]?` + /// Header search paths exported by the target. + /// - `linkopts: [String]?` + /// Linker options passed through to dependents. + /// - `module_map: Starlark.Label?` + /// Explicit Clang module map. + /// - `module_name: String?` + /// Swift module name exposed by the mixed-language target. + /// - `non_arc_srcs: Starlark.Value?` + /// Objective-C sources that should compile without ARC. + /// - `sdk_dylibs: [String]?` + /// SDK dylibs to link, such as `sqlite3` or `libz`. + /// - `sdk_frameworks: [String]?` + /// SDK frameworks to link strongly. /// - `package_name: String?` /// Optional package name used for module/package identity. + /// - `private_deps: Starlark.Value?` + /// Dependencies that are private to the target implementation. + /// - `swift_copts: [String]?` + /// Swift compiler flags. + /// - `swift_defines: Starlark.Value?` + /// Swift compilation condition symbols, including `select(...)` expressions. + /// - `swift_plugins: Starlark.Value?` + /// Swift compiler plugins to apply. + /// - `swift_srcs: Starlark.Value?` + /// Swift sources compiled by `swiftc`. + /// - `swiftc_inputs: Starlark.Value?` + /// Extra inputs that should be available to the Swift compiler. + /// - `textual_hdrs: Starlark.Value?` + /// Textual headers consumed by Clang but not modularized. + /// - `umbrella_header: Starlark.Label?` + /// Umbrella header used for the generated module. + /// - `weak_sdk_frameworks: [String]?` + /// SDK frameworks to weakly link. + /// - `deps: Starlark.Value?` + /// Regular dependencies linked into the library. /// - `visibility: Starlark.Statement.Argument.Visibility?` /// Repo-local convenience for emitting a `visibility` attribute. public static func mixed_language_library( name: String, - module_name: String? = nil, - srcs: Starlark.Value? = nil, - deps: Starlark.Value? = nil, - data: Starlark.Value? = nil, - defines: Starlark.Value? = nil, - copts: [String]? = nil, + additional_objc_compiler_inputs: Starlark.Value? = nil, always_include_developer_search_paths: Bool? = nil, + alwayslink: Bool? = nil, + clang_copts: [String]? = nil, + clang_defines: Starlark.Value? = nil, clang_deps: Starlark.Value? = nil, + clang_srcs: Starlark.Value? = nil, + data: Starlark.Value? = nil, + enable_modules: Bool? = nil, + hdrs: Starlark.Value? = nil, + includes: [String]? = nil, + linkopts: [String]? = nil, + module_map: Starlark.Label? = nil, + module_name: String? = nil, + non_arc_srcs: Starlark.Value? = nil, package_name: String? = nil, + private_deps: Starlark.Value? = nil, + sdk_dylibs: [String]? = nil, + sdk_frameworks: [String]? = nil, + swift_copts: [String]? = nil, + swift_defines: Starlark.Value? = nil, + swift_plugins: Starlark.Value? = nil, + swift_srcs: Starlark.Value? = nil, + swiftc_inputs: Starlark.Value? = nil, + textual_hdrs: Starlark.Value? = nil, + umbrella_header: Starlark.Label? = nil, + weak_sdk_frameworks: [String]? = nil, + deps: Starlark.Value? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Swift.mixed_language_library.call { "name" => name - if let module_name { "module_name" => module_name } - if let srcs { "srcs" => srcs } - if let deps { "deps" => deps } - if let data { "data" => data } - if let defines { "defines" => defines } - if let copts { "copts" => copts } + if let additional_objc_compiler_inputs { + "additional_objc_compiler_inputs" => additional_objc_compiler_inputs + } if let always_include_developer_search_paths { "always_include_developer_search_paths" => always_include_developer_search_paths } + if let alwayslink { "alwayslink" => alwayslink } + if let clang_copts { "clang_copts" => clang_copts } + if let clang_defines { "clang_defines" => clang_defines } if let clang_deps { "clang_deps" => clang_deps } + if let clang_srcs { "clang_srcs" => clang_srcs } + if let data { "data" => data } + if let enable_modules { "enable_modules" => enable_modules } + if let hdrs { "hdrs" => hdrs } + if let includes { "includes" => includes } + if let linkopts { "linkopts" => linkopts } + if let module_map { "module_map" => module_map } + if let module_name { "module_name" => module_name } + if let non_arc_srcs { "non_arc_srcs" => non_arc_srcs } if let package_name { "package_name" => package_name } + if let private_deps { "private_deps" => private_deps } + if let sdk_dylibs { "sdk_dylibs" => sdk_dylibs } + if let sdk_frameworks { "sdk_frameworks" => sdk_frameworks } + if let swift_copts { "swift_copts" => swift_copts } + if let swift_defines { "swift_defines" => swift_defines } + if let swift_plugins { "swift_plugins" => swift_plugins } + if let swift_srcs { "swift_srcs" => swift_srcs } + if let swiftc_inputs { "swiftc_inputs" => swiftc_inputs } + if let textual_hdrs { "textual_hdrs" => textual_hdrs } + if let umbrella_header { "umbrella_header" => umbrella_header } + if let weak_sdk_frameworks { "weak_sdk_frameworks" => weak_sdk_frameworks } + if let deps { "deps" => deps } if let visibility { visibility } } } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 704f3b6..17a1b00 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -7,8 +7,13 @@ import Foundation import Util +import Starlark extension Target { + var codegenModuleName: String { + name.replacingOccurrences(of: "-", with: "_") + } + func generateLibrary(_ builder: CodeBuilder, _ kit: Kit) { let name = name let cFamily = srcs_c + srcs_cpp + srcs_objc + srcs_objcpp @@ -21,10 +26,72 @@ extension Target { case (false, true): generateObjcLibrary(builder, kit) case (false, false): - /// TODO: mix objc & swift - Log.codeGenerate.warning("TODO: mix objc & swift") + generateMixedLanguageLibrary(builder, kit) case (true, true): Log.codeGenerate.warning("Target(\(name, privacy: .public)) can't happen") } } + + private func generateMixedLanguageLibrary(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project + let plugin = kit.plugins.compactMap { + $0[name] + }.flatMap(\.deps) + + let builtins: [String] = kit.builtinPlugins + .compactMap(\.target) + .reduce([]) { origin, next in + let data = next[name] ?? [] + return origin + data + } + + builder.load(.mixed_language_library) + builder.call( + Rules.Swift.Call.mixed_language_library( + name: "\(name)_mixed", + clang_copts: [ + "-fblocks", + "-fobjc-arc", + "-fPIC", + "-fmodule-name=\(codegenModuleName)", + ], + clang_srcs: .build { + srcs_c + srcs_cpp + srcs_objc + srcs_objcpp + }, + data: .build { + if !assets.isEmpty { + ":Assets" + } + xibs + storyboards + }, + hdrs: .build { + headers + hpps + }, + module_name: codegenModuleName, + sdk_dylibs: dylibsSDK, + sdk_frameworks: frameworksSDK, + swift_defines: defines(project: project), + swift_srcs: .build { + srcs_swift + }, + weak_sdk_frameworks: weakFrameworksSDK, + deps: .build { + linkedFrameworksLibrary(project: project) + applicationHost(project: project) + plugin + builtins + }, + visibility: .private)) + + builder.call( + Rules.Builtin.Call.alias( + name: "\(name)_library", + actual: .named("\(name)_mixed"), + visibility: .public)) + } } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 05f6faf..46b1814 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -12,7 +12,7 @@ import Starlark // TODO: https://github.com/XCodeBazelize/Bazelize/issues/7 extension Target { - func generateObjcLibrary(_ builder: CodeBuilder, _: Kit) { + func generateObjcLibrary(_ builder: CodeBuilder, _: Kit, aliasPublic: Bool = true) { builder.load(.objc_library) /// "enable_modules" => select(\.enableModules).starlark builder.call( @@ -36,24 +36,26 @@ extension Target { "-fblocks", "-fobjc-arc", "-fPIC", - "-fmodule-name=\(name)", + "-fmodule-name=\(codegenModuleName)", ], includes: [ /// public header "." /// https://github.com/bazelbuild/bazel/issues/92 ".", ], - module_name: name, + module_name: codegenModuleName, sdk_dylibs: dylibsSDK, sdk_frameworks: frameworksSDK, testonly: isTest, visibility: .private, weak_sdk_frameworks: weakFrameworksSDK)) - builder.call( - Rules.Builtin.Call.alias( - name: "\(name)_library", - actual: .named("\(name)_objc"), - visibility: .public)) + if aliasPublic { + builder.call( + Rules.Builtin.Call.alias( + name: "\(name)_library", + actual: .named("\(name)_objc"), + visibility: .public)) + } } } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 8c3fe86..9d89920 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -1,7 +1,11 @@ extension Target { // MARK: Internal - func generateSwiftLibrary(_ builder: CodeBuilder, _ kit: Kit) { + func generateSwiftLibrary( + _ builder: CodeBuilder, + _ kit: Kit, + extraDeps: [Starlark.Label] = []) + { let project = kit.project let plugin = kit.plugins.compactMap { $0[name] @@ -18,11 +22,12 @@ extension Target { builder.call( Rules.Swift.Call.swift_library( name: "\(name)_swift", - module_name: name, + module_name: codegenModuleName, srcs: .build { srcs_swift }, deps: .build { + extraDeps linkedFrameworksLibrary(project: project) applicationHost(project: project) plugin @@ -48,7 +53,7 @@ extension Target { // MARK: Private - private func defines(project: Project) -> Starlark.Value { + func defines(project: Project) -> Starlark.Value { select(\.swiftDefine, project: project).map { text -> [String] in let flags: [String] = (text ?? "").split(separator: " ").map(String.init) @@ -78,7 +83,7 @@ extension Target { /// TEST_HOST /// $(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example /// build/Debug-iphoneos/Example.app//Example - private func applicationHost(project _: Project) -> String? { + func applicationHost(project _: Project) -> String? { guard let host = prefer(\.testHost) else { return nil } guard let _ = prefer(\.bundleLoader) else { return nil } guard let targetName = host.components(separatedBy: "/").last else { return nil } diff --git a/Tests/BazelRulesTests/RulesSwiftTests.swift b/Tests/BazelRulesTests/RulesSwiftTests.swift index 49137a9..aedf548 100644 --- a/Tests/BazelRulesTests/RulesSwiftTests.swift +++ b/Tests/BazelRulesTests/RulesSwiftTests.swift @@ -210,28 +210,41 @@ struct RulesSwiftTests { func testMixedLanguageLibraryTypedCallWithSelectDefines() { let call = Rules.Swift.Call.mixed_language_library( name: "Core", - srcs: ["A.swift", "B.m"], - defines: .select( + alwayslink: true, + clang_copts: ["-fmodule-name=Core"], + clang_srcs: ["B.m"], + sdk_dylibs: ["libz"], + swift_defines: .select( .various([ .config("Debug"): ["DEBUG"], .default: [], - ]))) + ])), + swift_srcs: ["A.swift"]) #expect( call.text == """ mixed_language_library( name = "Core", - srcs = [ - "A.swift", + alwayslink = True, + clang_copts = [ + "-fmodule-name=Core", + ], + clang_srcs = [ "B.m", ], - defines = select({ + sdk_dylibs = [ + "libz", + ], + swift_defines = select({ "//:Debug": [ "DEBUG", ], "//conditions:default": None }), + swift_srcs = [ + "A.swift", + ], ) """) } diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 40c27a0..e0539b1 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -109,3 +109,66 @@ struct RoadmapTreeBuilderTests { #expect(!appBuild.contains("//Targets/IceCubesShareExtension:IceCubesShareExtension_library")) } + @Test + func iinaTargetsGenerateSanitizedModuleNamesAndMacAppRules() async throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/iina/IINA.xcodeproj" + + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? output.delete() } + + let kit = try await Kit(projectPath, "Release", outputPath: output) + try await kit.run(projectPath) + + let cliBuild = try String(contentsOfFile: (output + "Targets/iina-cli/BUILD").string) + #expect(cliBuild.contains("module_name = \"iina_cli\"")) + #expect(cliBuild.contains("minimum_os_version = \"11\"")) + + let pluginBuild = try String(contentsOfFile: (output + "Targets/iina-plugin/BUILD").string) + #expect(pluginBuild.contains("module_name = \"iina_plugin\"")) + + let appBuild = try String(contentsOfFile: (output + "Targets/iina/BUILD").string) + #expect(appBuild.contains("mixed_language_library(")) + #expect(appBuild.contains("name = \"iina_mixed\"")) + #expect(appBuild.contains("module_name = \"iina\"")) + #expect(appBuild.contains("app_icons = glob([")) + #expect(appBuild.contains("Sources/iina/Assets.xcassets/AppIcon.appiconset/**")) + #expect(appBuild.contains("sdk_frameworks = [")) + #expect(appBuild.contains("\"CoreDisplay\"")) + #expect(appBuild.contains("\"PIP\"")) + #expect(!appBuild.contains("\"CoreDisplay.framework\"")) + #expect(!appBuild.contains("\"PIP.framework\"")) + #expect(appBuild.contains("sdk_dylibs = [")) + #expect(appBuild.contains("\"libX11.6\"")) + #expect(appBuild.contains("\"libXau.6\"")) + #expect(appBuild.contains("\"libXdmcp.6\"")) + #expect(!appBuild.contains("cc_import(")) + #expect(!appBuild.contains("additional_contents = {")) + #expect(appBuild.contains("@swiftpkg_grmustache.swift//:Mustache")) + #expect(appBuild.contains("macos_application(")) + #expect(appBuild.contains("minimum_os_version = \"11\"")) + + let nightlyOutput = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? nightlyOutput.delete() } + + let nightlyKit = try await Kit(projectPath, "Nightly", outputPath: nightlyOutput) + try await nightlyKit.run(projectPath) + + let nightlyBuild = try String(contentsOfFile: (nightlyOutput + "Targets/iina/BUILD").string) + #expect(nightlyBuild.contains("Sources/iina/Assets.xcassets/AppIconNightly.appiconset/**")) + + let prebuiltBuild = try String(contentsOfFile: (output + "Prebuilt/BUILD").string) + #expect(!prebuiltBuild.contains("libX11.6")) + #expect(!prebuiltBuild.contains("libXau.6")) + #expect(!prebuiltBuild.contains("libXdmcp.6")) + #expect(!prebuiltBuild.contains("name = \"PIP\"")) + #expect(!prebuiltBuild.contains("name = \"CoreDisplay\"")) + + let module = try String(contentsOfFile: (output + "MODULE.bazel").string) + #expect(module.contains("swiftpkg_grmustache.swift")) + #expect(!module.contains("swiftpkg_swiftpkg_")) + } +} From 35b876c89728600c9c523b9edddad0e96c264ce7 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:24:32 +0800 Subject: [PATCH 017/173] Include copy-files entries in the roadmap source tree --- Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift | 2 +- Sources/Xcode2/RoadmapTreeBuilder.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index e61c36a..707c280 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -109,7 +109,7 @@ extension Bazel { extension XCode2.XCode.Target { fileprivate var pathsForRoadmapTree: [String] { - let allFiles = files.sources + files.headers + files.resources + files.others + let allFiles = files.sources + files.headers + files.resources + files.copyFiles + files.others let candidates = allFiles.compactMap(\.roadmapRelativePath).sorted { let lhsDepth = $0.split(separator: "/").count let rhsDepth = $1.split(separator: "/").count diff --git a/Sources/Xcode2/RoadmapTreeBuilder.swift b/Sources/Xcode2/RoadmapTreeBuilder.swift index d40a3d5..7a59c0e 100644 --- a/Sources/Xcode2/RoadmapTreeBuilder.swift +++ b/Sources/Xcode2/RoadmapTreeBuilder.swift @@ -112,7 +112,7 @@ extension XCode { extension XCode.Target { fileprivate var pathsForRoadmapTree: [String] { - let allFiles = files.sources + files.headers + files.resources + files.others + let allFiles = files.sources + files.headers + files.resources + files.copyFiles + files.others let candidates = allFiles.compactMap(\.roadmapRelativePath).sorted { let lhsDepth = $0.split(separator: "/").count let rhsDepth = $1.split(separator: "/").count From 6e412f5873658698e70667494875036c7517b0d7 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:24:32 +0800 Subject: [PATCH 018/173] Attach default plist fragment to framework bundles --- Sources/BazelizeKit/Codegen/Codegen+Framework.swift | 2 +- Tests/XCode2Tests/RoadmapTreeBuilderTests.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index 174dfd5..3ee067f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -15,7 +15,7 @@ extension Target { infoplists: .build { plist_file plist_auto - // plist_default + plist_default }, minimum_os_version: prefer(\.platform.iOS), visibility: .public)) diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index e0539b1..1a49bcd 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -65,6 +65,10 @@ struct RoadmapTreeBuilderTests { let frameworkBuild = try String(contentsOfFile: (output + "Targets/Framework1/BUILD").string) #expect(frameworkBuild.contains("ios_framework(")) #expect(frameworkBuild.contains("name = \"Framework1\"")) + #expect(frameworkBuild.contains("plist_fragment(")) + #expect(frameworkBuild.contains("name = \"plist_default\"")) + #expect(frameworkBuild.contains("infoplists = [")) + #expect(frameworkBuild.contains("\":plist_default\"")) let static2Build = try String(contentsOfFile: (output + "Targets/Static2/BUILD").string) #expect(static2Build.contains("objc_library(")) From 184ba3c2230d67e24d23f9c8729b040267df61c0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:24:32 +0800 Subject: [PATCH 019/173] Add roadmap output flow to the iOS fixture --- fixture/iOS/Example/Test.swift | 2 +- fixture/iOS/Makefile | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/fixture/iOS/Example/Test.swift b/fixture/iOS/Example/Test.swift index dd839ca..c3cda1a 100644 --- a/fixture/iOS/Example/Test.swift +++ b/fixture/iOS/Example/Test.swift @@ -8,5 +8,5 @@ import Foundation func test() -> Int { - 0 + 0b1111 } diff --git a/fixture/iOS/Makefile b/fixture/iOS/Makefile index 2360ab4..d6118ab 100644 --- a/fixture/iOS/Makefile +++ b/fixture/iOS/Makefile @@ -1,8 +1,19 @@ + +BAZELIZE = ../../.build/debug/bazelize + .PHONY: bazelize bazelize: @bazelize --project Example.xcodeproj + @bazel mod tidy +.PHONY: bazelize2 +bazelize2: + @$(BAZELIZE) --project Example.xcodeproj --output App + cd App && bazel mod tidy + cd App && bazel run //Targets/Example + + .PHONY: clear clear: @bazelize --project Example.xcodeproj --clear From e66e0209abc23a882cbaddeab5e4dde02320d87e Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:25:06 +0800 Subject: [PATCH 020/173] Ignore generated iOS fixture workspace files --- .gitignore | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3c277e7..38da6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -184,4 +184,13 @@ cache/ .codex-local-skills/ .vendor/ app/ -Generated/ \ No newline at end of file +Generated/ + +# bazelize output inside the iOS fixture +fixture/iOS/BUILD +fixture/iOS/*/BUILD +fixture/iOS/MODULE.bazel +fixture/iOS/MODULE.bazel.lock +fixture/iOS/Package.swift +fixture/iOS/Package.resolved +fixture/iOS/config.bazelrc From ef5aed3db0baf4cf2cc8e27aedd582aa6b40216a Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:25:06 +0800 Subject: [PATCH 021/173] Add iina porting notes --- Notes.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Notes.md diff --git a/Notes.md b/Notes.md new file mode 100644 index 0000000..5248434 --- /dev/null +++ b/Notes.md @@ -0,0 +1,17 @@ + +iina: + +部分 dylib 是 source? +```shell +Targets/iina/Sources/iina/MPVController.swift:152:29: error: cannot find 'MPV_FORMAT_FLAG' in scope + 150 | MPVOption.Equalizer.saturation: MPV_FORMAT_INT64, + 151 | MPVOption.Window.fullscreen: MPV_FORMAT_FLAG, + 152 | MPVOption.Window.ontop: MPV_FORMAT_FLAG, + | `- error: cannot find 'MPV_FORMAT_FLAG' in scope + 153 | MPVOption.Window.windowScale: MPV_FORMAT_DOUBLE, + 154 | MPVProperty.mediaTitle: MPV_FORMAT_STRING, +``` + +plist 有一些 $(xxx) 需要一些取代 +可以在 bazel 或者 swift 層處理 + From 61e74beb035c70ca89328919e3512377b20afd1c Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:41:00 +0800 Subject: [PATCH 022/173] Bump XcodeProj, Yams and swift-argument-parser --- Package.resolved | 11 ++++++----- Package.swift | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Package.resolved b/Package.resolved index ed19fbf..e5043fc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "34ef4a46e898ba832195fd68b8836d5ee7fbfb728d2a337fa848758e685e9d81", "pins" : [ { "identity" : "aexml", @@ -41,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -167,10 +168,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", - "version" : "6.2.1" + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 6739dc0..a1da89d 100644 --- a/Package.swift +++ b/Package.swift @@ -14,14 +14,14 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/tuist/XcodeProj", from: "9.10.1"), + .package(url: "https://github.com/tuist/XcodeProj", from: "9.16.0"), .package(url: "https://github.com/Flight-School/AnyCodable", from: "0.6.7"), - .package(url: "https://github.com/jpsim/Yams", from: "6.2.1"), + .package(url: "https://github.com/jpsim/Yams", from: "6.2.2"), .package(url: "https://github.com/kylef/PathKit", from: "1.0.1"), .package(url: "https://github.com/yume190/SwiftCommand", from: "1.1.3"), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), /// tag: swift-DEVELOPMENT-SNAPSHOT-2023-01-28-a /// support async command From fe5072353349ae94c65c1377b130d0b2b06678b0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:41:03 +0800 Subject: [PATCH 023/173] Replace SwiftCommand with swift-subprocess in PluginLoader --- Package.resolved | 22 +++---- Package.swift | 4 +- Sources/PluginLoader/PluginBuilder.swift | 77 ++++++++++++++---------- 3 files changed, 58 insertions(+), 45 deletions(-) diff --git a/Package.resolved b/Package.resolved index e5043fc..a83191f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -109,6 +109,15 @@ "revision" : "215e9f91823d7e44c379fa17bf1eef189438fc24" } }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess", + "state" : { + "revision" : "b3937ab85dd32f6e9435914599c1519074769c1a", + "version" : "1.0.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -145,22 +154,13 @@ "revision" : "5a993c8848487c934d53ffceef8e1cad0a241dc1" } }, - { - "identity" : "swiftcommand", - "kind" : "remoteSourceControl", - "location" : "https://github.com/yume190/SwiftCommand", - "state" : { - "revision" : "f82e9d3d65493aac2e5f819fb7f69cdaeb306bf5", - "version" : "1.1.3" - } - }, { "identity" : "xcodeproj", "kind" : "remoteSourceControl", "location" : "https://github.com/tuist/XcodeProj", "state" : { - "revision" : "01bb77000bc8c23a09ea2058f4954612f03cb705", - "version" : "9.10.1" + "revision" : "cfc3234fa2a60babbd26712ac0dec0d44734c019", + "version" : "9.16.0" } }, { diff --git a/Package.swift b/Package.swift index a1da89d..d5e6822 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( .package(url: "https://github.com/jpsim/Yams", from: "6.2.2"), .package(url: "https://github.com/kylef/PathKit", from: "1.0.1"), - .package(url: "https://github.com/yume190/SwiftCommand", from: "1.1.3"), + .package(url: "https://github.com/swiftlang/swift-subprocess", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), @@ -145,6 +145,6 @@ let package = Package( "PathKit", "Util", "XCode", - "SwiftCommand", + .product(name: "Subprocess", package: "swift-subprocess"), ]), ]) diff --git a/Sources/PluginLoader/PluginBuilder.swift b/Sources/PluginLoader/PluginBuilder.swift index a1bd62d..e80b5c2 100644 --- a/Sources/PluginLoader/PluginBuilder.swift +++ b/Sources/PluginLoader/PluginBuilder.swift @@ -7,8 +7,8 @@ import Foundation @preconcurrency import PathKit -import SwiftCommand -import SystemPackage +import Subprocess +import System import Util // MARK: - PluginCompiler @@ -19,22 +19,24 @@ import Util enum PluginCompiler { // MARK: Internal - static func build(plugins: [PluginInfo]) throws -> [PluginInfo] { + static func build(plugins: [PluginInfo]) async throws -> [PluginInfo] { try git.mkpath() try build.mkpath() - return plugins.compactMap { info -> PluginInfo? in + var result: [PluginInfo] = [] + for info in plugins { if checkExist(plugin: info) { - return info + result.append(info) + continue } do { - try build(plugin: info) - return info + try await build(plugin: info) + result.append(info) } catch { Log.pluginLoader.warning("Build Plugin(\(info.repo)) Fail: \(error.localizedDescription)") - return nil } } + return result } // MARK: Private @@ -43,10 +45,6 @@ enum PluginCompiler { private static let git = root + "git" private static let build = root + "build" + swift - private static let commandGit = Command.findInPath(withName: "git") - private static let commandSwift = Command.findInPath(withName: "swift") - - private static func checkExist(plugin: PluginInfo) -> Bool { plugin.paths .map { (path: String) -> Path in @@ -70,27 +68,15 @@ enum PluginCompiler { /// git checkout tag /// swift build -c release /// cp .build/release/*.dylib build/XCodeBazelize_Bazelize/tag - private static func build(plugin: PluginInfo) throws { + private static func build(plugin: PluginInfo) async throws { let repo = git + plugin.user_repo if !repo.exists { - _ = try commandGit?.setCWD(FilePath(git.string)) - .addArguments("clone", plugin.url, plugin.user_repo) - .setStdout(.null) - .logging() - .wait() + try await run("git", "clone", plugin.url, plugin.user_repo, cwd: git) } - _ = try commandGit?.setCWD(FilePath(repo.string)) - .addArguments("checkout", plugin.tag) - .setStdout(.null) - .logging() - .wait() + try await run("git", "checkout", plugin.tag, cwd: repo) - _ = try commandSwift?.setCWD(FilePath(repo.string)) - .addArguments("build", "-c", "release") - .setStdout(.null) - .logging() - .wait() + try await run("swift", "build", "-c", "release", cwd: repo) let release = repo + ".build" + "release" @@ -105,12 +91,39 @@ enum PluginCompiler { } } -extension Command { - __consuming func logging() -> Self { +extension PluginCompiler { + fileprivate static func run( + _ executable: String, + _ arguments: String..., + cwd: Path) + async throws + { Log.pluginLoader.info(""" - \(cwd?.string ?? "")> \(executablePath) \(arguments.joined(separator: " ")) + \(cwd.string)> \(executable) \(arguments.joined(separator: " ")) """) - return self + let result = try await Subprocess.run( + .name(executable), + arguments: Arguments(arguments), + workingDirectory: FilePath(cwd.string), + output: .discarded, + error: .currentStandardError) + + guard result.terminationStatus.isSuccess else { + throw CommandError( + command: "\(executable) \(arguments.joined(separator: " "))", + status: result.terminationStatus) + } + } +} + +// MARK: - CommandError + +struct CommandError: Error, CustomStringConvertible { + let command: String + let status: TerminationStatus + + var description: String { + "`\(command)` failed with \(status)" } } From edf304b28fc5c2f3e017b390f6f7fffa09884bfe Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 10:45:29 +0800 Subject: [PATCH 024/173] Document SwiftPM pin constraint and refresh resolved graph --- Package.resolved | 12 ++++++------ Package.swift | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Package.resolved b/Package.resolved index a83191f..1dbf1f4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "34ef4a46e898ba832195fd68b8836d5ee7fbfb728d2a337fa848758e685e9d81", + "originHash" : "b56c62d8ee7081df49e35cad75d735d86b3c168c73793b3e87772068182a960c", "pins" : [ { "identity" : "aexml", @@ -132,17 +132,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } }, { "identity" : "swift-toolchain-sqlite", "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-toolchain-sqlite", + "location" : "https://github.com/swiftlang/swift-toolchain-sqlite.git", "state" : { - "revision" : "b45b80b943e88db3cb8ddea798fa3fa9912375ff", - "version" : "1.0.7" + "revision" : "d9b11cb79071d5ab992ff47dfd2e8d6e19418d97", + "version" : "1.0.13" } }, { diff --git a/Package.swift b/Package.swift index d5e6822..ae9bfc7 100644 --- a/Package.swift +++ b/Package.swift @@ -23,8 +23,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), - /// tag: swift-DEVELOPMENT-SNAPSHOT-2023-01-28-a - /// support async command + /// SwiftPMDataModel for the legacy `XCode` target. + /// 6.3+ requires macOS 14, which would raise this package's platform floor. .package( url: "https://github.com/apple/swift-package-manager", branch: "swift-6.2.4-RELEASE"), From b45e8c4a8eac46f579eef2f82a6196a22d979021 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 11:28:08 +0800 Subject: [PATCH 025/173] Emit a latest constant in generated repo enums --- Sources/RepoEnumCore/RepoEnumCore.swift | 6 +++++- Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/RepoEnumCore/RepoEnumCore.swift b/Sources/RepoEnumCore/RepoEnumCore.swift index 81235f9..23af231 100644 --- a/Sources/RepoEnumCore/RepoEnumCore.swift +++ b/Sources/RepoEnumCore/RepoEnumCore.swift @@ -110,7 +110,11 @@ public struct RepoEnumFile: Equatable { let cases = tags.map { #" case \#($0.caseName) = "\#($0.normalizedVersion)""# } .joined(separator: "\n") - let body = cases.isEmpty ? "" : "\(cases)\n" + let latest = tags.first.map { tag in + " static let latest: \(source.name) = .\(tag.caseName)\n\n" + } ?? "" + + let body = cases.isEmpty ? "" : "\(latest)\(cases)\n" return """ extension Repo { /// \(source.url) diff --git a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift index 8fb729c..bfa5be2 100644 --- a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift +++ b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift @@ -26,6 +26,7 @@ func rendersDescendingAndDeduplicatedEnumCases() throws { #expect(file.content.contains(#"case v4_0_1 = "4.0.1""#)) #expect(file.content.contains(#"case v4_0_0 = "4.0.0""#)) #expect(file.content.contains(#"case v3_6_0 = "3.6.0""#)) + #expect(file.content.contains("static let latest: XCodeProj = .v4_0_1")) #expect(file.content.firstRange(of: #"case v4_0_1 = "4.0.1""#)?.lowerBound ?? file.content.startIndex < file.content.firstRange(of: #"case v4_0_0 = "4.0.0""#)?.lowerBound ?? file.content.endIndex) } From 8de16e14d5707006911720ffaa23273a6809aa5e Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 11:28:08 +0800 Subject: [PATCH 026/173] Regenerate repo enums with latest constants --- Sources/BazelizeKit/Repo/Repo+Apple.swift | 3 + .../BazelizeKit/Repo/Repo+AppleLinker.swift | 2 + .../BazelizeKit/Repo/Repo+AppleSupport.swift | 80 ++++++++++++++++++- Sources/BazelizeKit/Repo/Repo+Bazel.swift | 6 ++ .../BazelizeKit/Repo/Repo+BazelSkylib.swift | 3 + Sources/BazelizeKit/Repo/Repo+RulesCC.swift | 8 ++ Sources/BazelizeKit/Repo/Repo+Swift.swift | 3 + Sources/BazelizeKit/Repo/Repo+SwiftPM.swift | 14 ++++ Sources/BazelizeKit/Repo/Repo+XCodeProj.swift | 3 + 9 files changed, 121 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Repo/Repo+Apple.swift b/Sources/BazelizeKit/Repo/Repo+Apple.swift index 7c90f71..66bfbd4 100644 --- a/Sources/BazelizeKit/Repo/Repo+Apple.swift +++ b/Sources/BazelizeKit/Repo/Repo+Apple.swift @@ -1,6 +1,9 @@ extension Repo { /// https://github.com/bazelbuild/rules_apple enum Apple: String { + static let latest: Apple = .v5_0_0 + + case v5_0_0 = "5.0.0" case v4_5_3 = "4.5.3" case v4_5_2 = "4.5.2" case v4_5_1 = "4.5.1" diff --git a/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift b/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift index 3e1fb2a..687bf04 100644 --- a/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift +++ b/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift @@ -1,6 +1,8 @@ extension Repo { /// https://github.com/keith/rules_apple_linker enum AppleLinker: String { + static let latest: AppleLinker = .v0_7_0 + case v0_7_0 = "0.7.0" case v0_6_3 = "0.6.3" case v0_6_2 = "0.6.2" diff --git a/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift b/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift index 5df817d..0fc67af 100644 --- a/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift +++ b/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift @@ -1,6 +1,84 @@ extension Repo { /// https://github.com/bazelbuild/apple_support enum AppleSupport: String { + static let latest: AppleSupport = .v2_8_2 + + case v2_8_2 = "2.8.2" + case v2_8_1 = "2.8.1" + case v2_8_0 = "2.8.0" + case v2_7_0 = "2.7.0" + case v2_6_1 = "2.6.1" + case v2_6_0 = "2.6.0" case v2_5_4 = "2.5.4" + case v2_5_3 = "2.5.3" + case v2_5_2 = "2.5.2" + case v2_5_1 = "2.5.1" + case v2_5_0 = "2.5.0" + case v2_4_0 = "2.4.0" + case v2_3_0 = "2.3.0" + case v2_2_0 = "2.2.0" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + case v1_24_5 = "1.24.5" + case v1_24_4 = "1.24.4" + case v1_24_3 = "1.24.3" + case v1_24_2 = "1.24.2" + case v1_24_1 = "1.24.1" + case v1_24_0 = "1.24.0" + case v1_23_1 = "1.23.1" + case v1_23_0 = "1.23.0" + case v1_22_1 = "1.22.1" + case v1_22_0 = "1.22.0" + case v1_21_1 = "1.21.1" + case v1_21_0 = "1.21.0" + case v1_20_0 = "1.20.0" + case v1_19_0 = "1.19.0" + case v1_18_1 = "1.18.1" + case v1_18_0 = "1.18.0" + case v1_17_1 = "1.17.1" + case v1_17_0 = "1.17.0" + case v1_16_0 = "1.16.0" + case v1_15_1 = "1.15.1" + case v1_14_0 = "1.14.0" + case v1_13_0 = "1.13.0" + case v1_12_0 = "1.12.0" + case v1_11_1 = "1.11.1" + case v1_11_0 = "1.11.0" + case v1_10_1 = "1.10.1" + case v1_10_0 = "1.10.0" + case v1_9_0 = "1.9.0" + case v1_8_1 = "1.8.1" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_0 = "1.6.0" + case v1_5_0 = "1.5.0" + case v1_4_1 = "1.4.1" + case v1_4_0 = "1.4.0" + case v1_3_2 = "1.3.2" + case v1_3_1 = "1.3.1" + case v1_3_0 = "1.3.0" + case v1_2_0 = "1.2.0" + case v1_1_0 = "1.1.0" + case v1_0_0 = "1.0.0" + case v0_13_0 = "0.13.0" + case v0_12_1 = "0.12.1" + case v0_12_0 = "0.12.0" + case v0_11_0 = "0.11.0" + case v0_10_0 = "0.10.0" + case v0_9_2 = "0.9.2" + case v0_9_1 = "0.9.1" + case v0_9_0 = "0.9.0" + case v0_8_0 = "0.8.0" + case v0_7_2 = "0.7.2" + case v0_7_1 = "0.7.1" + case v0_7_0 = "0.7.0" + case v0_6_0 = "0.6.0" + case v0_5_0 = "0.5.0" + case v0_4_0 = "0.4.0" + case v0_3_0 = "0.3.0" + case v0_2_0 = "0.2.0" + case v0_1_1 = "0.1.1" + case v0_1_0 = "0.1.0" } -} +} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Bazel.swift b/Sources/BazelizeKit/Repo/Repo+Bazel.swift index 48a9f54..299e5e7 100644 --- a/Sources/BazelizeKit/Repo/Repo+Bazel.swift +++ b/Sources/BazelizeKit/Repo/Repo+Bazel.swift @@ -1,10 +1,16 @@ extension Repo { /// https://github.com/bazelbuild/bazel enum Bazel: String { + static let latest: Bazel = .v9_2_0 + + case v9_2_0 = "9.2.0" + case v9_1_1 = "9.1.1" case v9_1_0 = "9.1.0" case v9_0_2 = "9.0.2" case v9_0_1 = "9.0.1" case v9_0_0 = "9.0.0" + case v8_8_0 = "8.8.0" + case v8_7_0 = "8.7.0" case v8_6_0 = "8.6.0" case v8_5_1 = "8.5.1" case v8_5_0 = "8.5.0" diff --git a/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift b/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift index 2a8ad8f..f439ea5 100644 --- a/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift +++ b/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift @@ -1,6 +1,9 @@ extension Repo { /// https://github.com/bazelbuild/bazel-skylib enum BazelSkylib: String { + static let latest: BazelSkylib = .v1_9_2 + + case v1_9_2 = "1.9.2" case v1_9_1 = "1.9.1" case v1_9_0 = "1.9.0" case v1_8_2 = "1.8.2" diff --git a/Sources/BazelizeKit/Repo/Repo+RulesCC.swift b/Sources/BazelizeKit/Repo/Repo+RulesCC.swift index 6779e76..4482103 100644 --- a/Sources/BazelizeKit/Repo/Repo+RulesCC.swift +++ b/Sources/BazelizeKit/Repo/Repo+RulesCC.swift @@ -1,6 +1,14 @@ extension Repo { /// https://github.com/bazelbuild/rules_cc enum RulesCC: String { + static let latest: RulesCC = .v0_2_24 + + case v0_2_24 = "0.2.24" + case v0_2_23 = "0.2.23" + case v0_2_22 = "0.2.22" + case v0_2_21 = "0.2.21" + case v0_2_20 = "0.2.20" + case v0_2_19 = "0.2.19" case v0_2_18 = "0.2.18" case v0_2_17 = "0.2.17" case v0_2_16 = "0.2.16" diff --git a/Sources/BazelizeKit/Repo/Repo+Swift.swift b/Sources/BazelizeKit/Repo/Repo+Swift.swift index ec90bcc..11487cd 100644 --- a/Sources/BazelizeKit/Repo/Repo+Swift.swift +++ b/Sources/BazelizeKit/Repo/Repo+Swift.swift @@ -1,6 +1,9 @@ extension Repo { /// https://github.com/bazelbuild/rules_swift enum Swift: String { + static let latest: Swift = .v4_0_1 + + case v4_0_1 = "4.0.1" case v3_6_1 = "3.6.1" case v3_6_0 = "3.6.0" case v3_5_0 = "3.5.0" diff --git a/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift b/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift index 539d1eb..f6b9027 100644 --- a/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift +++ b/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift @@ -1,6 +1,20 @@ extension Repo { /// https://github.com/cgrindel/rules_swift_package_manager enum SwiftPM: String { + static let latest: SwiftPM = .v1_24_0 + + case v1_24_0 = "1.24.0" + case v1_23_0 = "1.23.0" + case v1_22_0 = "1.22.0" + case v1_21_0 = "1.21.0" + case v1_20_0 = "1.20.0" + case v1_19_0 = "1.19.0" + case v1_18_1 = "1.18.1" + case v1_18_0 = "1.18.0" + case v1_17_1 = "1.17.1" + case v1_17_0 = "1.17.0" + case v1_16_1 = "1.16.1" + case v1_16_0 = "1.16.0" case v1_15_0 = "1.15.0" case v1_14_0 = "1.14.0" case v1_13_0 = "1.13.0" diff --git a/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift b/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift index 0181ad6..9db485e 100644 --- a/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift +++ b/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift @@ -1,6 +1,9 @@ extension Repo { /// https://github.com/MobileNativeFoundation/rules_xcodeproj enum XCodeProj: String { + static let latest: XCodeProj = .v4_1_0 + + case v4_1_0 = "4.1.0" case v4_0_1 = "4.0.1" case v4_0_0 = "4.0.0" case v3_6_0 = "3.6.0" From 3d1064b859f73eab29d5996af68cfc436510597e Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 11:57:52 +0800 Subject: [PATCH 027/173] Pin repo dependencies to generated latest constants --- Sources/BazelizeKit/Bazel/Bazel+Module.swift | 6 +++--- Sources/BazelizeKit/Bazel/Bazel+Version.swift | 2 +- Sources/BazelizeKit/Plugin/Plugin+Apple.swift | 2 +- Sources/BazelizeKit/Plugin/Plugin+Linker.swift | 2 +- Sources/BazelizeKit/Plugin/Plugin+Swift.swift | 2 +- Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift | 2 +- Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/BazelizeKit/Bazel/Bazel+Module.swift b/Sources/BazelizeKit/Bazel/Bazel+Module.swift index 9dfd1d6..8203811 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Module.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Module.swift @@ -14,9 +14,9 @@ extension Bazel { struct Module: BazelFile { let path: Path public let builder = CodeBuilder() - private let skylib: Repo.BazelSkylib = .v1_9_0 - private let cc: Repo.RulesCC = .v0_2_18 - private let appleSupport: Repo.AppleSupport = .v2_5_4 + private let skylib: Repo.BazelSkylib = .latest + private let cc: Repo.RulesCC = .latest + private let appleSupport: Repo.AppleSupport = .latest init(_ root: Path) { path = root + "MODULE.bazel" diff --git a/Sources/BazelizeKit/Bazel/Bazel+Version.swift b/Sources/BazelizeKit/Bazel/Bazel+Version.swift index e0edbc8..12b9a76 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Version.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Version.swift @@ -6,7 +6,7 @@ extension Bazel { struct Version: BazelFile { let path: Path let code = "\(Self.repo.rawValue)" - static private let repo: Repo.Bazel = .v9_1_0 + static private let repo: Repo.Bazel = .latest init(_ root: Path) { path = root + ".bazelversion" diff --git a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift index 2ee88cc..9767b1a 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift @@ -11,7 +11,7 @@ import Foundation /// https://github.com/bazelbuild/rules_apple final class PluginApple: PluginBuiltin { - let repo: Repo.Apple = .v4_5_3 + let repo: Repo.Apple = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( diff --git a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift index 6c3f68b..2c659a5 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift @@ -13,7 +13,7 @@ import Foundation /// https://github.com/keith/rules_apple_linker class PluginLinker: PluginBuiltin { - let repo: Repo.AppleLinker = .v0_7_0 + let repo: Repo.AppleLinker = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( diff --git a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift index e639ef3..b2fbc42 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift @@ -11,7 +11,7 @@ import Foundation /// https://github.com/bazelbuild/rules_swift final class PluginSwift: PluginBuiltin { - let repo: Repo.Swift = .v3_6_1 + let repo: Repo.Swift = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 5f9cd21..1529b45 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -12,7 +12,7 @@ import PathKit /// http://github.com/cgrindel/rules_swift_package_manager final class PluginSwiftPM: PluginBuiltin { - private let repo: Repo.SwiftPM = .v1_15_0 + private let repo: Repo.SwiftPM = .latest let remotes: [RemotePackage] let locals: [LocalPackage] private var packages: [String] = [] diff --git a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift index d888c2e..380b64f 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift @@ -12,7 +12,7 @@ import XcodeProj /// https://github.com/MobileNativeFoundation/rules_xcodeproj final class PluginXCodeProj: PluginBuiltin { - let repo: Repo.XCodeProj = .v4_0_1 + let repo: Repo.XCodeProj = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_xcodeproj", From b00013b92a26c8c9a3c095a5c5a9cceb21b37ca2 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 12:02:48 +0800 Subject: [PATCH 028/173] Generate BazelDep namespace instead of Repo --- .../{Repo/Repo+Apple.swift => BazelDep/BazelDep+Apple.swift} | 0 .../BazelDep+AppleLinker.swift} | 0 .../BazelDep+AppleSupport.swift} | 0 .../{Repo/Repo+Bazel.swift => BazelDep/BazelDep+Bazel.swift} | 0 .../BazelDep+BazelSkylib.swift} | 0 .../Repo+RulesCC.swift => BazelDep/BazelDep+RulesCC.swift} | 0 .../{Repo/Repo+Swift.swift => BazelDep/BazelDep+Swift.swift} | 0 .../Repo+SwiftPM.swift => BazelDep/BazelDep+SwiftPM.swift} | 0 .../BazelDep+XCodeProj.swift} | 0 .../BazelizeKit/{Repo/Repo.swift => BazelDep/BazelDep.swift} | 0 Sources/RepoEnumCore/RepoEnumCore.swift | 4 ++-- Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift | 2 +- 12 files changed, 3 insertions(+), 3 deletions(-) rename Sources/BazelizeKit/{Repo/Repo+Apple.swift => BazelDep/BazelDep+Apple.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+AppleLinker.swift => BazelDep/BazelDep+AppleLinker.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+AppleSupport.swift => BazelDep/BazelDep+AppleSupport.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+Bazel.swift => BazelDep/BazelDep+Bazel.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+BazelSkylib.swift => BazelDep/BazelDep+BazelSkylib.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+RulesCC.swift => BazelDep/BazelDep+RulesCC.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+Swift.swift => BazelDep/BazelDep+Swift.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+SwiftPM.swift => BazelDep/BazelDep+SwiftPM.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo+XCodeProj.swift => BazelDep/BazelDep+XCodeProj.swift} (100%) rename Sources/BazelizeKit/{Repo/Repo.swift => BazelDep/BazelDep.swift} (100%) diff --git a/Sources/BazelizeKit/Repo/Repo+Apple.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+Apple.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift diff --git a/Sources/BazelizeKit/Repo/Repo+AppleLinker.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+AppleLinker.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift diff --git a/Sources/BazelizeKit/Repo/Repo+AppleSupport.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+AppleSupport.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift diff --git a/Sources/BazelizeKit/Repo/Repo+Bazel.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+Bazel.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift diff --git a/Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift b/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+BazelSkylib.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift diff --git a/Sources/BazelizeKit/Repo/Repo+RulesCC.swift b/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+RulesCC.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift diff --git a/Sources/BazelizeKit/Repo/Repo+Swift.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+Swift.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift diff --git a/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift b/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+SwiftPM.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift diff --git a/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift b/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo+XCodeProj.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift diff --git a/Sources/BazelizeKit/Repo/Repo.swift b/Sources/BazelizeKit/BazelDep/BazelDep.swift similarity index 100% rename from Sources/BazelizeKit/Repo/Repo.swift rename to Sources/BazelizeKit/BazelDep/BazelDep.swift diff --git a/Sources/RepoEnumCore/RepoEnumCore.swift b/Sources/RepoEnumCore/RepoEnumCore.swift index 23af231..352132e 100644 --- a/Sources/RepoEnumCore/RepoEnumCore.swift +++ b/Sources/RepoEnumCore/RepoEnumCore.swift @@ -103,7 +103,7 @@ public struct RepoEnumFile: Equatable { } public var filename: String { - "Repo+\(source.name).swift" + "BazelDep+\(source.name).swift" } public var content: String { @@ -116,7 +116,7 @@ public struct RepoEnumFile: Equatable { let body = cases.isEmpty ? "" : "\(latest)\(cases)\n" return """ - extension Repo { + extension BazelDep { /// \(source.url) enum \(source.name): String { \(body) } diff --git a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift index bfa5be2..fa513c5 100644 --- a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift +++ b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift @@ -22,7 +22,7 @@ func rendersDescendingAndDeduplicatedEnumCases() throws { try #require(RepoVersionTag(rawTag: "3.6.0")), ]) - #expect(file.filename == "Repo+XCodeProj.swift") + #expect(file.filename == "BazelDep+XCodeProj.swift") #expect(file.content.contains(#"case v4_0_1 = "4.0.1""#)) #expect(file.content.contains(#"case v4_0_0 = "4.0.0""#)) #expect(file.content.contains(#"case v3_6_0 = "3.6.0""#)) From 14ef4e45b742c758bec48afe33e68a21ccba9edd Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 12:02:48 +0800 Subject: [PATCH 029/173] Rename Repo enums to BazelDep across BazelizeKit --- Makefile | 2 +- Sources/BazelizeKit/Bazel/Bazel+Module.swift | 6 +++--- Sources/BazelizeKit/Bazel/Bazel+Version.swift | 4 ++-- Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift | 2 +- Sources/BazelizeKit/BazelDep/BazelDep.swift | 9 ++++++--- Sources/BazelizeKit/Plugin/Plugin+Apple.swift | 4 ++-- Sources/BazelizeKit/Plugin/Plugin+Linker.swift | 4 ++-- Sources/BazelizeKit/Plugin/Plugin+Swift.swift | 4 ++-- Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift | 4 ++-- Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift | 4 ++-- 18 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Makefile b/Makefile index 70ddeb0..ecae22c 100644 --- a/Makefile +++ b/Makefile @@ -43,4 +43,4 @@ update-repo-enums: .PHONY: replace replace: update-repo-enums - cp Generated/*.swift Sources/BazelizeKit/Repo/ + cp Generated/*.swift Sources/BazelizeKit/BazelDep/ diff --git a/Sources/BazelizeKit/Bazel/Bazel+Module.swift b/Sources/BazelizeKit/Bazel/Bazel+Module.swift index 8203811..4b03813 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Module.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Module.swift @@ -14,9 +14,9 @@ extension Bazel { struct Module: BazelFile { let path: Path public let builder = CodeBuilder() - private let skylib: Repo.BazelSkylib = .latest - private let cc: Repo.RulesCC = .latest - private let appleSupport: Repo.AppleSupport = .latest + private let skylib: BazelDep.BazelSkylib = .latest + private let cc: BazelDep.RulesCC = .latest + private let appleSupport: BazelDep.AppleSupport = .latest init(_ root: Path) { path = root + "MODULE.bazel" diff --git a/Sources/BazelizeKit/Bazel/Bazel+Version.swift b/Sources/BazelizeKit/Bazel/Bazel+Version.swift index 12b9a76..ac91e17 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Version.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Version.swift @@ -5,8 +5,8 @@ extension Bazel { /// .bazelversion struct Version: BazelFile { let path: Path - let code = "\(Self.repo.rawValue)" - static private let repo: Repo.Bazel = .latest + let code = "\(Self.bazel.rawValue)" + static private let bazel: BazelDep.Bazel = .latest init(_ root: Path) { path = root + ".bazelversion" diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift index 66bfbd4..0d27d11 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/bazelbuild/rules_apple enum Apple: String { static let latest: Apple = .v5_0_0 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift index 687bf04..f7a9fd5 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/keith/rules_apple_linker enum AppleLinker: String { static let latest: AppleLinker = .v0_7_0 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift index 0fc67af..75b8cdd 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/bazelbuild/apple_support enum AppleSupport: String { static let latest: AppleSupport = .v2_8_2 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift index 299e5e7..2a63037 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/bazelbuild/bazel enum Bazel: String { static let latest: Bazel = .v9_2_0 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift b/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift index f439ea5..bfe91c7 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/bazelbuild/bazel-skylib enum BazelSkylib: String { static let latest: BazelSkylib = .v1_9_2 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift b/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift index 4482103..d316820 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/bazelbuild/rules_cc enum RulesCC: String { static let latest: RulesCC = .v0_2_24 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift index 11487cd..5f587c1 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/bazelbuild/rules_swift enum Swift: String { static let latest: Swift = .v4_0_1 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift b/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift index f6b9027..fe4ebcd 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/cgrindel/rules_swift_package_manager enum SwiftPM: String { static let latest: SwiftPM = .v1_24_0 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift b/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift index 9db485e..c415eaf 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift @@ -1,4 +1,4 @@ -extension Repo { +extension BazelDep { /// https://github.com/MobileNativeFoundation/rules_xcodeproj enum XCodeProj: String { static let latest: XCodeProj = .v4_1_0 diff --git a/Sources/BazelizeKit/BazelDep/BazelDep.swift b/Sources/BazelizeKit/BazelDep/BazelDep.swift index 78da7a7..8cc8671 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep.swift @@ -1,10 +1,13 @@ // -// Repo.swift +// BazelDep.swift // // // Created by Yume on 2022/7/5. // -// MARK: - Repo +// MARK: - BazelDep -enum Repo { } +/// Released versions of the Bazel modules Bazelize emits into `MODULE.bazel`. +/// +/// Generated by the `repo-enum` package plugin; see `RepoSources.yml`. +enum BazelDep { } diff --git a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift index 9767b1a..4b477db 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift @@ -11,12 +11,12 @@ import Foundation /// https://github.com/bazelbuild/rules_apple final class PluginApple: PluginBuiltin { - let repo: Repo.Apple = .latest + let dep: BazelDep.Apple = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_apple", - version: repo.rawValue, + version: dep.rawValue, repo_name: "build_bazel_rules_apple") } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift index 2c659a5..b30d6b2 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift @@ -13,11 +13,11 @@ import Foundation /// https://github.com/keith/rules_apple_linker class PluginLinker: PluginBuiltin { - let repo: Repo.AppleLinker = .latest + let dep: BazelDep.AppleLinker = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_apple_linker", - version: repo.rawValue) + version: dep.rawValue) } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift index b2fbc42..813f599 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift @@ -11,12 +11,12 @@ import Foundation /// https://github.com/bazelbuild/rules_swift final class PluginSwift: PluginBuiltin { - let repo: Repo.Swift = .latest + let dep: BazelDep.Swift = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_swift", - version: repo.rawValue, + version: dep.rawValue, repo_name: "build_bazel_rules_swift") } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 1529b45..29795dc 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -12,7 +12,7 @@ import PathKit /// http://github.com/cgrindel/rules_swift_package_manager final class PluginSwiftPM: PluginBuiltin { - private let repo: Repo.SwiftPM = .latest + private let dep: BazelDep.SwiftPM = .latest let remotes: [RemotePackage] let locals: [LocalPackage] private var packages: [String] = [] @@ -46,7 +46,7 @@ final class PluginSwiftPM: PluginBuiltin { override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_swift_package_manager", - version: repo.rawValue) + version: dep.rawValue) builder.custom(""" swift_deps = use_extension( "@rules_swift_package_manager//:extensions.bzl", diff --git a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift index 380b64f..4f4fa99 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift @@ -12,11 +12,11 @@ import XcodeProj /// https://github.com/MobileNativeFoundation/rules_xcodeproj final class PluginXCodeProj: PluginBuiltin { - let repo: Repo.XCodeProj = .latest + let dep: BazelDep.XCodeProj = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_xcodeproj", - version: repo.rawValue) + version: dep.rawValue) } // TODO: From ccdbed714d8a4d731bddad0c8150ae46a03ff564 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 12:48:11 +0800 Subject: [PATCH 030/173] Read bazel_dep versions from the Bazel Central Registry --- RepoSources.yml | 10 ++- Sources/RepoEnumCore/RepoEnumCore.swift | 72 +++++++++++++++++-- .../RepoEnumCoreTests/RepoEnumCoreTests.swift | 72 +++++++++++++++---- 3 files changed, 136 insertions(+), 18 deletions(-) diff --git a/RepoSources.yml b/RepoSources.yml index b3c6ff0..6dc0976 100644 --- a/RepoSources.yml +++ b/RepoSources.yml @@ -1,18 +1,26 @@ - name: Apple url: https://github.com/bazelbuild/rules_apple + module: rules_apple - name: Swift url: https://github.com/bazelbuild/rules_swift + module: rules_swift - name: XCodeProj url: https://github.com/MobileNativeFoundation/rules_xcodeproj + module: rules_xcodeproj - name: SwiftPM url: https://github.com/cgrindel/rules_swift_package_manager + module: rules_swift_package_manager - name: AppleLinker url: https://github.com/keith/rules_apple_linker + module: rules_apple_linker - name: Bazel url: https://github.com/bazelbuild/bazel - name: BazelSkylib url: https://github.com/bazelbuild/bazel-skylib + module: bazel_skylib - name: RulesCC url: https://github.com/bazelbuild/rules_cc + module: rules_cc - name: AppleSupport - url: https://github.com/bazelbuild/apple_support \ No newline at end of file + url: https://github.com/bazelbuild/apple_support + module: apple_support diff --git a/Sources/RepoEnumCore/RepoEnumCore.swift b/Sources/RepoEnumCore/RepoEnumCore.swift index 352132e..38c3fe9 100644 --- a/Sources/RepoEnumCore/RepoEnumCore.swift +++ b/Sources/RepoEnumCore/RepoEnumCore.swift @@ -9,10 +9,16 @@ import Yams public struct RepoSource: Codable, Sendable, Equatable { public let name: String public let url: String + /// Bazel Central Registry module name. + /// + /// Present means versions come from the registry instead of the repository's + /// git tags, because `bazel_dep` can only resolve what the registry serves. + public let module: String? - public init(name: String, url: String) { + public init(name: String, url: String, module: String? = nil) { self.name = name self.url = url + self.module = module } } @@ -67,12 +73,19 @@ public protocol GitHubTagFetching: Sendable { func tags(for repositoryURL: String) async throws -> [String] } +// MARK: - ModuleVersionFetching + +public protocol ModuleVersionFetching: Sendable { + func versions(forModule module: String) async throws -> [String] +} + // MARK: - RepoEnumGeneratorError public enum RepoEnumGeneratorError: LocalizedError { case invalidArguments(String) case invalidGitHubURL(String) case githubRequestFailed(statusCode: Int, message: String) + case registryRequestFailed(module: String, statusCode: Int) public var errorDescription: String? { switch self { @@ -82,6 +95,8 @@ public enum RepoEnumGeneratorError: LocalizedError { return "Invalid GitHub repository URL: \(url)" case .githubRequestFailed(let statusCode, let message): return "GitHub API request failed (\(statusCode)): \(message)" + case .registryRequestFailed(let module, let statusCode): + return "Bazel Central Registry request for \(module) failed (\(statusCode))." } } } @@ -199,15 +214,60 @@ public struct GitHubTagClient: GitHubTagFetching { } } +// MARK: - BazelRegistryClient + +/// Reads published module versions from the Bazel Central Registry. +public struct BazelRegistryClient: ModuleVersionFetching { + private struct Metadata: Decodable { + let versions: [String] + let yanked_versions: [String: String]? + } + + private let session: URLSession + private let registry: URL + + public init( + session: URLSession = .shared, + registry: URL = URL(string: "https://bcr.bazel.build")!) + { + self.session = session + self.registry = registry + } + + public func versions(forModule module: String) async throws -> [String] { + let url = registry + .appendingPathComponent("modules") + .appendingPathComponent(module) + .appendingPathComponent("metadata.json") + + let (data, response) = try await session.data(from: url) + + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + throw RepoEnumGeneratorError.registryRequestFailed( + module: module, + statusCode: httpResponse.statusCode) + } + + let metadata = try JSONDecoder().decode(Metadata.self, from: data) + let yanked = Set((metadata.yanked_versions ?? [:]).keys) + return metadata.versions.filter { !yanked.contains($0) } + } +} + // MARK: - RepoEnumGeneratorService public struct RepoEnumGeneratorService { private let client: GitHubTagFetching + private let registry: ModuleVersionFetching private let decoder = YAMLDecoder() private let fileManager = FileManager.default - public init(client: GitHubTagFetching = GitHubTagClient()) { + public init( + client: GitHubTagFetching = GitHubTagClient(), + registry: ModuleVersionFetching = BazelRegistryClient()) + { self.client = client + self.registry = registry } public func generate(configFile: URL, outputDirectory: URL) async throws { @@ -217,8 +277,12 @@ public struct RepoEnumGeneratorService { try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) for source in sources { - let rawTags = try await client.tags(for: source.url) - let tags = rawTags.compactMap(RepoVersionTag.init(rawTag:)) + let rawVersions = if let module = source.module { + try await registry.versions(forModule: module) + } else { + try await client.tags(for: source.url) + } + let tags = rawVersions.compactMap(RepoVersionTag.init(rawTag:)) let file = RepoEnumFile(source: source, tags: tags) let fileURL = outputDirectory.appendingPathComponent(file.filename) try file.content.write(to: fileURL, atomically: true, encoding: .utf8) diff --git a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift index fa513c5..be063ff 100644 --- a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift +++ b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift @@ -37,7 +37,7 @@ func githubErrorIsReadable() async { config.protocolClasses = [MockURLProtocol.self] let session = URLSession(configuration: config) - await MockURLProtocolStorage.shared.setHandler { request in + MockURLProtocolStorage.shared.setHandler { request in let body = #"{"message":"API rate limit exceeded"}"#.data(using: .utf8)! let response = HTTPURLResponse( url: try #require(request.url), @@ -54,6 +54,52 @@ func githubErrorIsReadable() async { } } +@Test +func registryVersionsSkipYankedReleases() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RegistryMockURLProtocol.self] + let session = URLSession(configuration: config) + + let client = BazelRegistryClient(session: session) + let versions = try await client.versions(forModule: "rules_cc") + + #expect(versions == ["0.2.20", "0.2.22"]) +} + +// MARK: - RegistryMockURLProtocol + +/// Serves one fixed BCR metadata payload; kept separate from `MockURLProtocol` +/// so the two network tests can run in parallel without sharing a handler. +private final class RegistryMockURLProtocol: URLProtocol, @unchecked Sendable { + private static let payload = #""" + { + "versions": ["0.2.20", "0.2.21", "0.2.22"], + "yanked_versions": {"0.2.21": "broken release"} + } + """# + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(Self.payload.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { } +} + // MARK: - MockURLProtocol private final class MockURLProtocol: URLProtocol, @unchecked Sendable { @@ -66,15 +112,13 @@ private final class MockURLProtocol: URLProtocol, @unchecked Sendable { } override func startLoading() { - Task { - do { - let (response, data) = try await MockURLProtocolStorage.shared.handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } + do { + let (response, data) = try MockURLProtocolStorage.shared.handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) } } @@ -83,18 +127,20 @@ private final class MockURLProtocol: URLProtocol, @unchecked Sendable { // MARK: - MockURLProtocolStorage -private actor MockURLProtocolStorage { +private final class MockURLProtocolStorage: @unchecked Sendable { static let shared = MockURLProtocolStorage() + private let lock = NSLock() private var currentHandler: @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) = { _ in fatalError("Handler not set") } func setHandler(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) { - currentHandler = handler + lock.withLock { currentHandler = handler } } func handler(_ request: URLRequest) throws -> (HTTPURLResponse, Data) { - try currentHandler(request) + let handler = lock.withLock { currentHandler } + return try handler(request) } } From 22259859b8f76c486e07990bc82e57e9329c62dd Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 12:48:11 +0800 Subject: [PATCH 031/173] Regenerate BazelDep versions from the registry --- .../BazelizeKit/BazelDep/BazelDep+Apple.swift | 44 ------------------- .../BazelDep/BazelDep+AppleLinker.swift | 15 ------- .../BazelDep/BazelDep+AppleSupport.swift | 21 --------- .../BazelDep/BazelDep+BazelSkylib.swift | 16 ------- .../BazelDep/BazelDep+RulesCC.swift | 8 +--- .../BazelizeKit/BazelDep/BazelDep+Swift.swift | 39 ---------------- .../BazelDep/BazelDep+SwiftPM.swift | 32 +------------- .../BazelDep/BazelDep+XCodeProj.swift | 19 -------- 8 files changed, 2 insertions(+), 192 deletions(-) diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift index 0d27d11..bde93f8 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift @@ -12,7 +12,6 @@ extension BazelDep { case v4_3_3 = "4.3.3" case v4_3_2 = "4.3.2" case v4_3_1 = "4.3.1" - case v4_3_0 = "4.3.0" case v4_2_0 = "4.2.0" case v4_1_2 = "4.1.2" case v4_1_1 = "4.1.1" @@ -61,48 +60,5 @@ extension BazelDep { case v2_2_0 = "2.2.0" case v2_1_0 = "2.1.0" case v2_0_0 = "2.0.0" - case v1_1_3 = "1.1.3" - case v1_1_2 = "1.1.2" - case v1_1_1 = "1.1.1" - case v1_1_0 = "1.1.0" - case v1_0_1 = "1.0.1" - case v1_0_0 = "1.0.0" - case v0_34_2 = "0.34.2" - case v0_34_1 = "0.34.1" - case v0_34_0 = "0.34.0" - case v0_33_0 = "0.33.0" - case v0_32_0 = "0.32.0" - case v0_31_3 = "0.31.3" - case v0_31_2 = "0.31.2" - case v0_31_1 = "0.31.1" - case v0_31_0 = "0.31.0" - case v0_30_0 = "0.30.0" - case v0_21_2 = "0.21.2" - case v0_21_1 = "0.21.1" - case v0_21_0 = "0.21.0" - case v0_20_0 = "0.20.0" - case v0_19_0 = "0.19.0" - case v0_18_0 = "0.18.0" - case v0_17_2 = "0.17.2" - case v0_17_1 = "0.17.1" - case v0_17_0 = "0.17.0" - case v0_16_1 = "0.16.1" - case v0_15_0 = "0.15.0" - case v0_14_0 = "0.14.0" - case v0_13_0 = "0.13.0" - case v0_12_0 = "0.12.0" - case v0_11_1 = "0.11.1" - case v0_11_0 = "0.11.0" - case v0_10_0 = "0.10.0" - case v0_9_0 = "0.9.0" - case v0_8_0 = "0.8.0" - case v0_7_0 = "0.7.0" - case v0_6_0 = "0.6.0" - case v0_5_0 = "0.5.0" - case v0_4_0 = "0.4.0" - case v0_3_0 = "0.3.0" - case v0_2_0 = "0.2.0" - case v0_1_0 = "0.1.0" - case v0_0_1 = "0.0.1" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift index f7a9fd5..99009ed 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift @@ -6,8 +6,6 @@ extension BazelDep { case v0_7_0 = "0.7.0" case v0_6_3 = "0.6.3" case v0_6_2 = "0.6.2" - case v0_6_1 = "0.6.1" - case v0_6_0 = "0.6.0" case v0_5_4 = "0.5.4" case v0_5_3 = "0.5.3" case v0_5_2 = "0.5.2" @@ -16,18 +14,5 @@ extension BazelDep { case v0_4_0 = "0.4.0" case v0_3_1 = "0.3.1" case v0_3_0 = "0.3.0" - case v0_2_4 = "0.2.4" - case v0_2_3 = "0.2.3" - case v0_2_2 = "0.2.2" - case v0_2_1 = "0.2.1" - case v0_2_0 = "0.2.0" - case v0_1_7 = "0.1.7" - case v0_1_6 = "0.1.6" - case v0_1_5 = "0.1.5" - case v0_1_4 = "0.1.4" - case v0_1_3 = "0.1.3" - case v0_1_2 = "0.1.2" - case v0_1_1 = "0.1.1" - case v0_1_0 = "0.1.0" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift index 75b8cdd..2562621 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift @@ -8,7 +8,6 @@ extension BazelDep { case v2_8_0 = "2.8.0" case v2_7_0 = "2.7.0" case v2_6_1 = "2.6.1" - case v2_6_0 = "2.6.0" case v2_5_4 = "2.5.4" case v2_5_3 = "2.5.3" case v2_5_2 = "2.5.2" @@ -57,28 +56,8 @@ extension BazelDep { case v1_4_0 = "1.4.0" case v1_3_2 = "1.3.2" case v1_3_1 = "1.3.1" - case v1_3_0 = "1.3.0" - case v1_2_0 = "1.2.0" - case v1_1_0 = "1.1.0" case v1_0_0 = "1.0.0" case v0_13_0 = "0.13.0" - case v0_12_1 = "0.12.1" - case v0_12_0 = "0.12.0" case v0_11_0 = "0.11.0" - case v0_10_0 = "0.10.0" - case v0_9_2 = "0.9.2" - case v0_9_1 = "0.9.1" - case v0_9_0 = "0.9.0" - case v0_8_0 = "0.8.0" - case v0_7_2 = "0.7.2" - case v0_7_1 = "0.7.1" - case v0_7_0 = "0.7.0" - case v0_6_0 = "0.6.0" - case v0_5_0 = "0.5.0" - case v0_4_0 = "0.4.0" - case v0_3_0 = "0.3.0" - case v0_2_0 = "0.2.0" - case v0_1_1 = "0.1.1" - case v0_1_0 = "0.1.0" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift b/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift index bfe91c7..d1a3275 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift @@ -4,7 +4,6 @@ extension BazelDep { static let latest: BazelSkylib = .v1_9_2 case v1_9_2 = "1.9.2" - case v1_9_1 = "1.9.1" case v1_9_0 = "1.9.0" case v1_8_2 = "1.8.2" case v1_8_1 = "1.8.1" @@ -21,21 +20,6 @@ extension BazelDep { case v1_2_1 = "1.2.1" case v1_2_0 = "1.2.0" case v1_1_1 = "1.1.1" - case v1_1_0 = "1.1.0" case v1_0_3 = "1.0.3" - case v1_0_2 = "1.0.2" - case v1_0_1 = "1.0.1" - case v1_0_0 = "1.0.0" - case v0_9_0 = "0.9.0" - case v0_8_0 = "0.8.0" - case v0_7_0 = "0.7.0" - case v0_6_0 = "0.6.0" - case v0_5_0 = "0.5.0" - case v0_4_0 = "0.4.0" - case v0_3_1 = "0.3.1" - case v0_3_0 = "0.3.0" - case v0_2_0 = "0.2.0" - case v0_1_1 = "0.1.1" - case v0_1_0 = "0.1.0" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift b/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift index d316820..76dc399 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift @@ -1,10 +1,8 @@ extension BazelDep { /// https://github.com/bazelbuild/rules_cc enum RulesCC: String { - static let latest: RulesCC = .v0_2_24 + static let latest: RulesCC = .v0_2_22 - case v0_2_24 = "0.2.24" - case v0_2_23 = "0.2.23" case v0_2_22 = "0.2.22" case v0_2_21 = "0.2.21" case v0_2_20 = "0.2.20" @@ -33,22 +31,18 @@ extension BazelDep { case v0_1_3 = "0.1.3" case v0_1_2 = "0.1.2" case v0_1_1 = "0.1.1" - case v0_1_0 = "0.1.0" case v0_0_17 = "0.0.17" case v0_0_16 = "0.0.16" case v0_0_15 = "0.0.15" - case v0_0_14 = "0.0.14" case v0_0_13 = "0.0.13" case v0_0_12 = "0.0.12" case v0_0_11 = "0.0.11" case v0_0_10 = "0.0.10" case v0_0_9 = "0.0.9" case v0_0_8 = "0.0.8" - case v0_0_7 = "0.0.7" case v0_0_6 = "0.0.6" case v0_0_5 = "0.0.5" case v0_0_4 = "0.0.4" - case v0_0_3 = "0.0.3" case v0_0_2 = "0.0.2" case v0_0_1 = "0.0.1" } diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift index 5f587c1..6933acf 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift @@ -16,7 +16,6 @@ extension BazelDep { case v3_1_1 = "3.1.1" case v3_1_0 = "3.1.0" case v3_0_2 = "3.0.2" - case v3_0_0 = "3.0.0" case v2_9_0 = "2.9.0" case v2_8_2 = "2.8.2" case v2_8_1 = "2.8.1" @@ -54,44 +53,6 @@ extension BazelDep { case v1_6_0 = "1.6.0" case v1_5_1 = "1.5.1" case v1_5_0 = "1.5.0" - case v1_4_0 = "1.4.0" - case v1_3_0 = "1.3.0" case v1_2_0 = "1.2.0" - case v1_1_1 = "1.1.1" - case v1_1_0 = "1.1.0" - case v1_0_0 = "1.0.0" - case v0_27_0 = "0.27.0" - case v0_26_0 = "0.26.0" - case v0_25_0 = "0.25.0" - case v0_24_0 = "0.24.0" - case v0_23_0 = "0.23.0" - case v0_22_0 = "0.22.0" - case v0_21_0 = "0.21.0" - case v0_20_0 = "0.20.0" - case v0_19_0 = "0.19.0" - case v0_18_0 = "0.18.0" - case v0_17_0 = "0.17.0" - case v0_16_1 = "0.16.1" - case v0_16_0 = "0.16.0" - case v0_15_0 = "0.15.0" - case v0_14_0 = "0.14.0" - case v0_13_0 = "0.13.0" - case v0_12_1 = "0.12.1" - case v0_12_0 = "0.12.0" - case v0_11_1 = "0.11.1" - case v0_11_0 = "0.11.0" - case v0_10_1 = "0.10.1" - case v0_9_0 = "0.9.0" - case v0_8_0 = "0.8.0" - case v0_7_0 = "0.7.0" - case v0_6_0 = "0.6.0" - case v0_5_0 = "0.5.0" - case v0_4_0 = "0.4.0" - case v0_3_1 = "0.3.1" - case v0_3_0 = "0.3.0" - case v0_2_0 = "0.2.0" - case v0_1_3 = "0.1.3" - case v0_1_1 = "0.1.1" - case v0_1_0 = "0.1.0" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift b/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift index fe4ebcd..6465c59 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift @@ -1,26 +1,22 @@ extension BazelDep { /// https://github.com/cgrindel/rules_swift_package_manager enum SwiftPM: String { - static let latest: SwiftPM = .v1_24_0 + static let latest: SwiftPM = .v1_23_0 - case v1_24_0 = "1.24.0" case v1_23_0 = "1.23.0" case v1_22_0 = "1.22.0" case v1_21_0 = "1.21.0" case v1_20_0 = "1.20.0" case v1_19_0 = "1.19.0" case v1_18_1 = "1.18.1" - case v1_18_0 = "1.18.0" case v1_17_1 = "1.17.1" case v1_17_0 = "1.17.0" case v1_16_1 = "1.16.1" - case v1_16_0 = "1.16.0" case v1_15_0 = "1.15.0" case v1_14_0 = "1.14.0" case v1_13_0 = "1.13.0" case v1_12_0 = "1.12.0" case v1_11_3 = "1.11.3" - case v1_11_2 = "1.11.2" case v1_11_1 = "1.11.1" case v1_11_0 = "1.11.0" case v1_10_0 = "1.10.0" @@ -35,39 +31,27 @@ extension BazelDep { case v1_1_0 = "1.1.0" case v1_0_0 = "1.0.0" case v0_47_2 = "0.47.2" - case v0_47_1 = "0.47.1" - case v0_47_0 = "0.47.0" - case v0_46_0 = "0.46.0" case v0_45_0 = "0.45.0" case v0_44_0 = "0.44.0" case v0_43_0 = "0.43.0" case v0_42_0 = "0.42.0" case v0_41_0 = "0.41.0" case v0_40_1 = "0.40.1" - case v0_40_0 = "0.40.0" case v0_39_0 = "0.39.0" case v0_38_2 = "0.38.2" - case v0_38_1 = "0.38.1" - case v0_38_0 = "0.38.0" case v0_37_0 = "0.37.0" case v0_36_0 = "0.36.0" case v0_35_1 = "0.35.1" - case v0_35_0 = "0.35.0" case v0_34_1 = "0.34.1" case v0_34_0 = "0.34.0" case v0_33_0 = "0.33.0" case v0_32_0 = "0.32.0" case v0_31_1 = "0.31.1" - case v0_31_0 = "0.31.0" case v0_30_0 = "0.30.0" case v0_29_2 = "0.29.2" case v0_29_1 = "0.29.1" - case v0_29_0 = "0.29.0" case v0_28_0 = "0.28.0" - case v0_27_0 = "0.27.0" case v0_26_2 = "0.26.2" - case v0_26_1 = "0.26.1" - case v0_26_0 = "0.26.0" case v0_25_0 = "0.25.0" case v0_24_0 = "0.24.0" case v0_23_0 = "0.23.0" @@ -76,8 +60,6 @@ extension BazelDep { case v0_20_0 = "0.20.0" case v0_19_0 = "0.19.0" case v0_18_2 = "0.18.2" - case v0_18_1 = "0.18.1" - case v0_18_0 = "0.18.0" case v0_17_0 = "0.17.0" case v0_16_0 = "0.16.0" case v0_15_0 = "0.15.0" @@ -98,17 +80,5 @@ extension BazelDep { case v0_4_4 = "0.4.4" case v0_4_3 = "0.4.3" case v0_4_2 = "0.4.2" - case v0_4_1 = "0.4.1" - case v0_4_0 = "0.4.0" - case v0_3_3 = "0.3.3" - case v0_3_2 = "0.3.2" - case v0_3_1 = "0.3.1" - case v0_3_0 = "0.3.0" - case v0_2_2 = "0.2.2" - case v0_2_1 = "0.2.1" - case v0_2_0 = "0.2.0" - case v0_1_0 = "0.1.0" - case v0_0_2 = "0.0.2" - case v0_0_1 = "0.0.1" } } \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift b/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift index c415eaf..658ee57 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift @@ -13,7 +13,6 @@ extension BazelDep { case v3_3_0 = "3.3.0" case v3_2_0 = "3.2.0" case v3_1_2 = "3.1.2" - case v3_1_1 = "3.1.1" case v3_1_0 = "3.1.0" case v3_0_0 = "3.0.0" case v2_12_1 = "2.12.1" @@ -72,24 +71,6 @@ extension BazelDep { case v1_0_1 = "1.0.1" case v0_12_3 = "0.12.3" case v0_12_2 = "0.12.2" - case v0_12_1 = "0.12.1" case v0_12_0 = "0.12.0" - case v0_11_0 = "0.11.0" - case v0_10_2 = "0.10.2" - case v0_10_1 = "0.10.1" - case v0_10_0 = "0.10.0" - case v0_9_0 = "0.9.0" - case v0_8_0 = "0.8.0" - case v0_7_1 = "0.7.1" - case v0_7_0 = "0.7.0" - case v0_6_0 = "0.6.0" - case v0_5_1 = "0.5.1" - case v0_5_0 = "0.5.0" - case v0_4_2 = "0.4.2" - case v0_4_1 = "0.4.1" - case v0_4_0 = "0.4.0" - case v0_3_0 = "0.3.0" - case v0_2_0 = "0.2.0" - case v0_1_0 = "0.1.0" } } \ No newline at end of file From b2d823a6ba90d600030eb55234c2bb1f1124d5d1 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:14:39 +0800 Subject: [PATCH 032/173] Delete the unused Xcode2 roadmap tree builder --- Sources/Xcode2/RoadmapTreeBuilder.swift | 178 ------------------------ 1 file changed, 178 deletions(-) delete mode 100644 Sources/Xcode2/RoadmapTreeBuilder.swift diff --git a/Sources/Xcode2/RoadmapTreeBuilder.swift b/Sources/Xcode2/RoadmapTreeBuilder.swift deleted file mode 100644 index 7a59c0e..0000000 --- a/Sources/Xcode2/RoadmapTreeBuilder.swift +++ /dev/null @@ -1,178 +0,0 @@ -import Foundation -import PathKit - -// MARK: - XCode.RoadmapTreeBuilder - -extension XCode { - public struct RoadmapTreeBuilder { - public let output: Path - - public init(output: Path) { - self.output = output - } - - public func build(project: XCode.Project) throws { - try output.mkpath() - try linkPackageResolvedIfPresent(project: project) - try materializePrebuiltFiles(project: project) - - let targetsRoot = output + "Targets" - try targetsRoot.mkpath() - - for target in project.targets { - try prepare(target: target, project: project, targetsRoot: targetsRoot) - } - } - - private func prepare( - target: XCode.Target, - project: XCode.Project, - targetsRoot: Path) throws - { - let targetRoot = targetsRoot + target.name - let sourcesRoot = targetRoot + "Sources" - let generatedRoot = targetRoot + "Generated" - - try sourcesRoot.mkpath() - try generatedRoot.mkpath() - - var materializedDirectories = Set() - for relativePath in target.pathsForRoadmapTree { - let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let hasMaterializedAncestor = materializedDirectories.contains { existing in - normalizedPath == existing || normalizedPath.hasPrefix(existing + "/") - } - guard !hasMaterializedAncestor else { continue } - - let source = Path(project.workspacePath) + relativePath - guard source.exists else { continue } - guard !source.isSelfReferentialSymlink else { continue } - - let destination = sourcesRoot + relativePath - try materialize(source: source, destination: destination) - if source.isDirectory { - materializedDirectories.insert(normalizedPath) - } - } - } - - private func materializePrebuiltFiles(project: XCode.Project) throws { - let prebuiltRoot = output + "Prebuilt" - try prebuiltRoot.mkpath() - - for file in project.prebuiltFiles { - guard let relativePath = file.path, !relativePath.isEmpty else { continue } - let source = Path(project.workspacePath) + relativePath - guard source.exists else { continue } - guard !source.isSelfReferentialSymlink else { continue } - - let destination = prebuiltRoot + Path(relativePath).lastComponent - try replaceIfNeeded(at: destination) - try destination.symlink(source) - } - } - - private func linkPackageResolvedIfPresent(project: XCode.Project) throws { - let source = Path(project.workspacePath) + "Package.resolved" - guard source.exists else { return } - - let destination = output + "Package.resolved" - try replaceIfNeeded(at: destination) - try destination.symlink(source) - } - - private func replaceIfNeeded(at path: Path) throws { - guard path.exists || path.isSymlink else { return } - try path.delete() - } - - private func materialize(source: Path, destination: Path) throws { - guard !source.isRoadmapIgnoredFile else { return } - - if source.isDirectory { - if destination.isSymlink { - try destination.delete() - } - if !destination.exists { - try destination.mkpath() - } - for child in try source.children() { - guard !child.isSelfReferentialSymlink else { continue } - try materialize(source: child, destination: destination + child.lastComponent) - } - return - } - - try destination.parent().mkpath() - try replaceIfNeeded(at: destination) - try destination.symlink(source) - } - } -} - -extension XCode.Target { - fileprivate var pathsForRoadmapTree: [String] { - let allFiles = files.sources + files.headers + files.resources + files.copyFiles + files.others - let candidates = allFiles.compactMap(\.roadmapRelativePath).sorted { - let lhsDepth = $0.split(separator: "/").count - let rhsDepth = $1.split(separator: "/").count - if lhsDepth == rhsDepth { - return $0 < $1 - } - return lhsDepth < rhsDepth - } - - var result: [String] = [] - var seen = Set() - - for path in candidates where seen.insert(path).inserted { - let hasAncestor = result.contains { existing in - path == existing || path.hasPrefix(existing + "/") - } - guard !hasAncestor else { continue } - result.append(path) - } - - return result - } -} - -extension XCode.Project { - fileprivate var prebuiltFiles: [XCode.File] { - let all = targets.flatMap { target in - target.files.frameworks.filter { $0.label?.hasPrefix("//Prebuilt:") == true } - } - - var seen = Set() - return all.filter { file in - guard let path = file.path else { return false } - return seen.insert(path).inserted - } - } -} - -extension XCode.File { - fileprivate var roadmapRelativePath: String? { - if let path, !path.isEmpty { - return path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - } - return nil - } -} - -extension Path { - fileprivate var isSelfReferentialSymlink: Bool { - guard isSymlink else { return false } - guard let destination = try? symlinkDestination().absolute() else { return false } - return destination == absolute() - } - - fileprivate var isRoadmapIgnoredFile: Bool { - switch lastComponent { - case "BUILD", "BUILD.bazel": - return true - default: - return false - } - } -} From 3afdcfc5bd326534ac4916b09b06360ec207475f Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:14:47 +0800 Subject: [PATCH 033/173] Pin rules_swift_package_manager below its strict minimum-OS transition --- Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 29795dc..112f63a 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -12,7 +12,12 @@ import PathKit /// http://github.com/cgrindel/rules_swift_package_manager final class PluginSwiftPM: PluginBuiltin { - private let dep: BazelDep.SwiftPM = .latest + /// Not `.latest`: 1.16.0+ transitions every SwiftPM target to its own declared + /// platform floor and then fails analysis when a package imports a dependency + /// with a higher floor. Xcode never enforces that, so real projects (e.g. + /// SimplyCoreAudio declaring macOS 10.12 while depending on swift-atomics + /// declaring 10.13) stop analyzing on versions past 1.15.0. + private let dep: BazelDep.SwiftPM = .v1_15_0 let remotes: [RemotePackage] let locals: [LocalPackage] private var packages: [String] = [] From 463f2bf5d0ddeed2ca5a67135c5f16515929a5f2 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:14:47 +0800 Subject: [PATCH 034/173] Seed Xcode package pins into the generated workspace --- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 112f63a..38a4bc7 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -21,7 +21,11 @@ final class PluginSwiftPM: PluginBuiltin { let remotes: [RemotePackage] let locals: [LocalPackage] private var packages: [String] = [] + private var projectPath: Path? + func loadPackageNames(projPath: Path) async throws { + projectPath = projPath + let packageSwift = package let workspace = projPath.parent() let path = workspace + packageSwift.path @@ -154,8 +158,23 @@ final class PluginSwiftPM: PluginBuiltin { """) } + /// Seeds Xcode's own pins so the first `swift package resolve` keeps the versions + /// the project builds against instead of floating every package to its newest + /// release. Never overwrites an existing file: after the first run the resolved + /// graph belongs to SwiftPM and Bazel. + private var packageResolved: PluginBuiltin.Custom? { + guard !remotes.isEmpty else { return nil } + guard let projectPath else { return nil } + guard !(kit.outputRoot + "Package.resolved").exists else { return nil } + + let resolved = projectPath + "project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + guard let content = try? String(contentsOfFile: resolved.string, encoding: .utf8) else { return nil } + + return .init(path: "Package.resolved", content: content) + } + override var custom: [PluginBuiltin.Custom]? { - [package] + [package, packageResolved].compactMap { $0 } } override var tip: String? { From 005bd99250c9226a48ed1fd6e9fa20f89b179a82 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:14:47 +0800 Subject: [PATCH 035/173] Default minimum OS versions and import config.bazelrc from .bazelrc --- Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift | 72 +++++++++++++++++-- Sources/BazelizeKit/Kit.swift | 6 +- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift b/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift index ea42801..46cefce 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift @@ -28,13 +28,73 @@ extension Bazel { /// build:debug --//:mode=debug /// bazel build --config=debug [PACKAGE:RULE] mutating - func setup(config: [String : BuildSettings]?) { + func setup(config: [String : BuildSettings]?, targets: [Target]) { let configs = config?.keys.map { $0 } ?? [] - code = configs.map { config in - """ - build:\(config) --//:mode=\(config) - """ - }.sorted().withNewLine + let modes = configs.map { config in + "build:\(config) --//:mode=\(config)" + }.sorted() + + code = (Self.minimumOSFlags(targets: targets) + modes).withNewLine + } + + /// Xcode resolves deployment targets per target; Bazel needs a default for + /// everything outside a bundle rule's transition, otherwise SwiftPM + /// dependencies fail analysis against Bazel's own (much older) defaults. + private static func minimumOSFlags(targets: [Target]) -> [String] { + let platforms: [(flag: String, keyPath: KeyPath)] = [ + ("ios_minimum_os", \.platform.iOS), + ("macos_minimum_os", \.platform.macOS), + ("tvos_minimum_os", \.platform.tvOS), + ("watchos_minimum_os", \.platform.watchOS), + ] + + return platforms.compactMap { platform in + let versions = targets.compactMap { target in + target.prefer(platform.keyPath) + } + guard let highest = versions.max(by: Self.isOlder) else { return nil } + return "build --\(platform.flag)=\(highest)" + } + } + + private static func isOlder(_ lhs: String, _ rhs: String) -> Bool { + let left = lhs.split(separator: ".").compactMap { Int($0) } + let right = rhs.split(separator: ".").compactMap { Int($0) } + + for (l, r) in zip(left, right) where l != r { + return l < r + } + return left.count < right.count + } + } +} + +extension Bazel { + /// /.bazelrc + /// + /// Bazel only reads `config.bazelrc` when the root `.bazelrc` imports it, so + /// the generated flags are inert without this file. + struct RootRC { + static let importLine = "import %workspace%/config.bazelrc" + + let path: Path + + init(_ root: Path) { + path = root + ".bazelrc" + } + + /// Creates `.bazelrc` when missing and otherwise appends the import once, + /// because the file may be hand-written and carry unrelated flags. + func ensureImport() throws { + guard let existing = try? String(contentsOfFile: path.string, encoding: .utf8) else { + try path.write(Self.importLine + "\n") + return + } + + guard !existing.components(separatedBy: .newlines).contains(Self.importLine) else { return } + + let separator = existing.hasSuffix("\n") || existing.isEmpty ? "" : "\n" + try path.write(existing + separator + Self.importLine + "\n") } } } diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 9ae50b9..287f546 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -21,6 +21,7 @@ public final class Kit { lazy var module = Bazel.Module(outputRoot) lazy var build = Bazel.RootBuild(outputRoot) lazy var config = Bazel.BazelRC(outputRoot) + lazy var rootRC = Bazel.RootRC(outputRoot) lazy var prebuilt = Bazel.PrebuiltBuild(outputRoot) lazy var targetsBuild = project.targets.map { target in Bazel.TargetBuild(outputRoot, target) @@ -134,10 +135,11 @@ extension Kit { Log.codeGenerate.info("Create `BUILD` at \(path, privacy: .public)") } - /// {WORKSPACE}/config.bazelrc + /// {WORKSPACE}/config.bazelrc and {WORKSPACE}/.bazelrc private final func generateConfig() throws { - config.setup(config: project.config) + config.setup(config: project.config, targets: project.targets) try config.write() + try rootRC.ensureImport() let path = config.path Log.codeGenerate.info("Create `config.bazelrc` at \(path, privacy: .public)") From 804a1a04b2030a383107b7cc84bc7ef2b042dc63 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:15:04 +0800 Subject: [PATCH 036/173] Keep generated plist fragments consistent with rules_apple --- .../Codegen/CodeGen+Extension.swift | 4 +- .../Codegen/Codegen+Application.swift | 20 +-- .../Codegen/Codegen+Framework.swift | 4 +- .../BazelizeKit/Codegen/Codegen+Plist.swift | 155 +++++++++++++++--- 4 files changed, 142 insertions(+), 41 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index 2360e6f..ae64548 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -17,7 +17,7 @@ extension Target { } } - private func buildIOS(_ builder: CodeBuilder, _: Kit) { + private func buildIOS(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.ios_extension) // families = ["iphone", "ipad"], // provisioning_profile = ":ShareExtension.mobileprovision", # 若需要簽名 @@ -34,7 +34,7 @@ extension Target { infoplists: .build { plist_file plist_auto - plist_default + plistDefault(kit) }, minimum_os_version: prefer(\.platform.iOS), visibility: .public)) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 1004c1f..5373c45 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -19,7 +19,7 @@ extension Target { } } - func generateCommandLineApplicationCode(_ builder: CodeBuilder, _: Kit) { + func generateCommandLineApplicationCode(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.macos_command_line_application) builder.call( Rules.Apple.MacOS.Call.macos_command_line_application( @@ -31,7 +31,7 @@ extension Target { infoplists: .build { plist_file plist_auto - // plist_default + plistDefault(kit) }, minimum_os_version: prefer(\.platform.macOS), visibility: .public)) @@ -39,7 +39,7 @@ extension Target { // MARK: Private - private func buildWatch(_ builder: CodeBuilder, _: Kit) { + private func buildWatch(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.watchos_application) builder.call( Rules.Apple.WatchOS.Call.watchos_application( @@ -52,7 +52,7 @@ extension Target { infoplists: .build { plist_file plist_auto - plist_default + plistDefault(kit) }, minimum_os_version: prefer(\.platform.watchOS), resources: .build { @@ -78,7 +78,7 @@ extension Target { infoplists: .build { plist_file plist_auto - plist_default + plistDefault(kit) }, // "launch_storyboard" => ":Base.lproj/LaunchScreen.storyboard" minimum_os_version: prefer(\.platform.iOS), @@ -91,7 +91,7 @@ extension Target { visibility: .public)) } - private func buildMac(_ builder: CodeBuilder, _: Kit) { + private func buildMac(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.macos_application) builder.call( Rules.Apple.MacOS.Call.macos_application( @@ -104,13 +104,13 @@ extension Target { infoplists: .build { plist_file plist_auto - plist_default + plistDefault(kit) }, minimum_os_version: prefer(\.platform.macOS), visibility: .public)) } - private func buildTV(_ builder: CodeBuilder, _: Kit) { + private func buildTV(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.tvos_application) builder.call( Rules.Apple.TVOS.Call.tvos_application( @@ -123,7 +123,7 @@ extension Target { infoplists: .build { plist_file plist_auto - plist_default + plistDefault(kit) }, minimum_os_version: prefer(\.platform.tvOS), resources: .build { @@ -132,7 +132,7 @@ extension Target { visibility: .public)) } - private var appIcons: Starlark.Value? { + var appIcons: Starlark.Value? { guard let iconName = prefer(\.assetCatalog.appIconName) else { return nil } let iconGlobs = assets.map { asset in "\(asset)/\(iconName).appiconset/**" diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index 3ee067f..fe0720f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -1,7 +1,7 @@ // TODO: https://github.com/XCodeBazelize/Bazelize/issues/8 framework(static/dynamic) extension Target { - func generateFrameworkCode(_ builder: CodeBuilder, _: Kit) { + func generateFrameworkCode(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.ios_framework) builder.call( Rules.Apple.IOS.Call.ios_framework( @@ -15,7 +15,7 @@ extension Target { infoplists: .build { plist_file plist_auto - plist_default + plistDefault(kit) }, minimum_os_version: prefer(\.platform.iOS), visibility: .public)) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index b9c5db6..b50b58d 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -49,30 +49,118 @@ extension Target { visibility: .private)) } + /// Keys the target's checked-in `Info.plist` already defines. + func infoPlistKeys(project: Project?) -> Set { + guard let nodes = infoPlistNodes(project: project) else { return [] } + + return Set( + nodes + .compactMap { $0 as? XMLElement } + .filter { $0.name == "key" } + .compactMap(\.stringValue)) + } + // MARK: Private - private func plistContent(project: Project?) -> String? { + private func infoPlistNodes(project: Project?) -> [XMLNode]? { guard let project else { return nil } - guard let plistPath = prefer(\.plist.infoPlist) else { - return nil - } - let path = Path(project.workspacePath) + plistPath + guard let plistPath = prefer(\.plist.infoPlist) else { return nil } + let path = Path(project.workspacePath) + plistPath guard let content: String = try? path.read() else { return nil } - guard - let xml = try? XMLDocument(xmlString: content, options: .documentXInclude) - .rootElement()? - .elements(forName: "dict") - .first? - .children else { return nil } + return try? XMLDocument(xmlString: content, options: .documentXInclude) + .rootElement()? + .elements(forName: "dict") + .first? + .children + } + + private func plistContent(project: Project?) -> String? { + guard let nodes = infoPlistNodes(project: project) else { return nil } + + return Self.entries(nodes, dropping: appIcons == nil ? [] : Self.iconKeys) + .withNewLine + .replacingOccurrences(of: "$(PRODUCT_MODULE_NAME)", with: "$(PRODUCT_NAME)") + .resolvingBuildSettingReferences(with: selectedSettings) + } + + /// `macos_application`/`ios_application` derive these from `app_icons`, and + /// `plisttool` fails the build when a fragment disagrees with what it wrote. + private static let iconKeys: Set = [ + "CFBundleIconFile", + "CFBundleIconFiles", + "CFBundleIconName", + ] + + /// The plist `dict` is a flat ``/value sequence, so dropping a key means + /// dropping the element that follows it too. + private static func entries(_ nodes: [XMLNode], dropping keys: Set) -> [String] { + var result: [String] = [] + var skipValue = false - return xml.compactMap { node -> String in - node.detach() - return node.xmlString(options: [.nodePrettyPrint, .nodePreserveAll]) + for node in nodes { + guard let element = node as? XMLElement else { continue } + + if skipValue { + skipValue = false + continue + } + + if + element.name == "key", + let key = element.stringValue, + keys.contains(key) + { + skipValue = true + continue + } + + element.detach() + result.append(element.xmlString(options: [.nodePrettyPrint, .nodePreserveAll])) } - .withNewLine - .replacingOccurrences(of: "$(PRODUCT_MODULE_NAME)", with: "$(PRODUCT_NAME)") + + return result + } +} + +extension String { + /// Variables `plisttool` substitutes itself; leaving them intact keeps + /// rules_apple in charge of the bundle identity it also validates. + fileprivate static let plistToolVariables: Set = [ + "BUNDLE_NAME", + "DEVELOPMENT_LANGUAGE", + "EXECUTABLE_NAME", + "PRODUCT_BUNDLE_IDENTIFIER", + "PRODUCT_NAME", + "TARGET_NAME", + ] + + /// Expands the remaining `$(SETTING)` references from the target's build + /// settings. `plisttool` only knows a handful of variables, so anything else + /// copied out of an Xcode `Info.plist` would either reach the bundle verbatim + /// or collide with a resolved value in another fragment. + fileprivate func resolvingBuildSettingReferences(with settings: BuildSettings) -> String { + guard let regex = try? NSRegularExpression(pattern: #"\$\(([A-Za-z0-9_]+)\)"#) else { return self } + + let matches = regex.matches(in: self, range: NSRange(startIndex..., in: self)) + var result = self + + for match in matches.reversed() { + guard + let wholeRange = Range(match.range(at: 0), in: self), + let keyRange = Range(match.range(at: 1), in: self) + else { + continue + } + + let key = String(self[keyRange]) + guard !Self.plistToolVariables.contains(key), let value = settings[key] else { continue } + + result.replaceSubrange(wholeRange, with: value) + } + + return result } } @@ -119,12 +207,16 @@ extension Target { extension Target { // MARK: Internal - var plist_default: Starlark.Label? { - configs.values.contains(where: { !defaultPlistFragments(for: $0).isEmpty }) ? ":plist_default" : nil + func plistDefault(_ kit: Kit) -> Starlark.Label? { + defaultPlistFragments( + for: selectedSettings, + skipping: infoPlistKeys(project: kit.project)).isEmpty ? nil : ":plist_default" } - func generatePlistDefault(_ builder: CodeBuilder, _: Kit) { - let plist = defaultPlistFragments(for: selectedSettings) + func generatePlistDefault(_ builder: CodeBuilder, _ kit: Kit) { + let plist = defaultPlistFragments( + for: selectedSettings, + skipping: infoPlistKeys(project: kit.project)) if !plist.isEmpty { builder.call( Rules.Plist.Call.plist_fragment( @@ -146,7 +238,14 @@ extension Target { return !defaultPlistFragments(for: selectedSettings).isEmpty } - private func defaultPlistFragments(for settings: BuildSettings) -> [String] { + /// The target's own `Info.plist` is the source of truth Xcode uses, so a + /// default derived from build settings must not restate those keys: `plisttool` + /// rejects two fragments that disagree on one key. + private func defaultPlistFragments( + for settings: BuildSettings, + skipping existing: Set = []) + -> [String] + { let defaults = [ ("CFBundleName", "$(PRODUCT_NAME)"), ("CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"), @@ -157,11 +256,13 @@ extension Target { ("CFBundleShortVersionString", settings.generatedPlist.marketingVersion ?? "$(MARKETING_VERSION)"), ] - return defaults.map { key, value in - """ - \(key) - \(value) - """ - } + return defaults + .filter { key, _ in !existing.contains(key) } + .map { key, value in + """ + \(key) + \(value) + """ + } } } From 3ea97d8d2a6e1714d0e6519a8b4b7337f7f599ca Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:15:05 +0800 Subject: [PATCH 037/173] Support SWIFT_OBJC_BRIDGING_HEADER in generated swift_library targets --- .../Language/Codegen+SwiftLibrary.swift | 18 ++++++++++++++++++ .../Roadmap/BazelizeKit+Roadmap.swift | 14 +++++++++++++- .../Model/Config/XCode+BuildSettings.swift | 1 + 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 9d89920..d173103 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -22,6 +22,7 @@ extension Target { builder.call( Rules.Swift.Call.swift_library( name: "\(name)_swift", + copts: bridgingHeaderCopts, module_name: codegenModuleName, srcs: .build { srcs_swift @@ -41,6 +42,9 @@ extension Target { storyboards }, defines: defines(project: project), + swiftc_inputs: .build { + bridgingHeader + }, testonly: isTest, visibility: .private)) @@ -51,6 +55,20 @@ extension Target { visibility: .public)) } + /// `SWIFT_OBJC_BRIDGING_HEADER`, relative to the target's `Sources/` tree. + /// + /// rules_swift has no bridging-header attribute, so the header is passed + /// straight to the compiler and declared as a `swiftc_inputs` file. + var bridgingHeader: String? { + guard let header = prefer(\.bridgingHeader), !header.isEmpty, !header.hasPrefix("/") else { return nil } + return "Sources/\(header)" + } + + var bridgingHeaderCopts: [String]? { + guard let bridgingHeader else { return nil } + return ["-import-objc-header", "$(location \(bridgingHeader))"] + } + // MARK: Private func defines(project: Project) -> Starlark.Value { diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 707c280..57b4173 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -110,7 +110,7 @@ extension Bazel { extension XCode2.XCode.Target { fileprivate var pathsForRoadmapTree: [String] { let allFiles = files.sources + files.headers + files.resources + files.copyFiles + files.others - let candidates = allFiles.compactMap(\.roadmapRelativePath).sorted { + let candidates = (allFiles.compactMap(\.roadmapRelativePath) + settingReferencedPaths).sorted { let lhsDepth = $0.split(separator: "/").count let rhsDepth = $1.split(separator: "/").count if lhsDepth == rhsDepth { @@ -132,6 +132,18 @@ extension XCode2.XCode.Target { return result } + + /// Files Xcode reaches through build settings instead of a build phase; the + /// bridging header and entitlements are rule inputs, so they need to exist in + /// the target's `Sources/` tree. + fileprivate var settingReferencedPaths: [String] { + [ + prefer(\.bridgingHeader), + metadata.entitlements, + ] + .compactMap { $0 } + .filter { !$0.isEmpty && !$0.hasPrefix("/") } + } } extension XCode2.XCode.Project { diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 8aca05b..3e76e10 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -63,6 +63,7 @@ extension XCode { extension XCode.BuildSettings { public var swiftVersion: String? { self["SWIFT_VERSION"] } public var swiftDefine: String? { self["OTHER_SWIFT_FLAGS"] } + public var bridgingHeader: String? { self["SWIFT_OBJC_BRIDGING_HEADER"] } public var testTargetName: String? { self["TEST_TARGET_NAME"] } public var testHost: String? { self["TEST_HOST"] } public var bundleLoader: String? { self["BUNDLE_LOADER"] } From 533dfab43a8b986c3a270e233b21a2c232cdd0ed Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:15:10 +0800 Subject: [PATCH 038/173] Link system frameworks and dylibs from swift_library targets --- .../Codegen/Language/Codegen+SwiftLibrary.swift | 16 ++++++++++++++++ Sources/Xcode2/Loader/XCode+TargetLoader.swift | 15 +++++++++++++++ .../Xcode2/Model/Target/XCode+Dependencies.swift | 6 ++++++ Sources/Xcode2/Model/Target/XCode+Target.swift | 4 ++++ .../TargetSummaryFormatterTests.swift | 1 + 5 files changed, 42 insertions(+) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index d173103..c4d13e4 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -42,6 +42,7 @@ extension Target { storyboards }, defines: defines(project: project), + linkopts: sdkLinkopts, swiftc_inputs: .build { bridgingHeader }, @@ -55,6 +56,7 @@ extension Target { visibility: .public)) } + /// `SWIFT_OBJC_BRIDGING_HEADER`, relative to the target's `Sources/` tree. /// /// rules_swift has no bridging-header attribute, so the header is passed @@ -69,6 +71,20 @@ extension Target { return ["-import-objc-header", "$(location \(bridgingHeader))"] } + /// `swift_library` has no `sdk_frameworks`, so system frameworks and dylibs from + /// the target's Frameworks phase are linked through raw linker flags. + var sdkLinkopts: [String]? { + let searchPaths = frameworkSearchPathsSDK.map { "-F\($0)" } + let frameworks = frameworksSDK.flatMap { ["-framework", $0] } + let weakFrameworks = weakFrameworksSDK.flatMap { ["-weak_framework", $0] } + let dylibs = dylibsSDK.map { name in + "-l\(name.delete(prefix: "lib") ?? name)" + } + + let flags = searchPaths + frameworks + weakFrameworks + dylibs + return flags.isEmpty ? nil : flags + } + // MARK: Private func defines(project: Project) -> Starlark.Value { diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index 657e1bb..df8e269 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -144,6 +144,20 @@ struct TargetLoader { return wrapped.sdkDylibName ?? wrapped.name.flatMap { Path($0).lastComponentWithoutExtension } } + /// Xcode references system frameworks by absolute path; only their directory + /// matters for linking, and anything outside the default + /// `/System/Library/Frameworks` has to be handed to the linker explicitly. + let sdkFrameworkSearchPaths = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.isSDKFramework, let fullPath = wrapped.fullPath, fullPath.hasPrefix("/") else { + return nil + } + let directory = Path(fullPath).parent().string + guard directory != "/System/Library/Frameworks" else { return nil } + return directory + } + let packageProducts = (native.packageProductDependencies ?? []).map { dependency in XCode.PackageProductDependency( productName: dependency.productName, @@ -157,6 +171,7 @@ struct TargetLoader { frameworks: Set(frameworks.compactMap { $0 }).sorted(), sdkDylibs: Set(sdkDylibs.compactMap { $0 }).sorted(), sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted(), + sdkFrameworkSearchPaths: Set(sdkFrameworkSearchPaths).sorted(), weakSDKFrameworks: Set(weakSDKFrameworks.compactMap { $0 }).sorted()) } diff --git a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift index bbe44bb..20d52ce 100644 --- a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift +++ b/Sources/Xcode2/Model/Target/XCode+Dependencies.swift @@ -7,6 +7,10 @@ extension XCode { public let frameworks: [String] public let sdkDylibs: [String] public let sdkFrameworks: [String] + /// Directories holding the linked system frameworks, e.g. + /// `/System/Library/PrivateFrameworks`, which the linker does not search by + /// default. + public let sdkFrameworkSearchPaths: [String] public let weakSDKFrameworks: [String] } } @@ -18,6 +22,7 @@ extension XCode.Dependencies { case frameworks case sdkDylibs case sdkFrameworks + case sdkFrameworkSearchPaths case weakSDKFrameworks } @@ -28,6 +33,7 @@ extension XCode.Dependencies { try container.encodeIfPresent(frameworks.nonEmpty, forKey: .frameworks) try container.encodeIfPresent(sdkDylibs.nonEmpty, forKey: .sdkDylibs) try container.encodeIfPresent(sdkFrameworks.nonEmpty, forKey: .sdkFrameworks) + try container.encodeIfPresent(sdkFrameworkSearchPaths.nonEmpty, forKey: .sdkFrameworkSearchPaths) try container.encodeIfPresent(weakSDKFrameworks.nonEmpty, forKey: .weakSDKFrameworks) } } diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift index c16608b..fa48c22 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -135,6 +135,10 @@ extension XCode.Target { dependencies.weakSDKFrameworks } + public var frameworkSearchPathsSDK: [String] { + dependencies.sdkFrameworkSearchPaths + } + public var selectedSettings: XCode.BuildSettings { if let preferConfig, let settings = configs[preferConfig] { return settings diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift index 4faab94..4d1cc7f 100644 --- a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift +++ b/Tests/XCode2Tests/TargetSummaryFormatterTests.swift @@ -87,6 +87,7 @@ struct TargetSummaryFormatterTests { frameworks: ["//Prebuilt:SVProgressHUD"], sdkDylibs: [], sdkFrameworks: ["SwiftUI", "UIKit"], + sdkFrameworkSearchPaths: [], weakSDKFrameworks: [])) let project = XCode.Project( From 6045ae20c7e8454747305c73a88fe531789a5163 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:15:10 +0800 Subject: [PATCH 039/173] Match iina expectations to the pinned v1.4.4 checkout --- Tests/XCode2Tests/ProjectLoaderTests.swift | 2 +- Tests/XCode2Tests/RoadmapTreeBuilderTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift index 1d8fe0e..d43ea64 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -50,7 +50,7 @@ struct ProjectLoaderTests { let target = try #require(project.targets.first { $0.name == "iina-cli" }) #expect(target.prefer(\.platform.sdk) == .macOS) - #expect(target.prefer(\.platform.macOS) == "11") + #expect(target.prefer(\.platform.macOS) == "10.15") } @Test diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 1a49bcd..ed3f971 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -129,7 +129,7 @@ struct RoadmapTreeBuilderTests { let cliBuild = try String(contentsOfFile: (output + "Targets/iina-cli/BUILD").string) #expect(cliBuild.contains("module_name = \"iina_cli\"")) - #expect(cliBuild.contains("minimum_os_version = \"11\"")) + #expect(cliBuild.contains("minimum_os_version = \"10.15\"")) let pluginBuild = try String(contentsOfFile: (output + "Targets/iina-plugin/BUILD").string) #expect(pluginBuild.contains("module_name = \"iina_plugin\"")) @@ -153,7 +153,7 @@ struct RoadmapTreeBuilderTests { #expect(!appBuild.contains("additional_contents = {")) #expect(appBuild.contains("@swiftpkg_grmustache.swift//:Mustache")) #expect(appBuild.contains("macos_application(")) - #expect(appBuild.contains("minimum_os_version = \"11\"")) + #expect(appBuild.contains("minimum_os_version = \"10.15\"")) let nightlyOutput = Path(NSTemporaryDirectory()) + UUID().uuidString defer { try? nightlyOutput.delete() } From 68c8668058c571363695d474eccc5899ccd5083a Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:25:10 +0800 Subject: [PATCH 040/173] Glob app icons only from catalogs that define the icon set --- .../Codegen/Codegen+Application.swift | 23 ++++++++++++------- .../BazelizeKit/Codegen/Codegen+Plist.swift | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 5373c45..867e40c 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -67,7 +67,7 @@ extension Target { builder.call( Rules.Apple.IOS.Call.ios_application( name: name, - app_icons: appIcons, + app_icons: appIcons(project: kit.project), bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" @@ -96,7 +96,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_application( name: name, - app_icons: appIcons, + app_icons: appIcons(project: kit.project), bundle_id: prefer(\.metadata.bundleID), deps: .build { ":\(name)_library" @@ -132,12 +132,19 @@ extension Target { visibility: .public)) } - var appIcons: Starlark.Value? { - guard let iconName = prefer(\.assetCatalog.appIconName) else { return nil } - let iconGlobs = assets.map { asset in - "\(asset)/\(iconName).appiconset/**" + /// `app_icons` globs would fail analysis on a catalog without the icon set + /// (`glob` disallows empty matches), and targets commonly carry several + /// catalogs — SwiftUI previews add one. + func appIcons(project: Project?) -> Starlark.Value? { + guard let project, let iconName = prefer(\.assetCatalog.appIconName) else { return nil } + + let workspace = Path(project.workspacePath) + let iconGlobs = assets.compactMap { asset -> String? in + let relative = asset.delete(prefix: "Sources/") ?? asset + guard (workspace + relative + "\(iconName).appiconset").exists else { return nil } + return "\(asset)/\(iconName).appiconset/**" } - guard !iconGlobs.isEmpty else { return nil } - return Starlark.glob(iconGlobs) + + return iconGlobs.isEmpty ? nil : Starlark.glob(iconGlobs) } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index b50b58d..fd60cde 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -79,7 +79,7 @@ extension Target { private func plistContent(project: Project?) -> String? { guard let nodes = infoPlistNodes(project: project) else { return nil } - return Self.entries(nodes, dropping: appIcons == nil ? [] : Self.iconKeys) + return Self.entries(nodes, dropping: appIcons(project: project) == nil ? [] : Self.iconKeys) .withNewLine .replacingOccurrences(of: "$(PRODUCT_MODULE_NAME)", with: "$(PRODUCT_NAME)") .resolvingBuildSettingReferences(with: selectedSettings) From f456ea65680ef5e02fba4130fcd2a42d0ef0c6d4 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:25:11 +0800 Subject: [PATCH 041/173] Compile .intentdefinition files into the owning target --- Sources/BazelRules/Rules+Apple.swift | 93 +++++++++++++++++++ .../BazelizeKit/Codegen/Codegen+Intent.swift | 46 +++++++++ .../BazelizeKit/Codegen/Codegen+Target.swift | 1 + .../Codegen/Language/Codegen+Library.swift | 1 + .../Language/Codegen+SwiftLibrary.swift | 1 + 5 files changed, 142 insertions(+) create mode 100644 Sources/BazelizeKit/Codegen/Codegen+Intent.swift diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index da006cd..6721a0d 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -112,6 +112,7 @@ extension Rules { } case apple_bundle_import + case apple_intent_library case apple_core_data_model case apple_core_ml_library case apple_resource_bundle @@ -1136,6 +1137,98 @@ extension Rules.Apple.Resources { if let visibility { visibility } } } + + /// Builds a `swift_intent_library` target. + /// + /// Parameters: + /// - `name: String` + /// The Bazel target name. + /// - `src: Starlark.Label` + /// The `.intentdefinition` file to generate classes from. + /// - `class_prefix: String?` + /// Class prefix for the generated classes. + /// - `class_visibility: String?` + /// Swift visibility of the generated classes: `public`, `private` or `project`. + /// - `swift_version: String?` + /// Swift language version used for the generated classes. + /// - `testonly: Bool?` + /// Repo-local convenience for emitting Bazel's `testonly` attribute. + /// - `visibility: Starlark.Statement.Argument.Visibility?` + /// Repo-local convenience for emitting a `visibility` attribute. + public static func swift_intent_library( + name: String, + src: Starlark.Label, + class_prefix: String? = nil, + class_visibility: String? = nil, + swift_version: String? = nil, + testonly: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.Resources.swift_intent_library.call { + "name" => name + "src" => src + if let class_prefix { "class_prefix" => class_prefix } + if let class_visibility { "class_visibility" => class_visibility } + if let swift_version { "swift_version" => swift_version } + if let testonly { "testonly" => testonly } + if let visibility { visibility } + } + } + + /// Builds an `apple_intent_library` target. + /// + /// Unlike `swift_intent_library` this exposes the generated sources directly, + /// so they can be compiled into the module that uses them — which is how + /// Xcode treats an `.intentdefinition` belonging to a target. + /// + /// Parameters: + /// - `name: String` + /// The Bazel target name. + /// - `src: Starlark.Label` + /// The `.intentdefinition` file to generate classes from. + /// - `language: String` + /// `Swift` or `Objective-C`. + /// - `class_prefix: String?` + /// Class prefix for the generated classes. + /// - `class_visibility: String?` + /// Swift visibility of the generated classes: `public`, `private` or `project`. + /// - `header_name: String?` + /// Generated header name, required for Objective-C. + /// - `swift_version: String?` + /// Swift language version used for the generated classes. + /// - `tags: [String]?` + /// Bazel tags; the rule is meant to be built only through its consumer. + /// - `testonly: Bool?` + /// Repo-local convenience for emitting Bazel's `testonly` attribute. + /// - `visibility: Starlark.Statement.Argument.Visibility?` + /// Repo-local convenience for emitting a `visibility` attribute. + public static func apple_intent_library( + name: String, + src: Starlark.Label, + language: String, + class_prefix: String? = nil, + class_visibility: String? = nil, + header_name: String? = nil, + swift_version: String? = nil, + tags: [String]? = nil, + testonly: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.Resources.apple_intent_library.call { + "name" => name + "src" => src + "language" => language + if let class_prefix { "class_prefix" => class_prefix } + if let class_visibility { "class_visibility" => class_visibility } + if let header_name { "header_name" => header_name } + if let swift_version { "swift_version" => swift_version } + if let tags { "tags" => tags } + if let testonly { "testonly" => testonly } + if let visibility { visibility } + } + } } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Intent.swift b/Sources/BazelizeKit/Codegen/Codegen+Intent.swift new file mode 100644 index 0000000..cf4bd2f --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Intent.swift @@ -0,0 +1,46 @@ +import BazelRules +import Foundation +import PathKit +import Starlark + +/// `.intentdefinition` +/// +/// Xcode compiles intent definitions into the target's own module, so the +/// generated sources are fed straight into the target's library instead of +/// becoming a separate module the sources would have to import. +extension Target { + // MARK: Internal + + var intentDefinitions: [String] { + srcs.filter { $0.hasSuffix(".intentdefinition") } + } + + var intentSources: [Starlark.Label] { + intentDefinitions.map { definition in + .named(":\(Self.intentTargetName(for: definition))") + } + } + + func generateIntentLibraries(_ builder: CodeBuilder, _: Kit) { + guard !intentDefinitions.isEmpty else { return } + + builder.load(loadableRule: Rules.Apple.Resources.apple_intent_library) + + for definition in intentDefinitions { + builder.call( + Rules.Apple.Resources.Call.apple_intent_library( + name: Self.intentTargetName(for: definition), + src: .named(definition), + language: "Swift", + tags: ["manual"], + testonly: isTest, + visibility: .private)) + } + } + + // MARK: Private + + private static func intentTargetName(for definition: String) -> String { + "\(Path(definition).lastComponentWithoutExtension)_intent" + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 2e5ed57..25c4193 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -3,6 +3,7 @@ import Util extension Target { func generateCode(_ kit: Kit) -> String { let builder = CodeBuilder() + generateIntentLibraries(builder, kit) generateLibrary(builder, kit) generateLoadPlistFragment(builder, kit) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 17a1b00..35c773c 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -78,6 +78,7 @@ extension Target { swift_defines: defines(project: project), swift_srcs: .build { srcs_swift + intentSources }, weak_sdk_frameworks: weakFrameworksSDK, deps: .build { diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index c4d13e4..cbf69d1 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -26,6 +26,7 @@ extension Target { module_name: codegenModuleName, srcs: .build { srcs_swift + intentSources }, deps: .build { extraDeps From 5edf1b70774ce7aa2b9c1d16db19471a0b3b431d Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:25:11 +0800 Subject: [PATCH 042/173] Drop the test host dep when it is already a target dependency --- .../Codegen/Language/Codegen+SwiftLibrary.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index cbf69d1..0484f11 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -118,10 +118,16 @@ extension Target { /// TEST_HOST /// $(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example /// build/Debug-iphoneos/Example.app//Example - func applicationHost(project _: Project) -> String? { + func applicationHost(project: Project) -> String? { guard let host = prefer(\.testHost) else { return nil } guard let _ = prefer(\.bundleLoader) else { return nil } guard let targetName = host.components(separatedBy: "/").last else { return nil } - return "//Targets/\(targetName):\(targetName)_library" + + let label = "//Targets/\(targetName):\(targetName)_library" + /// A test target usually also declares the host as a target dependency, and + /// Bazel rejects a duplicated label in `deps`. + guard !linkedFrameworksLibrary(project: project).contains(where: { $0.value == label }) else { return nil } + + return label } } From 8f058e1330f55eb5df46d136afba30ef64800551 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:31 +0800 Subject: [PATCH 043/173] Generate macos_framework for macOS framework targets --- Sources/BazelRules/Rules+Apple.swift | 27 +++++++++++++++++ .../Codegen/Codegen+Framework.swift | 30 +++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index 6721a0d..b8d6105 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -46,6 +46,9 @@ extension Rules { case macos_bundle case macos_command_line_application case macos_extension + case macos_framework + case macos_static_framework + case macos_dynamic_framework case macos_ui_test case macos_unit_test @@ -460,6 +463,30 @@ extension Rules.Apple.MacOS { } } + /// Builds a `macos_framework` target. + public static func macos_framework( + name: String, + bundle_id: String? = nil, + bundle_name: String? = nil, + deps: Starlark.Value? = nil, + infoplists: Starlark.Value? = nil, + minimum_os_version: String? = nil, + resources: Starlark.Value? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.MacOS.macos_framework.call { + "name" => name + if let bundle_id { "bundle_id" => bundle_id } + if let bundle_name { "bundle_name" => bundle_name } + if let deps { "deps" => deps } + if let infoplists { "infoplists" => infoplists } + if let minimum_os_version { "minimum_os_version" => minimum_os_version } + if let resources { "resources" => resources } + if let visibility { visibility } + } + } + public static func macos_extension( name: String, bundle_id: String? = nil, diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index fe0720f..302da87 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -2,22 +2,48 @@ extension Target { func generateFrameworkCode(_ builder: CodeBuilder, _ kit: Kit) { + switch prefer(\.platform.sdk) { + case .macOS: buildMacFramework(builder, kit) + default: buildIOSFramework(builder, kit) + } + } + + private func buildIOSFramework(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.ios_framework) builder.call( Rules.Apple.IOS.Call.ios_framework( name: name, bundle_id: prefer(\.metadata.bundleID), + /// Only the target's own code: a sibling framework is linked through + /// its library, never nested inside this bundle. deps: .build { ":\(name)_library" - frameworks }, families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { - plist_file + plistFile(kit) plist_auto plistDefault(kit) }, minimum_os_version: prefer(\.platform.iOS), visibility: .public)) } + + private func buildMacFramework(_ builder: CodeBuilder, _ kit: Kit) { + builder.load(.macos_framework) + builder.call( + Rules.Apple.MacOS.Call.macos_framework( + name: name, + bundle_id: prefer(\.metadata.bundleID), + deps: .build { + ":\(name)_library" + }, + infoplists: .build { + plistFile(kit) + plist_auto + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.macOS), + visibility: .public)) + } } From 5e8ed4f4efa05bf7164597a5b2c51f9369c88d27 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:31 +0800 Subject: [PATCH 044/173] Import prebuilt .a and .dylib binaries with cc_import --- .../Bazel/Bazel+PrebuiltBUILD.swift | 34 +++++++++++++++++++ Sources/BazelizeKit/Bazel/CodeBuilder.swift | 4 +++ 2 files changed, 38 insertions(+) diff --git a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift index ddc0181..94f958f 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift @@ -30,8 +30,42 @@ extension Bazel { file.fileType == "wrapper.xcframework" } + let staticLibraries = imported.filter { file in + file.fileType == "archive.ar" + } + + let dynamicLibraries = imported.filter { file in + file.fileType == "compiled.mach-o.dylib" + } + buildFrameworks(frameworks) buildXCFrameworks(xcframeworks) + buildLibraries(staticLibraries, dynamicLibraries) + } + + /// Checked-in `.a`/`.dylib` binaries; `cc_import` is the only rule that takes + /// a bare library and still exposes it to Swift and Objective-C targets. + private func buildLibraries(_ staticLibraries: [XCode.File], _ dynamicLibraries: [XCode.File]) { + guard !staticLibraries.isEmpty || !dynamicLibraries.isEmpty else { return } + builder.load(.cc_import) + + for file in unique(staticLibraries) { + guard let path = file.path, !path.isEmpty else { continue } + builder.call( + Rules.Cc.Call.cc_import( + name: Path(path).lastComponentWithoutExtension, + static_library: .named(Path(path).lastComponent), + visibility: .public)) + } + + for file in unique(dynamicLibraries) { + guard let path = file.path, !path.isEmpty else { continue } + builder.call( + Rules.Cc.Call.cc_import( + name: Path(path).lastComponentWithoutExtension, + shared_library: .named(Path(path).lastComponent), + visibility: .public)) + } } private func buildXCFrameworks(_ files: [XCode.File]) { diff --git a/Sources/BazelizeKit/Bazel/CodeBuilder.swift b/Sources/BazelizeKit/Bazel/CodeBuilder.swift index 0833abc..48e2408 100644 --- a/Sources/BazelizeKit/Bazel/CodeBuilder.swift +++ b/Sources/BazelizeKit/Bazel/CodeBuilder.swift @@ -36,6 +36,10 @@ extension CodeBuilder { load(loadableRule: rule) } + func load(_ rule: Rules.Cc) { + load(loadableRule: rule) + } + func load(_ rule: Rules.Apple.IOS) { load(loadableRule: rule) } From 10dfbb56e133e58c86a9e064852011b5e00e0a71 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:31 +0800 Subject: [PATCH 045/173] Skip SwiftPM wiring for projects without packages --- Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 38a4bc7..3175330 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -53,6 +53,10 @@ final class PluginSwiftPM: PluginBuiltin { } override func module(_ builder: CodeBuilder) { + /// A project without Swift packages has no `Package.swift` to point at, and + /// the extension fails module resolution when the manifest is missing. + guard hasPackages else { return } + builder.bazel_dep( name: "rules_swift_package_manager", version: dep.rawValue) @@ -174,17 +178,22 @@ final class PluginSwiftPM: PluginBuiltin { } override var custom: [PluginBuiltin.Custom]? { - [package, packageResolved].compactMap { $0 } + guard hasPackages else { return nil } + return [package, packageResolved].compactMap { $0 } } override var tip: String? { - if remotes.isEmpty, locals.isEmpty { return nil } + guard hasPackages else { return nil } return """ # rules_swift_package_manager After bazelize, run `swift package update` and `bazel mod tidy`. """ } + private var hasPackages: Bool { + !remotes.isEmpty || !locals.isEmpty + } + private var packageRepositories: [String] { let remoteRepos = remotes.compactMap(\.repositoryURL).map(Self.repositoryName(url:)) let localRepos = locals.map(\.relativePath).map(Self.repositoryName(path:)) From 1776e2c49b856fe06f9032f2359860e6337f9ff5 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:42 +0800 Subject: [PATCH 046/173] Resolve Xcode's built-in SRCROOT style settings --- Sources/Xcode2/Loader/XCode+TargetLoader.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index df8e269..a91a6bb 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -16,9 +16,15 @@ struct TargetLoader { self.project = project preferConfig = project.preferConfig configList = ConfigListLoader(native: native.buildConfigurationList, sourceRoot: project.workspacePath) + /// Xcode's built-in settings never appear in the project file, but build + /// settings reference them freely (`INFOPLIST_FILE = $(SRCROOT)/...`). + let workspace = project.workspacePath.string mergedConfig = configList.merge(defaultConfigList).mapValues { settings in settings.with(overrides: [ "TARGET_NAME": native.name, + "SRCROOT": workspace, + "SOURCE_ROOT": workspace, + "PROJECT_DIR": workspace, ]) } } From 123e20cbf4f8bd07314214a5095417bdf8a482a7 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:42 +0800 Subject: [PATCH 047/173] Emit plist fragments only when their content exists --- .../Codegen/CodeGen+Extension.swift | 2 +- .../Codegen/Codegen+Application.swift | 10 +-- .../BazelizeKit/Codegen/Codegen+Plist.swift | 84 +++++++++++++------ 3 files changed, 66 insertions(+), 30 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index ae64548..f446026 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -32,7 +32,7 @@ extension Target { entitlements: entitlementsLabel, families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { - plist_file + plistFile(kit) plist_auto plistDefault(kit) }, diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 867e40c..f80e08a 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -29,7 +29,7 @@ extension Target { ":\(name)_library" }, infoplists: .build { - plist_file + plistFile(kit) plist_auto plistDefault(kit) }, @@ -50,7 +50,7 @@ extension Target { frameworks }, infoplists: .build { - plist_file + plistFile(kit) plist_auto plistDefault(kit) }, @@ -76,7 +76,7 @@ extension Target { extensions: embeddedExtensions(project: project), families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { - plist_file + plistFile(kit) plist_auto plistDefault(kit) }, @@ -102,7 +102,7 @@ extension Target { ":\(name)_library" }, infoplists: .build { - plist_file + plistFile(kit) plist_auto plistDefault(kit) }, @@ -121,7 +121,7 @@ extension Target { frameworks }, infoplists: .build { - plist_file + plistFile(kit) plist_auto plistDefault(kit) }, diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index fd60cde..63a1d7a 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -28,11 +28,10 @@ extension Target { extension Target { // MARK: Internal - var plist_file: Starlark.Label? { - if configs.values.contains(where: { $0.plist.infoPlist != nil }) { - return ":plist_file" - } - return nil + /// Mirrors `generatePlistFile`: the label has to disappear when the file is + /// missing or unreadable, otherwise the rule references a target nobody emits. + func plistFile(_ kit: Kit) -> Starlark.Label? { + plistContent(project: kit.project) == nil ? nil : ":plist_file" } func generatePlistFile(_ builder: CodeBuilder, _ kit: Kit) { @@ -170,34 +169,39 @@ extension String { extension Target { // MARK: Internal + /// `INFOPLIST_KEY_*` settings only reach the bundle when Xcode generates the + /// `Info.plist`; with a checked-in file they are ignored, and emitting them + /// anyway makes `plisttool` fail on keys the file already defines. var plist_auto: Starlark.Label? { - configs.values.contains(where: { !$0.generatedPlist.entries.isEmpty }) ? ":plist_auto" : nil + hasGeneratedPlistEntries ? ":plist_auto" : nil } func generatePlistAuto(_ builder: CodeBuilder, _: Kit) { - let settings = selectedSettings - let plist = settings.generatedPlist.entries - if !plist.isEmpty { - builder.call( - Rules.Plist.Call.plist_fragment( - name: "plist_auto", - ext: "plist", - template: Starlark.custom(""" - ''' - \(plist.withNewLine) - ''' - """), - visibility: .private)) - } + guard hasGeneratedPlistEntries else { return } + + builder.call( + Rules.Plist.Call.plist_fragment( + name: "plist_auto", + ext: "plist", + template: Starlark.custom(""" + ''' + \(selectedSettings.generatedPlist.entries.withNewLine) + ''' + """), + visibility: .private)) } // MARK: Private - private func isGeneratePlistAuto(project: Project?) -> Bool { - guard project != nil else { return false } + private var hasGeneratedPlistEntries: Bool { let settings = selectedSettings return settings.generatedPlist.enabled && !settings.generatedPlist.entries.isEmpty } + + private func isGeneratePlistAuto(project: Project?) -> Bool { + guard project != nil else { return false } + return hasGeneratedPlistEntries + } } @@ -238,6 +242,38 @@ extension Target { return !defaultPlistFragments(for: selectedSettings).isEmpty } + /// `plisttool` substitutes only a handful of variables, so a default whose value + /// it cannot resolve has to be dropped: `macos_command_line_application` has no + /// bundled executable, and `PRODUCT_BUNDLE_PACKAGE_TYPE` is never substituted. + private var unsupportedDefaultPlistKeys: Set { + var keys: Set = [] + if productType == "com.apple.product-type.tool" { + keys.insert("CFBundleExecutable") + } + if bundlePackageType == nil { + keys.insert("CFBundlePackageType") + } + return keys + } + + /// The value Xcode derives for `PRODUCT_BUNDLE_PACKAGE_TYPE`. + private var bundlePackageType: String? { + switch productType { + case "com.apple.product-type.application": + return "APPL" + case "com.apple.product-type.framework", + "com.apple.product-type.framework.static": + return "FMWK" + case "com.apple.product-type.bundle", + "com.apple.product-type.bundle.unit-test", + "com.apple.product-type.bundle.ui-testing", + "com.apple.product-type.app-extension": + return "BNDL" + default: + return nil + } + } + /// The target's own `Info.plist` is the source of truth Xcode uses, so a /// default derived from build settings must not restate those keys: `plisttool` /// rejects two fragments that disagree on one key. @@ -251,13 +287,13 @@ extension Target { ("CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"), ("CFBundleVersion", settings.generatedPlist.currentProjectVersion ?? "$(CURRENT_PROJECT_VERSION)"), ("CFBundleExecutable", "$(EXECUTABLE_NAME)"), - ("CFBundlePackageType", "$(PRODUCT_BUNDLE_PACKAGE_TYPE)"), + ("CFBundlePackageType", bundlePackageType ?? ""), ("CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"), ("CFBundleShortVersionString", settings.generatedPlist.marketingVersion ?? "$(MARKETING_VERSION)"), ] return defaults - .filter { key, _ in !existing.contains(key) } + .filter { key, _ in !existing.contains(key) && !unsupportedDefaultPlistKeys.contains(key) } .map { key, value in """ \(key) From fb0031ced5fbbe94fb5bc17050be0a9f5669bb7d Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:49 +0800 Subject: [PATCH 048/173] Mirror Xcode's header map with module and internal headers --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 91 +++++++++++++++++++ .../Codegen/Language/Codegen+Library.swift | 13 ++- .../Language/Codegen+ObjcLibrary.swift | 13 +-- .../Roadmap/BazelizeKit+Roadmap.swift | 23 +++++ .../Xcode2/Model/Target/XCode+Target.swift | 18 ++++ 5 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 Sources/BazelizeKit/Codegen/Codegen+Headers.swift diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift new file mode 100644 index 0000000..0b09871 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -0,0 +1,91 @@ +import Foundation +import PathKit +import XCode2 + +/// Headers Xcode resolves through its implicit header map. +/// +/// Xcode builds a header map covering every header in the target, so +/// `#import "Other.h"` works no matter which directory the header lives in and +/// whether it belongs to a build phase at all. Bazel resolves includes by path, +/// so those headers have to be declared as inputs and their directories exposed +/// as include paths. +extension Target { + // MARK: Internal + + /// Headers that belong in the module: Xcode only publishes the ones flagged + /// `Public` or `Private` in the Headers phase, and modularizing the rest breaks + /// the module build (project headers routinely pull in C++ or private SDK code). + func moduleHeaderFiles(project _: Project) -> [String] { + let exported = exportedHeaders + /// Targets that publish nothing still need their headers reachable, so fall + /// back to treating them all as module headers. + return exported.isEmpty ? Array(Set(headers)).sorted() : exported.sorted() + } + + /// Headers that are compile inputs only. + func internalHeaderFiles(project: Project) -> [String] { + let module = Set(moduleHeaderFiles(project: project)) + let siblings = siblingHeaderPaths(project: project).map { "Sources/\($0)" } + return Array(Set(projectHeaders + siblings).subtracting(module)).sorted() + } + + func headerIncludes(project: Project) -> [String] { + let all = moduleHeaderFiles(project: project) + internalHeaderFiles(project: project) + let directories = all.map { header in + Path(header).parent().string + } + + /// "." keeps a public header reachable by its own relative path. + /// https://github.com/bazelbuild/bazel/issues/92 + return Array(Set(directories + ["."])).sorted() + } + + /// Workspace-relative headers that sit next to the target's compiled sources. + func siblingHeaderPaths(project: Project) -> [String] { + let workspace = Path(project.workspacePath) + + return sourceDirectories.flatMap { directory -> [String] in + let sourceDirectory = directory.isEmpty ? workspace : workspace + directory + guard sourceDirectory.isDirectory, let children = try? sourceDirectory.children() else { + return [] + } + + return children + .filter(\.isHeader) + .map { child in + directory.isEmpty ? child.lastComponent : "\(directory)/\(child.lastComponent)" + } + } + } + + /// Bazel picks the clang dialect from the file extension, Xcode from the + /// declared file type. When every C-family source in the target is + /// Objective-C++ but not named `.mm`, the dialect has to be forced. + var clangDialectCopts: [String] { + let clangSources = srcs_c + srcs_cpp + srcs_objc + srcs_objcpp + guard !clangSources.isEmpty else { return [] } + guard srcs_objcpp.count == clangSources.count else { return [] } + guard srcs_objcpp.contains(where: { !$0.hasSuffix(".mm") }) else { return [] } + + return ["-x", "objective-c++"] + } + + // MARK: Private + + /// Workspace-relative directories holding the target's compiled sources. + private var sourceDirectories: Set { + Set( + srcs.map { source in + let relative = source.delete(prefix: "Sources/") ?? source + let directory = Path(relative).parent().string + return directory == "." ? "" : directory + }) + } +} + +extension Path { + var isHeader: Bool { + guard let ext = `extension`?.lowercased() else { return false } + return ["h", "hh", "hpp", "hxx"].contains(ext) + } +} diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 35c773c..8f390d9 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -54,12 +54,13 @@ extension Target { "-fobjc-arc", "-fPIC", "-fmodule-name=\(codegenModuleName)", - ], + ] + clangDialectCopts, clang_srcs: .build { srcs_c srcs_cpp srcs_objc srcs_objcpp + internalHeaderFiles(project: project) }, data: .build { if !assets.isEmpty { @@ -69,12 +70,17 @@ extension Target { storyboards }, hdrs: .build { - headers - hpps + moduleHeaderFiles(project: project) + /// A mixed target gets the bridging header's declarations through + /// its own clang module: `swiftc` rejects `-import-objc-header` + /// while building a module. + bridgingHeader }, + includes: headerIncludes(project: project), module_name: codegenModuleName, sdk_dylibs: dylibsSDK, sdk_frameworks: frameworksSDK, + swift_copts: moduleSwiftCopts, swift_defines: defines(project: project), swift_srcs: .build { srcs_swift @@ -95,4 +101,5 @@ extension Target { actual: .named("\(name)_mixed"), visibility: .public)) } + } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 46b1814..9e5b466 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -12,7 +12,8 @@ import Starlark // TODO: https://github.com/XCodeBazelize/Bazelize/issues/7 extension Target { - func generateObjcLibrary(_ builder: CodeBuilder, _: Kit, aliasPublic: Bool = true) { + func generateObjcLibrary(_ builder: CodeBuilder, _ kit: Kit, aliasPublic: Bool = true) { + let project = kit.project builder.load(.objc_library) /// "enable_modules" => select(\.enableModules).starlark builder.call( @@ -23,11 +24,11 @@ extension Target { srcs_cpp srcs_objc srcs_objcpp + internalHeaderFiles(project: project) }, hdrs: .build { // FIXME: (@yume190) TODO: pch - headers - hpps + moduleHeaderFiles(project: project) }, deps: .build { frameworksLibrary @@ -38,11 +39,7 @@ extension Target { "-fPIC", "-fmodule-name=\(codegenModuleName)", ], - includes: [ - /// public header "." - /// https://github.com/bazelbuild/bazel/issues/92 - ".", - ], + includes: headerIncludes(project: project), module_name: codegenModuleName, sdk_dylibs: dylibsSDK, sdk_frameworks: frameworksSDK, diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 57b4173..b6690f1 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -51,6 +51,29 @@ extension Bazel { materializedDirectories.insert(normalizedPath) } } + + try prepareSiblingHeaders(target: target, project: project, sourcesRoot: sourcesRoot) + } + + /// Xcode's implicit header map makes every header in the target reachable by + /// file name, even when it belongs to no build phase. Bazel needs the file + /// declared, so headers next to the target's compiled sources come along. + private func prepareSiblingHeaders( + target: Target, + project: Project, + sourcesRoot: Path) throws + { + let workspace = Path(project.workspacePath) + + for relativePath in target.siblingHeaderPaths(project: project) { + let source = workspace + relativePath + guard source.exists, !source.isSelfReferentialSymlink else { continue } + + let destination = sourcesRoot + relativePath + guard !destination.exists, !destination.isSymlink else { continue } + + try materialize(source: source, destination: destination) + } } private func preparePrebuiltFiles(project: XCode2.XCode.Project) throws { diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift index fa48c22..f8e7359 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -59,6 +59,24 @@ extension XCode.Target { filePaths(files.headers) } + /// Headers Xcode copies into the product, i.e. the ones that end up in the + /// module Swift and dependents import. + public var exportedHeaders: [String] { + filePaths( + files.headers.filter { header in + header.attributes.contains("Public") || header.attributes.contains("Private") + }) + } + + /// Headers that stay internal to the target: reachable while compiling its own + /// sources, never part of the module. + public var projectHeaders: [String] { + filePaths( + files.headers.filter { header in + !header.attributes.contains("Public") && !header.attributes.contains("Private") + }) + } + public var hpps: [String] { headers.filter { $0.hasSuffix(".hpp") || $0.hasSuffix(".hh") || $0.hasSuffix(".hxx") } } From 3b09626d3d1e582fc361b219a496c9c049a28138 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:49 +0800 Subject: [PATCH 049/173] Classify sources and libraries by declared Xcode file type --- Sources/Xcode2/Loader/XCode+FileLoader.swift | 36 +++++++++++++------ .../Xcode2/Loader/XCode+TargetLoader.swift | 4 +-- .../Xcode2/Model/Target/XCode+Target.swift | 35 ++++++++++++++---- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index e18446c..c56acf7 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -15,6 +15,8 @@ enum KnownFileType: String { case metal = "sourcecode.metal" case staticLibrary = "archive.ar" case dynamicLibrary = "compiled.mach-o.dylib" + /// Text-based dylib stub, e.g. `/usr/lib/libIOReport.tbd`. + case dylibStub = "sourcecode.text-based-dylib-definition" case xib = "file.xib" case storyboard = "file.storyboard" case xcassets = "folder.assetcatalog" @@ -36,6 +38,7 @@ enum KnownFileType: String { case "metal": self = .metal case "a": self = .staticLibrary case "dylib": self = .dynamicLibrary + case "tbd": self = .dylibStub case "xib": self = .xib case "storyboard": self = .storyboard case "xcassets": self = .xcassets @@ -75,7 +78,9 @@ struct FileLoader { } var fileType: String? { - ref?.lastKnownFileType ?? ref?.explicitFileType + /// `explicitFileType` is the override Xcode compiles with, e.g. a `.m` file + /// declared as Objective-C++. + ref?.explicitFileType ?? ref?.lastKnownFileType } var sourceTree: String { @@ -116,19 +121,28 @@ struct FileLoader { } var isSDKFramework: Bool { - sourceTree == PBXSourceTree.sdkRoot.description || + /// A framework reference is a bundle; the same source trees also hold + /// dylibs and `.tbd` stubs, which link completely differently. + guard typedFileType == .framework || typedFileType == .xcframework else { return false } + + return sourceTree == PBXSourceTree.sdkRoot.description || sourceTree == PBXSourceTree.developerDir.description || fullPath?.hasPrefix("/System/Library/Frameworks/") == true || fullPath?.hasPrefix("/System/Library/PrivateFrameworks/") == true } var isSDKDylib: Bool { - typedFileType == .dynamicLibrary && ( - sourceTree == PBXSourceTree.sdkRoot.description || - sourceTree == PBXSourceTree.developerDir.description || - fullPath?.hasPrefix("/usr/lib/") == true || - fullPath?.hasPrefix("/System/iOSSupport/usr/lib/") == true - ) + guard typedFileType == .dynamicLibrary || typedFileType == .dylibStub else { return false } + + return sourceTree == PBXSourceTree.sdkRoot.description || + sourceTree == PBXSourceTree.developerDir.description || + fullPath?.hasPrefix("/usr/lib/") == true || + fullPath?.hasPrefix("/System/iOSSupport/usr/lib/") == true + } + + /// A dylib or its text-based stub, both linked with `-l`. + var isDylibLike: Bool { + typedFileType == .dynamicLibrary || typedFileType == .dylibStub } private var ref: PBXFileReference? { @@ -152,7 +166,7 @@ struct FileLoader { if buildPhase == BuildPhase.frameworks.rawValue, canUsePrebuiltLabel, - typedFileType != .dynamicLibrary, + !isDylibLike, !isSDKFramework, !isSDKDylib { @@ -241,14 +255,14 @@ extension KnownFileType { return .header case .xib, .storyboard, .xcassets, .strings, .stringsdict, .plist: return .resource - case .staticLibrary, .dynamicLibrary, .xcframework, .framework: + case .staticLibrary, .dynamicLibrary, .dylibStub, .xcframework, .framework: return .binary } } fileprivate var isBinaryArtifact: Bool { switch self { - case .staticLibrary, .dynamicLibrary, .xcframework, .framework: + case .staticLibrary, .dynamicLibrary, .dylibStub, .xcframework, .framework: return true default: return false diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index a91a6bb..38349dd 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -111,7 +111,7 @@ struct TargetLoader { guard let file = buildFile.file else { return nil } let wrapped = FileLoader(native: file, project: project) guard !wrapped.isSDKFramework, !wrapped.isSDKDylib else { return nil } - guard wrapped.fileType != "compiled.mach-o.dylib" else { return nil } + guard !wrapped.isDylibLike else { return nil } if let identity = wrapped.frameworkIdentity, targetDependencyIdentities.contains(identity) { return nil @@ -146,7 +146,7 @@ struct TargetLoader { let sdkDylibs = frameworkBuildFiles.compactMap { buildFile -> String? in guard let file = buildFile.file else { return nil } let wrapped = FileLoader(native: file, project: project) - guard wrapped.fileType == KnownFileType.dynamicLibrary.rawValue else { return nil } + guard wrapped.isDylibLike else { return nil } return wrapped.sdkDylibName ?? wrapped.name.flatMap { Path($0).lastComponentWithoutExtension } } diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift index f8e7359..ba0192e 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -86,29 +86,52 @@ extension XCode.Target { } public var srcs_c: [String] { - srcs.filter { $0.hasSuffix(".c") } + sources(ofType: "sourcecode.c.c", extensions: [".c"]) } public var srcs_objc: [String] { - srcs.filter { $0.hasSuffix(".m") } + sources(ofType: "sourcecode.c.objc", extensions: [".m"]) } public var srcs_cpp: [String] { - srcs.filter { [".cc", ".cp", ".cpp", ".cxx"].contains(where: $0.hasSuffix) } + sources(ofType: "sourcecode.cpp.cpp", extensions: [".cc", ".cp", ".cpp", ".cxx"]) } public var srcs_objcpp: [String] { - srcs.filter { $0.hasSuffix(".mm") } + sources(ofType: "sourcecode.cpp.objcpp", extensions: [".mm"]) } public var srcs_swift: [String] { - srcs.filter { $0.hasSuffix(".swift") } + sources(ofType: "sourcecode.swift", extensions: [".swift"]) } public var srcs_metal: [String] { - srcs.filter { $0.hasSuffix(".metal") } + sources(ofType: "sourcecode.metal", extensions: [".metal"]) } + /// Xcode compiles by declared file type, which can disagree with the extension + /// (`explicitFileType = sourcecode.cpp.objcpp` on a `.m` file is common for + /// Objective-C code that includes C++). + private func sources(ofType type: String, extensions: [String]) -> [String] { + filePaths( + files.sources.filter { file in + if let fileType = file.fileType, Self.compiledFileTypes.contains(fileType) { + return fileType == type + } + guard let path = file.path else { return false } + return extensions.contains { path.hasSuffix($0) } + }) + } + + private static let compiledFileTypes: Set = [ + "sourcecode.c.c", + "sourcecode.c.objc", + "sourcecode.cpp.cpp", + "sourcecode.cpp.objcpp", + "sourcecode.swift", + "sourcecode.metal", + ] + public var resources: [String] { filePaths(files.resources) } From e94dac1a33f30dcab2c13bbdc36a30bba545829d Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:48:49 +0800 Subject: [PATCH 050/173] Compile non-executable targets with parse-as-library --- .../Language/Codegen+SwiftLibrary.swift | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 0484f11..51a89cc 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -22,7 +22,7 @@ extension Target { builder.call( Rules.Swift.Call.swift_library( name: "\(name)_swift", - copts: bridgingHeaderCopts, + copts: swiftCopts, module_name: codegenModuleName, srcs: .build { srcs_swift @@ -72,6 +72,36 @@ extension Target { return ["-import-objc-header", "$(location \(bridgingHeader))"] } + /// Swift compiles a file named `main.swift` as top-level code and emits a `main` + /// symbol. Xcode only does that for executables, so anything else — a framework + /// with a `main.swift` is common — has to be parsed as a library. + var parseAsLibraryCopts: [String] { + switch productType { + case "com.apple.product-type.application", + "com.apple.product-type.tool": + return [] + default: + break + } + + guard srcs_swift.contains(where: { $0.hasSuffix("/main.swift") || $0 == "main.swift" }) else { + return [] + } + + return ["-parse-as-library"] + } + + var swiftCopts: [String]? { + let copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts + return copts.isEmpty ? nil : copts + } + + /// Same flags minus the bridging header: a mixed-language target exposes those + /// declarations through its own clang module instead. + var moduleSwiftCopts: [String]? { + parseAsLibraryCopts.isEmpty ? nil : parseAsLibraryCopts + } + /// `swift_library` has no `sdk_frameworks`, so system frameworks and dylibs from /// the target's Frameworks phase are linked through raw linker flags. var sdkLinkopts: [String]? { From 9425002488616c3a15edb7757220c6517df383ce Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:55:08 +0800 Subject: [PATCH 051/173] Normalize build setting paths before using them as labels --- .../BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift | 6 +++++- Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 51a89cc..8622fc2 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -1,3 +1,5 @@ +import PathKit + extension Target { // MARK: Internal @@ -64,7 +66,9 @@ extension Target { /// straight to the compiler and declared as a `swiftc_inputs` file. var bridgingHeader: String? { guard let header = prefer(\.bridgingHeader), !header.isEmpty, !header.hasPrefix("/") else { return nil } - return "Sources/\(header)" + /// Build settings carry paths like `./Target/Bridge.h`, which Bazel rejects + /// as a label. + return "Sources/\(Path(header).normalize().string)" } var bridgingHeaderCopts: [String]? { diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index b6690f1..f35bfac 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -166,6 +166,7 @@ extension XCode2.XCode.Target { ] .compactMap { $0 } .filter { !$0.isEmpty && !$0.hasPrefix("/") } + .map { Path($0).normalize().string } } } From d1a43f7c0db887dfda70d05fc2a6362509d07aa3 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 13:55:08 +0800 Subject: [PATCH 052/173] Generate the SwiftPM manifest with tools version 6.0 --- Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 3175330..744ad59 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -150,7 +150,7 @@ final class PluginSwiftPM: PluginBuiltin { return .init( path: "Package.swift", content: """ - // swift-tools-version: 5.7 + // swift-tools-version: 6.0 import PackageDescription let package = Package( From ad9a84d50ef07a069fb936d1075ef24eb334e12e Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 14:17:24 +0800 Subject: [PATCH 053/173] Keep app icon sets out of the asset resources filegroup --- .../Codegen/Resource/Codegen+Asset.swift | 29 ++++++++++--------- .../Starlark/Value/Starlark+Value.swift | 16 +++++----- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift index 0453399..411d13c 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift @@ -11,28 +11,31 @@ extension Target { /// "Base.lproj/Main.storyboard", /// "Base.lproj/LaunchScreen.storyboard", /// ], - func generateAssets(_ builder: CodeBuilder, _: Kit) { + func generateAssets(_ builder: CodeBuilder, _ kit: Kit) { /// //Example:Assets.xcassets /// to /// Assets.xcassets/** - let files = assets - .map { label in - "\(label)/**" - } - .map { (label: String) in -// if label.hasPrefix("//:") { -// return label.replacingOccurrences(of: "//:", with: "") -// } - // TODO: glob can't use `../` - label - } + let files = assets.map { label in + "\(label)/**" + } guard !files.isEmpty else { return } builder.call( Rules.Builtin.Call.filegroup( name: "Assets", - srcs: Starlark.glob(files), + srcs: Starlark.glob(files, exclude: appIconExcludes(kit)), visibility: .private)) } + + /// App icons reach the bundle through the rule's `app_icons` attribute. Leaving + /// them in the resources too makes rules_apple reject the catalog: it accepts + /// exactly one `*.appiconset`, while Xcode projects routinely ship several and + /// pick one with `ASSETCATALOG_COMPILER_APPICON_NAME`. + private func appIconExcludes(_ kit: Kit) -> [String] { + guard appIcons(project: kit.project) != nil else { return [] } + return assets.map { label in + "\(label)/*.appiconset/**" + } + } } diff --git a/Sources/Starlark/Starlark/Value/Starlark+Value.swift b/Sources/Starlark/Starlark/Value/Starlark+Value.swift index 0b8f1f8..983495d 100644 --- a/Sources/Starlark/Starlark/Value/Starlark+Value.swift +++ b/Sources/Starlark/Starlark/Value/Starlark+Value.swift @@ -9,8 +9,8 @@ extension Starlark { .custom(value) } - public static func glob(_ files: [String]) -> Value { - .glob(files) + public static func glob(_ files: [String], exclude: [String] = []) -> Value { + .glob(files, exclude: exclude) } public indirect enum Value: Sendable, Text { @@ -21,7 +21,7 @@ extension Starlark { case array([Value]) case dictionary([String: Value]) case select(Starlark.Select) - case glob([String]) + case glob([String], exclude: [String]) case custom(String) case none @@ -99,11 +99,13 @@ extension Starlark { return value ? "True" : "False" case .select(let value): return value.text - case .glob(let files): + case .glob(let files, let exclude): let asset = Value(files.sorted()) ?? .none - return """ - glob(\(asset.text)) - """ + guard !exclude.isEmpty else { + return "glob(\(asset.text))" + } + let excluded = Value(exclude.sorted()) ?? .none + return "glob(\(asset.text), exclude = \(excluded.text))" case .custom(let value): return value case .none: From 23b247feb43a65bd570ec17486fa21cd7b249055 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 14:17:24 +0800 Subject: [PATCH 054/173] Drop Info.plist entries plisttool cannot resolve --- .../BazelizeKit/Codegen/Codegen+Plist.swift | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index 63a1d7a..d013df2 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -9,6 +9,7 @@ import BazelRules import Foundation import PathKit import Starlark +import Util extension Target { func generateLoadPlistFragment(_ builder: CodeBuilder, _ kit: Kit) { @@ -78,10 +79,8 @@ extension Target { private func plistContent(project: Project?) -> String? { guard let nodes = infoPlistNodes(project: project) else { return nil } - return Self.entries(nodes, dropping: appIcons(project: project) == nil ? [] : Self.iconKeys) - .withNewLine - .replacingOccurrences(of: "$(PRODUCT_MODULE_NAME)", with: "$(PRODUCT_NAME)") - .resolvingBuildSettingReferences(with: selectedSettings) + let dropped = appIcons(project: project) == nil ? [] : Self.iconKeys + return entries(nodes, dropping: dropped).withNewLine } /// `macos_application`/`ios_application` derive these from `app_icons`, and @@ -92,31 +91,49 @@ extension Target { "CFBundleIconName", ] - /// The plist `dict` is a flat ``/value sequence, so dropping a key means - /// dropping the element that follows it too. - private static func entries(_ nodes: [XMLNode], dropping keys: Set) -> [String] { + /// The plist `dict` is a flat ``/value sequence, so a dropped key takes the + /// element that follows it with it. + /// + /// Entries whose value still references an unresolvable build setting are + /// dropped as well: `plisttool` fails the build on a variable it cannot + /// substitute, e.g. Xcode built-ins like `$(SDK_VERSION)`. + private func entries(_ nodes: [XMLNode], dropping keys: Set) -> [String] { + let settings = selectedSettings var result: [String] = [] - var skipValue = false + var pendingKey: (name: String, xml: String)? for node in nodes { guard let element = node as? XMLElement else { continue } + element.detach() + + let xml = element + .xmlString(options: [.nodePrettyPrint, .nodePreserveAll]) + .replacingOccurrences(of: "$(PRODUCT_MODULE_NAME)", with: "$(PRODUCT_NAME)") + .resolvingBuildSettingReferences(with: settings) - if skipValue { - skipValue = false + if element.name == "key" { + pendingKey = (element.stringValue ?? "", xml) continue } - if - element.name == "key", - let key = element.stringValue, - keys.contains(key) - { - skipValue = true + guard let key = pendingKey else { + result.append(xml) continue } + pendingKey = nil - element.detach() - result.append(element.xmlString(options: [.nodePrettyPrint, .nodePreserveAll])) + guard !keys.contains(key.name) else { continue } + + if xml.hasUnresolvedBuildSettingReference { + Log.codeGenerate.warning(""" + Drop Info.plist key \(key.name, privacy: .public) of \ + \(name, privacy: .public): unresolved build setting reference + """) + continue + } + + result.append(key.xml) + result.append(xml) } return result @@ -161,6 +178,17 @@ extension String { return result } + + /// `$(SETTING)` references left after resolution, excluding the ones + /// `plisttool` substitutes itself. + fileprivate var hasUnresolvedBuildSettingReference: Bool { + guard let regex = try? NSRegularExpression(pattern: #"\$\(([A-Za-z0-9_]+)\)"#) else { return false } + + return regex.matches(in: self, range: NSRange(startIndex..., in: self)).contains { match in + guard let keyRange = Range(match.range(at: 1), in: self) else { return false } + return !Self.plistToolVariables.contains(String(self[keyRange])) + } + } } /// plist_auto From a2cae521d146bb44e2f4ad3c070fd07898d7b711 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 14:17:24 +0800 Subject: [PATCH 055/173] Honor HEADER_SEARCH_PATHS when generating libraries --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 60 ++++++++++++++++++- .../Language/Codegen+SwiftLibrary.swift | 17 ++++-- .../Roadmap/BazelizeKit+Roadmap.swift | 6 +- .../Model/Config/XCode+BuildSettings.swift | 17 ++++++ 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 0b09871..7cc0bcf 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -26,13 +26,38 @@ extension Target { func internalHeaderFiles(project: Project) -> [String] { let module = Set(moduleHeaderFiles(project: project)) let siblings = siblingHeaderPaths(project: project).map { "Sources/\($0)" } - return Array(Set(projectHeaders + siblings).subtracting(module)).sorted() + let searched = searchPathHeaderFiles(project: project) + return Array(Set(projectHeaders + siblings + searched).subtracting(module)).sorted() + } + + /// Headers reachable only through `HEADER_SEARCH_PATHS`. + /// + /// Bazel sandboxes compile actions, so an include path is useless unless the + /// headers behind it are declared inputs. + func searchPathHeaderFiles(project: Project) -> [String] { + let workspace = Path(project.workspacePath) + + return headerSearchPaths(project: project).flatMap { directory -> [String] in + let root = workspace + directory + guard root.isDirectory, let children = try? root.recursiveChildren() else { return [] } + + return children + .filter(\.isHeader) + .compactMap { child -> String? in + let absolute = child.absolute().string + let prefix = root.absolute().string + "/" + guard absolute.hasPrefix(prefix) else { return nil } + return "Sources/\(directory)/\(absolute.dropFirst(prefix.count))" + } + } } func headerIncludes(project: Project) -> [String] { let all = moduleHeaderFiles(project: project) + internalHeaderFiles(project: project) let directories = all.map { header in Path(header).parent().string + } + headerSearchPaths(project: project).map { path in + "Sources/\(path)" } /// "." keeps a public header reachable by its own relative path. @@ -40,6 +65,39 @@ extension Target { return Array(Set(directories + ["."])).sorted() } + /// The same include paths, spelled for `swiftc`'s clang importer. + /// + /// Bazel resolves the `includes` attribute relative to the package, raw `-I` + /// flags relative to the execution root. + func swiftIncludeCopts(project: Project) -> [String] { + headerIncludes(project: project) + .filter { $0 != "." } + .flatMap { directory in + ["-Xcc", "-ITargets/\(name)/\(directory)"] + } + } + + /// Workspace-relative `HEADER_SEARCH_PATHS` entries. + /// + /// Xcode resolves them against the project; anything outside the workspace + /// cannot be materialized into the target tree and is dropped. + func headerSearchPaths(project: Project) -> [String] { + let workspace = Path(project.workspacePath).absolute().string + + return (prefer(\.headerSearchPaths) ?? []).compactMap { path -> String? in + let normalized = Path(path).normalize().string + guard normalized != "." else { return nil } + + if !normalized.hasPrefix("/") { + return normalized + } + + let prefix = workspace + "/" + guard normalized.hasPrefix(prefix) else { return nil } + return String(normalized.dropFirst(prefix.count)) + } + } + /// Workspace-relative headers that sit next to the target's compiled sources. func siblingHeaderPaths(project: Project) -> [String] { let workspace = Path(project.workspacePath) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 8622fc2..fb70758 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -24,7 +24,7 @@ extension Target { builder.call( Rules.Swift.Call.swift_library( name: "\(name)_swift", - copts: swiftCopts, + copts: swiftCopts(project: project), module_name: codegenModuleName, srcs: .build { srcs_swift @@ -95,15 +95,20 @@ extension Target { return ["-parse-as-library"] } - var swiftCopts: [String]? { - let copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts + func swiftCopts(project: Project) -> [String]? { + var copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts + if bridgingHeader != nil { + copts += swiftIncludeCopts(project: project) + } return copts.isEmpty ? nil : copts } /// Same flags minus the bridging header: a mixed-language target exposes those - /// declarations through its own clang module instead. - var moduleSwiftCopts: [String]? { - parseAsLibraryCopts.isEmpty ? nil : parseAsLibraryCopts + /// declarations through its own clang module instead. The module's headers can + /// still reach for the target's include paths, so `swiftc` needs them too. + func moduleSwiftCopts(project: Project) -> [String]? { + let copts = parseAsLibraryCopts + swiftIncludeCopts(project: project) + return copts.isEmpty ? nil : copts } /// `swift_library` has no `sdk_frameworks`, so system frameworks and dylibs from diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index f35bfac..2cffe92 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -34,7 +34,7 @@ extension Bazel { try generatedRoot.mkpath() var materializedDirectories = Set() - for relativePath in target.pathsForRoadmapTree { + for relativePath in target.pathsForRoadmapTree(project: project) { let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let hasMaterializedAncestor = materializedDirectories.contains { existing in normalizedPath == existing || normalizedPath.hasPrefix(existing + "/") @@ -131,9 +131,9 @@ extension Bazel { } extension XCode2.XCode.Target { - fileprivate var pathsForRoadmapTree: [String] { + fileprivate func pathsForRoadmapTree(project: Project) -> [String] { let allFiles = files.sources + files.headers + files.resources + files.copyFiles + files.others - let candidates = (allFiles.compactMap(\.roadmapRelativePath) + settingReferencedPaths).sorted { + let candidates = (allFiles.compactMap(\.roadmapRelativePath) + settingReferencedPaths + headerSearchPaths(project: project)).sorted { let lhsDepth = $0.split(separator: "/").count let rhsDepth = $1.split(separator: "/").count if lhsDepth == rhsDepth { diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 3e76e10..468ffb4 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -64,6 +64,23 @@ extension XCode.BuildSettings { public var swiftVersion: String? { self["SWIFT_VERSION"] } public var swiftDefine: String? { self["OTHER_SWIFT_FLAGS"] } public var bridgingHeader: String? { self["SWIFT_OBJC_BRIDGING_HEADER"] } + + /// `HEADER_SEARCH_PATHS` plus `USER_HEADER_SEARCH_PATHS`, without Xcode's + /// `$(inherited)` marker. + public var headerSearchPaths: [String] { + ["HEADER_SEARCH_PATHS", "USER_HEADER_SEARCH_PATHS"] + .compactMap { self[$0] } + .flatMap { value in + value.split(separator: " ").map(String.init) + } + .map { path in + path.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + } + .filter { path in + !path.isEmpty && path != "$(inherited)" + } + } + public var testTargetName: String? { self["TEST_TARGET_NAME"] } public var testHost: String? { self["TEST_HOST"] } public var bundleLoader: String? { self["BUNDLE_LOADER"] } From d53a1aa2c76b6686fcf0ce2258e2546f8bd7552b Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 14:17:24 +0800 Subject: [PATCH 056/173] Enable clang modules like Xcode does --- Sources/BazelRules/Rules+Objc.swift | 2 ++ Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift | 3 ++- Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/BazelRules/Rules+Objc.swift b/Sources/BazelRules/Rules+Objc.swift index 4d78a3d..115ee7d 100644 --- a/Sources/BazelRules/Rules+Objc.swift +++ b/Sources/BazelRules/Rules+Objc.swift @@ -50,6 +50,7 @@ extension Rules.Objc { alwayslink: Bool? = nil, copts: [String]? = nil, defines: [String]? = nil, + enable_modules: Bool? = nil, includes: [String]? = nil, linkopts: [String]? = nil, module_map: Starlark.Label? = nil, @@ -74,6 +75,7 @@ extension Rules.Objc { if let alwayslink { "alwayslink" => alwayslink } if let copts { "copts" => copts } if let defines { "defines" => defines } + if let enable_modules { "enable_modules" => enable_modules } if let includes { "includes" => includes } if let linkopts { "linkopts" => linkopts } if let module_map { "module_map" => module_map } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 8f390d9..a4cc648 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -69,6 +69,7 @@ extension Target { xibs storyboards }, + enable_modules: prefer(\.enableModules), hdrs: .build { moduleHeaderFiles(project: project) /// A mixed target gets the bridging header's declarations through @@ -80,7 +81,7 @@ extension Target { module_name: codegenModuleName, sdk_dylibs: dylibsSDK, sdk_frameworks: frameworksSDK, - swift_copts: moduleSwiftCopts, + swift_copts: moduleSwiftCopts(project: project), swift_defines: defines(project: project), swift_srcs: .build { srcs_swift diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 9e5b466..01af074 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -15,7 +15,6 @@ extension Target { func generateObjcLibrary(_ builder: CodeBuilder, _ kit: Kit, aliasPublic: Bool = true) { let project = kit.project builder.load(.objc_library) - /// "enable_modules" => select(\.enableModules).starlark builder.call( Rules.Objc.Call.objc_library( name: "\(name)_objc", @@ -39,6 +38,7 @@ extension Target { "-fPIC", "-fmodule-name=\(codegenModuleName)", ], + enable_modules: prefer(\.enableModules), includes: headerIncludes(project: project), module_name: codegenModuleName, sdk_dylibs: dylibsSDK, From c98bc453911dc7baa748a99a3cb037af285eb6f6 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 14:51:34 +0800 Subject: [PATCH 057/173] Rewrite brace-style setting references and skip unusable search paths --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 7 ++++++- .../BazelizeKit/Codegen/Codegen+Plist.swift | 21 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 7cc0bcf..594fca7 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -85,7 +85,12 @@ extension Target { let workspace = Path(project.workspacePath).absolute().string return (prefer(\.headerSearchPaths) ?? []).compactMap { path -> String? in - let normalized = Path(path).normalize().string + /// Xcode quotes segments and allows `$(SETTING:modifier)`; a path that + /// still carries either cannot be resolved to a directory here. + let unquoted = path.replacingOccurrences(of: "\"", with: "") + guard !unquoted.contains("$") else { return nil } + + let normalized = Path(unquoted).normalize().string guard normalized != "." else { return nil } if !normalized.hasPrefix("/") { diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index d013df2..2e83674 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -80,7 +80,7 @@ extension Target { guard let nodes = infoPlistNodes(project: project) else { return nil } let dropped = appIcons(project: project) == nil ? [] : Self.iconKeys - return entries(nodes, dropping: dropped).withNewLine + return entries(nodes, dropping: dropped).withNewLine.escapedForPlistFragment } /// `macos_application`/`ios_application` derive these from `app_icons`, and @@ -141,6 +141,9 @@ extension Target { } extension String { + /// Xcode accepts both `$(SETTING)` and `${SETTING}`. + fileprivate static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)[)}]"# + /// Variables `plisttool` substitutes itself; leaving them intact keeps /// rules_apple in charge of the bundle identity it also validates. fileprivate static let plistToolVariables: Set = [ @@ -157,7 +160,7 @@ extension String { /// copied out of an Xcode `Info.plist` would either reach the bundle verbatim /// or collide with a resolved value in another fragment. fileprivate func resolvingBuildSettingReferences(with settings: BuildSettings) -> String { - guard let regex = try? NSRegularExpression(pattern: #"\$\(([A-Za-z0-9_]+)\)"#) else { return self } + guard let regex = try? NSRegularExpression(pattern: Self.buildSettingPattern) else { return self } let matches = regex.matches(in: self, range: NSRange(startIndex..., in: self)) var result = self @@ -179,10 +182,22 @@ extension String { return result } + /// `plist_fragment` treats `{...}` as a `--define` placeholder, so a brace that + /// reaches the template fails analysis. Unresolved `${SETTING}` references are + /// rewritten to the equivalent `$(SETTING)`, which `plisttool` also substitutes. + fileprivate var escapedForPlistFragment: String { + guard let regex = try? NSRegularExpression(pattern: #"\$\{([A-Za-z0-9_]+)\}"#) else { return self } + + return regex.stringByReplacingMatches( + in: self, + range: NSRange(startIndex..., in: self), + withTemplate: "\\$($1)") + } + /// `$(SETTING)` references left after resolution, excluding the ones /// `plisttool` substitutes itself. fileprivate var hasUnresolvedBuildSettingReference: Bool { - guard let regex = try? NSRegularExpression(pattern: #"\$\(([A-Za-z0-9_]+)\)"#) else { return false } + guard let regex = try? NSRegularExpression(pattern: Self.buildSettingPattern) else { return false } return regex.matches(in: self, range: NSRange(startIndex..., in: self)).contains { match in guard let keyRange = Range(match.range(at: 1), in: self) else { return false } From 132ad236f99150f2111a38b7b463c1ef13e843ea Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 14:51:34 +0800 Subject: [PATCH 058/173] Infer the target platform when SDKROOT is absent or auto --- .../Codegen/CodeGen+Extension.swift | 2 +- .../Codegen/Codegen+Application.swift | 9 ++++- .../Codegen/Codegen+Framework.swift | 2 +- .../Codegen/Codegen+Platform.swift | 38 +++++++++++++++++++ .../BazelizeKit/Codegen/Codegen+UITest.swift | 2 +- .../Codegen/Codegen+UnitTest.swift | 2 +- 6 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 Sources/BazelizeKit/Codegen/Codegen+Platform.swift diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index f446026..aa2903f 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -11,7 +11,7 @@ extension Target { // MARK: Internal func generateExtension(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.platform.sdk) { + switch platformSDK { case .iOS: buildIOS(builder, kit) default: break } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index f80e08a..b257545 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -1,8 +1,9 @@ +import Util extension Target { // MARK: Internal func generateApplicationCode(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.platform.sdk) { + switch platformSDK { case .iOS: buildIOS(builder, kit) case .macOS: buildMac(builder, kit) case .tvOS: buildTV(builder, kit) @@ -15,7 +16,11 @@ extension Target { if family.contains(.iphone) { buildIOS(builder, kit) } - default: break + default: + Log.codeGenerate.warning(""" + Name: \(name, privacy: .public) + SDK: \(platformSDK?.rawValue ?? "nil", privacy: .public) has no application rule + """) } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index 302da87..074ec9e 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -2,7 +2,7 @@ extension Target { func generateFrameworkCode(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.platform.sdk) { + switch platformSDK { case .macOS: buildMacFramework(builder, kit) default: buildIOSFramework(builder, kit) } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift new file mode 100644 index 0000000..e98dcd6 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift @@ -0,0 +1,38 @@ +import XCode2 + +extension Target { + /// The platform the target builds for. + /// + /// `SDKROOT` is optional in a project file — Xcode falls back to the platform + /// implied by the deployment target — so the rule choice cannot depend on the + /// setting being present. + var platformSDK: SDK? { + if let sdk = prefer(\.platform.sdk), sdk != .auto { + return sdk + } + + /// `SDKROOT = auto` means the target is multiplatform; Xcode picks by device + /// family, and an iPhone family is the only one Bazel needs a separate rule + /// for here. + if prefer(\.platform.deviceFamily)?.contains(.iphone) == true { + return .iOS + } + + /// No usable `SDKROOT`: the deployment targets still say which platform the + /// target builds for. + if prefer(\.platform.iOS) != nil { + return .iOS + } + if prefer(\.platform.macOS) != nil { + return .macOS + } + if prefer(\.platform.tvOS) != nil { + return .tvOS + } + if prefer(\.platform.watchOS) != nil { + return .watchOS + } + + return prefer(\.platform.sdk) + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+UITest.swift b/Sources/BazelizeKit/Codegen/Codegen+UITest.swift index b4e55d0..d2087e8 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+UITest.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+UITest.swift @@ -13,7 +13,7 @@ extension Target { // MARK: Internal func generateUITest(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.platform.sdk) { + switch platformSDK { case .iOS: generateIOSUITest(builder, kit) case .macOS: generateMacUITest(builder, kit) case .tvOS: generateTVUITest(builder, kit) diff --git a/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift b/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift index aab7700..6f2ef58 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift @@ -13,7 +13,7 @@ extension Target { // MARK: Internal func generateUnitTest(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.platform.sdk) { + switch platformSDK { case .iOS: generateIOSUnitTest(builder, kit) case .macOS: generateMacUnitTest(builder, kit) case .tvOS: generateTVUnitTest(builder, kit) From 1ddbbad8196e4c537512a392964008dd6d336450 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 15:25:47 +0800 Subject: [PATCH 059/173] Take project defaults from the project's own configuration list --- Sources/Xcode2/Loader/XCode+ProjectLoader.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index 36bef9f..85574ec 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -54,13 +54,14 @@ final class ProjectLoader { defaultConfigList: defaultConfigList) } + /// The project-level build configuration list every target inherits. + /// + /// Picking it by elimination (all lists minus the native targets') is both wrong + /// for projects with aggregate or legacy targets and non-deterministic, because + /// the leftovers come out of a `Set`. private lazy var defaultConfigList: ConfigListLoader? = { - let all = Set(native.configurationLists.map { ConfigListLoader(native: $0, sourceRoot: workspacePath) }) - let targetLists = native.nativeTargets.map { - ConfigListLoader(native: $0.buildConfigurationList, sourceRoot: workspacePath) - } - - return all.subtracting(targetLists).first + guard let list = rootProject?.buildConfigurationList else { return nil } + return ConfigListLoader(native: list, sourceRoot: workspacePath) }() } From 9aada3d298fff0d477d8937226525e698a1e3429 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 15:25:47 +0800 Subject: [PATCH 060/173] Discover local packages inside synchronized groups --- .../Xcode2/Loader/XCode+ProjectLoader.swift | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index 85574ec..b5fa871 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -84,7 +84,7 @@ extension ProjectLoader { } return Self.mergeLocalPackages( explicit: explicit, - discovered: discoveredLocalPackages) + discovered: discoveredLocalPackages + synchronizedLocalPackages) } private var discoveredLocalPackages: [XCode.LocalPackage] { @@ -104,6 +104,29 @@ extension ProjectLoader { } } + /// Local packages Xcode picks up from a synchronized group instead of an + /// explicit package reference, e.g. a `Packages/` directory holding one + /// package per subdirectory. + private var synchronizedLocalPackages: [XCode.LocalPackage] { + native.fileSystemSynchronizedRootGroups.flatMap { group -> [XCode.LocalPackage] in + guard let relativeRoot = group.path else { return [] } + + let root = workspacePath + relativeRoot + guard root.isDirectory else { return [] } + + if (root + "Package.swift").exists { + return [.init(name: root.lastComponent, relativePath: relativeRoot)] + } + + return (try? root.children())?.compactMap { child in + guard child.isDirectory, (child + "Package.swift").exists else { return nil } + return XCode.LocalPackage( + name: child.lastComponent, + relativePath: "\(relativeRoot)/\(child.lastComponent)") + } ?? [] + } + } + func packageFiles(targetName: String) -> [FileLoader] { allFiles .compactMap { FileLoader(native: $0, project: self) } From 4f92cb005c9705f086ad8d135786097795490569 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 15:32:14 +0800 Subject: [PATCH 061/173] Pick the platform from SUPPORTED_PLATFORMS for multiplatform targets --- Sources/BazelizeKit/Codegen/Codegen+Platform.swift | 8 +++++--- .../Model/Config/XCode+BuildSettings+Platform.swift | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift index e98dcd6..9956dd1 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift @@ -11,9 +11,11 @@ extension Target { return sdk } - /// `SDKROOT = auto` means the target is multiplatform; Xcode picks by device - /// family, and an iPhone family is the only one Bazel needs a separate rule - /// for here. + /// `SDKROOT = auto` means the target is multiplatform: `SUPPORTED_PLATFORMS` + /// narrows it down, and failing that an iPhone device family does. + if let platform = prefer(\.platform.supportedPlatforms)?.first, platform != .auto { + return platform + } if prefer(\.platform.deviceFamily)?.contains(.iphone) == true { return .iOS } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift index 97c283b..5d76023 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift @@ -23,6 +23,13 @@ extension XCode.BuildSettings { SDK(rawValue: settings["SDKROOT"] ?? "") } + /// `SUPPORTED_PLATFORMS`, which decides the platform when `SDKROOT = auto`. + public var supportedPlatforms: [SDK] { + (settings["SUPPORTED_PLATFORMS"] ?? "") + .split(separator: " ") + .compactMap { SDK(rawValue: String($0)) } + } + public var iOS: String? { settings["IPHONEOS_DEPLOYMENT_TARGET"] } From fc24ef48b44f9d7bd737c4e7edd449426ffececb Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 15:36:06 +0800 Subject: [PATCH 062/173] Skip prebuilt binaries missing from the working tree --- Sources/Xcode2/Loader/XCode+FileLoader.swift | 5 +++++ Sources/Xcode2/Loader/XCode+TargetLoader.swift | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index c56acf7..fc69742 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -145,6 +145,11 @@ struct FileLoader { typedFileType == .dynamicLibrary || typedFileType == .dylibStub } + var existsOnDisk: Bool { + guard let fullPath else { return false } + return Path(fullPath).exists + } + private var ref: PBXFileReference? { native as? PBXFileReference } diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index 38349dd..5402b35 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -121,6 +121,10 @@ struct TargetLoader { let label = wrapped.label(buildPhase: BuildPhase.frameworks.rawValue), label.hasPrefix("//Prebuilt:") { + /// A framework that only exists after a Carthage/CocoaPods/script + /// bootstrap cannot be imported, and referencing it anyway leaves the + /// generated workspace unloadable. + guard wrapped.existsOnDisk else { return nil } return label } From 5283cf9641897ccdfb279d98eba91d89208f86f6 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 15:36:06 +0800 Subject: [PATCH 063/173] Resolve build setting references with modifiers --- .../Model/Config/XCode+BuildSettings.swift | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 468ffb4..4e031e4 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -88,11 +88,14 @@ extension XCode.BuildSettings { } extension XCode.BuildSettings { + /// Xcode spells a reference `$(NAME)` or `${NAME}` and allows a modifier: + /// `$(PRODUCT_NAME:rfc1034identifier)`. + private static let referencePattern = #"\$[({]([A-Za-z0-9_]+)(?::([A-Za-z0-9_]+))?[)}]"# + private func resolved(_ value: String?, visited: Set) -> String? { guard let value else { return nil } - let pattern = #"\$\(([A-Za-z0-9_]+)\)"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return value } + guard let regex = try? NSRegularExpression(pattern: Self.referencePattern) else { return value } let matches = regex.matches( in: value, @@ -102,7 +105,6 @@ extension XCode.BuildSettings { var result = value for match in matches.reversed() { guard - match.numberOfRanges == 2, let wholeRange = Range(match.range(at: 0), in: value), let keyRange = Range(match.range(at: 1), in: value) else { @@ -114,9 +116,31 @@ extension XCode.BuildSettings { continue } - result.replaceSubrange(wholeRange, with: replacement) + let modifier = Range(match.range(at: 2), in: value).map { String(value[$0]) } + result.replaceSubrange(wholeRange, with: Self.apply(modifier, to: replacement)) } return result } + + private static func apply(_ modifier: String?, to value: String) -> String { + switch modifier { + case "rfc1034identifier": + return value.map { character in + character.isLetter || character.isNumber || character == "." || character == "-" + ? String(character) + : "-" + }.joined() + case "identifier", "c99extidentifier": + return value.map { character in + character.isLetter || character.isNumber ? String(character) : "_" + }.joined() + case "lower": + return value.lowercased() + case "upper": + return value.uppercased() + default: + return value + } + } } From 97046f416f50015ccbeed798621999aea72b1417 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 15:36:06 +0800 Subject: [PATCH 064/173] Fill plist defaults for keys the source plist cannot provide --- .../BazelizeKit/Codegen/Codegen+Plist.swift | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index 2e83674..ca003ad 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -49,15 +49,19 @@ extension Target { visibility: .private)) } - /// Keys the target's checked-in `Info.plist` already defines. + /// Keys the emitted `plist_file` fragment actually defines. + /// + /// Read from the fragment rather than the source `Info.plist`, so a key dropped + /// for being unresolvable still gets its build-setting default. func infoPlistKeys(project: Project?) -> Set { - guard let nodes = infoPlistNodes(project: project) else { return [] } + guard let content = plistContent(project: project) else { return [] } + guard let regex = try? NSRegularExpression(pattern: #"([^<]+)"#) else { return [] } + let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) return Set( - nodes - .compactMap { $0 as? XMLElement } - .filter { $0.name == "key" } - .compactMap(\.stringValue)) + matches.compactMap { match in + Range(match.range(at: 1), in: content).map { String(content[$0]) } + }) } // MARK: Private @@ -142,7 +146,7 @@ extension Target { extension String { /// Xcode accepts both `$(SETTING)` and `${SETTING}`. - fileprivate static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)[)}]"# + fileprivate static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)(?::[A-Za-z0-9_]+)?[)}]"# /// Variables `plisttool` substitutes itself; leaving them intact keeps /// rules_apple in charge of the bundle identity it also validates. @@ -328,11 +332,14 @@ extension Target { let defaults = [ ("CFBundleName", "$(PRODUCT_NAME)"), ("CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"), - ("CFBundleVersion", settings.generatedPlist.currentProjectVersion ?? "$(CURRENT_PROJECT_VERSION)"), + /// `plisttool` cannot resolve these, and rules_apple rejects a bundle + /// without them, so an unset setting falls back to Xcode's own template + /// values instead of a literal `$(SETTING)`. + ("CFBundleVersion", settings.generatedPlist.currentProjectVersion ?? "1"), ("CFBundleExecutable", "$(EXECUTABLE_NAME)"), ("CFBundlePackageType", bundlePackageType ?? ""), ("CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"), - ("CFBundleShortVersionString", settings.generatedPlist.marketingVersion ?? "$(MARKETING_VERSION)"), + ("CFBundleShortVersionString", settings.generatedPlist.marketingVersion ?? "1.0"), ] return defaults From 25083b98d5cf37628046ab2d3d1a4332bd951e2d Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 16:49:51 +0800 Subject: [PATCH 065/173] Clean only the directories bazelize owns in the output --- .../BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 2cffe92..941f0ea 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -7,13 +7,21 @@ extension Bazel { let output: Path let project: Project + /// Only the directories bazelize owns are wiped. + /// + /// Deleting the whole output root would take the project itself with it when + /// no `--output` is given, and otherwise throw away the resolved SwiftPM and + /// Bazel state that lives next to the generated files. func prepare() throws { - try? output.delete() + let targetsRoot = output + "Targets" + let prebuiltRoot = output + "Prebuilt" + + try? targetsRoot.delete() + try? prebuiltRoot.delete() + try output.mkpath() try linkPackageResolvedIfPresent(project: project) try preparePrebuiltFiles(project: project) - - let targetsRoot = output + "Targets" try targetsRoot.mkpath() for target in project.targets { From e850b72b787a68e2446bb99ecaeb661725735e3b Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 16:49:51 +0800 Subject: [PATCH 066/173] Leave CFBundlePackageType to rules_apple --- .../BazelizeKit/Codegen/Codegen+Plist.swift | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index ca003ad..754dbbb 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -290,36 +290,16 @@ extension Target { } /// `plisttool` substitutes only a handful of variables, so a default whose value - /// it cannot resolve has to be dropped: `macos_command_line_application` has no - /// bundled executable, and `PRODUCT_BUNDLE_PACKAGE_TYPE` is never substituted. + /// it cannot resolve has to be dropped: `macos_command_line_application` bundles + /// no executable. private var unsupportedDefaultPlistKeys: Set { var keys: Set = [] if productType == "com.apple.product-type.tool" { keys.insert("CFBundleExecutable") } - if bundlePackageType == nil { - keys.insert("CFBundlePackageType") - } return keys } - /// The value Xcode derives for `PRODUCT_BUNDLE_PACKAGE_TYPE`. - private var bundlePackageType: String? { - switch productType { - case "com.apple.product-type.application": - return "APPL" - case "com.apple.product-type.framework", - "com.apple.product-type.framework.static": - return "FMWK" - case "com.apple.product-type.bundle", - "com.apple.product-type.bundle.unit-test", - "com.apple.product-type.bundle.ui-testing", - "com.apple.product-type.app-extension": - return "BNDL" - default: - return nil - } - } /// The target's own `Info.plist` is the source of truth Xcode uses, so a /// default derived from build settings must not restate those keys: `plisttool` @@ -337,7 +317,6 @@ extension Target { /// values instead of a literal `$(SETTING)`. ("CFBundleVersion", settings.generatedPlist.currentProjectVersion ?? "1"), ("CFBundleExecutable", "$(EXECUTABLE_NAME)"), - ("CFBundlePackageType", bundlePackageType ?? ""), ("CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"), ("CFBundleShortVersionString", settings.generatedPlist.marketingVersion ?? "1.0"), ] From 5826ff050b3de9ea748a3bc46677026270e99a6f Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 16:49:51 +0800 Subject: [PATCH 067/173] Expose framework-style include paths for module headers --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 594fca7..6f10ffb 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -58,13 +58,31 @@ extension Target { Path(header).parent().string } + headerSearchPaths(project: project).map { path in "Sources/\(path)" - } + } + frameworkStyleIncludes(project: project) /// "." keeps a public header reachable by its own relative path. /// https://github.com/bazelbuild/bazel/issues/92 return Array(Set(directories + ["."])).sorted() } + /// Include paths that make `#import ` resolve. + /// + /// Xcode publishes a framework's headers under a directory named after the + /// framework, so dependents import them that way. In the generated tree the + /// headers keep their project-relative layout, which already has such a + /// directory whenever the sources live in a folder named after the module — the + /// path above it is what the compiler needs. + private func frameworkStyleIncludes(project: Project) -> [String] { + moduleHeaderFiles(project: project).compactMap { header in + let directory = Path(header).parent() + guard directory.lastComponent == codegenModuleName || directory.lastComponent == name else { + return nil + } + let parent = directory.parent().string + return parent == "." ? nil : parent + } + } + /// The same include paths, spelled for `swiftc`'s clang importer. /// /// Bazel resolves the `includes` attribute relative to the package, raw `-I` From 41ef8861fcf832b50d07ce207911b2dbfc83b5d0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 16:49:51 +0800 Subject: [PATCH 068/173] Move the iOS fixture and CI onto the roadmap output flow --- .github/workflows/swift.yml | 31 ++++++--- fixture/iOS/ExampleTests/ExampleTests.swift | 2 +- fixture/iOS/Makefile | 71 +++++++++------------ 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index de1bcb5..de21f99 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -113,32 +113,43 @@ jobs: - name: Bazel Generation working-directory: fixture/iOS run: | - ../../bazelize --project Example.xcodeproj + ../../bazelize --project Example.xcodeproj --output App + + - name: Resolve SPM Deps + working-directory: fixture/iOS/App + run: | + swift package resolve - name: Update SPM Deps - working-directory: fixture/iOS + working-directory: fixture/iOS/App run: | bazel mod tidy - name: Build Application - working-directory: fixture/iOS + working-directory: fixture/iOS/App run: | - bazel build Example - + bazel build //Targets/Example + - name: Copy IPA - working-directory: fixture/iOS + working-directory: fixture/iOS/App run: | - cp bazel-bin/Example/Example.ipa . + cp "$(bazel cquery //Targets/Example --output=files | grep '\.ipa$')" ../Example.ipa - name: Unit Test - working-directory: fixture/iOS + working-directory: fixture/iOS/App run: | - bazel test ExampleTests Framework1Tests Framework2Tests Framework3Tests + bazel test \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_device="iPhone 17" \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_version=27.0 \ + //Targets/ExampleTests \ + //Targets/Framework1Tests \ + //Targets/Framework2Tests \ + //Targets/Framework3Tests - name: Upload iOS Artifact uses: actions/upload-artifact@v7 with: name: iOS_Example.ipa path: fixture/iOS/Example.ipa - if-no-files-found: ignore # 'warn' or 'ignore' + if-no-files-found: ignore # 'warn' or 'ignore' diff --git a/fixture/iOS/ExampleTests/ExampleTests.swift b/fixture/iOS/ExampleTests/ExampleTests.swift index 7fd62c9..7230aed 100644 --- a/fixture/iOS/ExampleTests/ExampleTests.swift +++ b/fixture/iOS/ExampleTests/ExampleTests.swift @@ -10,6 +10,6 @@ import XCTest final class ExampleTests: XCTestCase { func testExample() throws { - XCTAssertEqual(test(), 0) + XCTAssertEqual(test(), 0b1111) } } diff --git a/fixture/iOS/Makefile b/fixture/iOS/Makefile index d6118ab..d94f60f 100644 --- a/fixture/iOS/Makefile +++ b/fixture/iOS/Makefile @@ -1,59 +1,48 @@ - BAZELIZE = ../../.build/debug/bazelize +OUTPUT = App -.PHONY: bazelize -bazelize: - @bazelize --project Example.xcodeproj - - @bazel mod tidy - -.PHONY: bazelize2 -bazelize2: - @$(BAZELIZE) --project Example.xcodeproj --output App - cd App && bazel mod tidy - cd App && bazel run //Targets/Example +# The simulator rules_apple runs unit tests on; override for other Xcode versions. +SIMULATOR_FLAGS = \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_device="iPhone 17" \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_version=27.0 +TESTS = \ + //Targets/ExampleTests \ + //Targets/Framework1Tests \ + //Targets/Framework2Tests \ + //Targets/Framework3Tests -.PHONY: clear -clear: - @bazelize --project Example.xcodeproj --clear - -rm Package.swift - -rm Package.resolved - -rm MODULE.bazel - -rm MODULE.bazel.lock +.PHONY: bazelize +bazelize: + @$(BAZELIZE) --project Example.xcodeproj --output $(OUTPUT) + cd $(OUTPUT) && swift package resolve + cd $(OUTPUT) && bazel mod tidy .PHONY: build -build: - bazel build Example - -.PHONY: updatePkg -updatePkg: - @bazel mod tidy +build: bazelize + cd $(OUTPUT) && bazel build //Targets/Example .PHONY: run -run: - bazel run --config=Debug Example +run: bazelize + cd $(OUTPUT) && bazel run --config=Debug //Targets/Example .PHONY: releaseRun -releaseRun: - bazel run --config=Release Example +releaseRun: bazelize + cd $(OUTPUT) && bazel run --config=Release //Targets/Example .PHONY: test test: - bazel test \ - --sandbox_debug \ - ExampleTests \ - Framework1Tests \ - Framework2Tests \ - Framework3Tests - + cd $(OUTPUT) && bazel test $(SIMULATOR_FLAGS) $(TESTS) .PHONY: uitest uitest: - bazel test ExampleUITests + cd $(OUTPUT) && bazel test $(SIMULATOR_FLAGS) //Targets/ExampleUITests +.PHONY: updatePkg +updatePkg: + cd $(OUTPUT) && bazel mod tidy -# -# bazel run //:update_build_files -# bazel test //... -# --experimental_enable_bzlmod \ No newline at end of file +.PHONY: clear +clear: + @$(BAZELIZE) --project Example.xcodeproj --output $(OUTPUT) --clear + -rm -rf $(OUTPUT) From 03ac7fb994127efc20a2f4a00c341ac24d5fc8f0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 17:28:13 +0800 Subject: [PATCH 069/173] Pin exact package versions to the revision Xcode resolved --- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 744ad59..ba01664 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -132,6 +132,12 @@ final class PluginSwiftPM: PluginBuiltin { case .upToNextMinorVersion(let version): return #" .package(url: "\#(url)", .upToNextMinor(from: "\#(version)")),"# case .exact(let version): + /// An exact version names one commit, and upstream deleting or + /// re-tagging it makes the manifest unresolvable. Xcode's own pin + /// records the revision, so use it when it is available. + if let revision = pinnedRevision(url: url) { + return #" .package(url: "\#(url)", revision: "\#(revision)"), // \#(version)"# + } return #" .package(url: "\#(url)", exact: "\#(version)"),"# case .branch(let branch): return #" .package(url: "\#(url)", branch: "\#(branch)"),"# @@ -177,6 +183,35 @@ final class PluginSwiftPM: PluginBuiltin { return .init(path: "Package.resolved", content: content) } + /// Revisions Xcode already resolved, keyed by package identity. + private lazy var pinnedRevisions: [String: String] = { + guard let projectPath else { return [:] } + + let resolved = projectPath + "project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + guard + let data = try? Data(contentsOf: URL(fileURLWithPath: resolved.string)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let pins = json["pins"] as? [[String: Any]] + else { + return [:] + } + + return pins.reduce(into: [String: String]()) { result, pin in + guard + let identity = pin["identity"] as? String, + let state = pin["state"] as? [String: Any], + let revision = state["revision"] as? String + else { + return + } + result[identity] = revision + } + }() + + private func pinnedRevision(url: String) -> String? { + pinnedRevisions[Self.repositoryModuleName(url: url).lowercased()] + } + override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } return [package, packageResolved].compactMap { $0 } From 8cef721780f80162d5c11a79cf455f8260013e5e Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 17:28:13 +0800 Subject: [PATCH 070/173] Resolve package products Xcode attaches to build files --- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 20 +++++++++++++---- .../Xcode2/Loader/XCode+ProjectLoader.swift | 3 +++ .../Xcode2/Loader/XCode+TargetLoader.swift | 22 +++++++++++++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index ba01664..67dff83 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -82,14 +82,26 @@ final class PluginSwiftPM: PluginBuiltin { } private func transformRemote(_ product: PackageProductDependency) -> String? { - guard let url = product.package else { return nil } /// NIO - let product = product.productName + let name = product.productName + guard let url = product.package ?? remoteURL(forProduct: name) else { return nil } /// @swiftpkg_swift_nio//:NIO + /// + /// Only the repository name is sanitized: rules_swift_package_manager keeps + /// the product name verbatim, dashes included (`SwiftUIIntrospect-Static`). return """ - @\(Self.repositoryName(url: url))//:\(product) - """.replacingOccurrences(of: "-", with: "_") + @\(Self.repositoryName(url: url))//:\(name) + """ + } + + /// Xcode can reference a package product without linking it back to the package. + /// The repository named after the product is the only sound guess, and it covers + /// the common one-product-per-package layout. + private func remoteURL(forProduct product: String) -> String? { + remotes.compactMap(\.repositoryURL).first { url in + Self.repositoryModuleName(url: url).caseInsensitiveCompare(product) == .orderedSame + } } private func transformLocal(_ product: PackageProductDependency) -> String? { diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index b5fa871..ec5d3c1 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -54,6 +54,9 @@ final class ProjectLoader { defaultConfigList: defaultConfigList) } + /// Names of every native target in the project. + lazy var targetNames: Set = Set(native.nativeTargets.map(\.name)) + /// The project-level build configuration list every target inherits. /// /// Picking it by elimination (all lists minus the native targets') is both wrong diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index 5402b35..b5a4c84 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -102,9 +102,21 @@ struct TargetLoader { } private var dependencies: XCode.Dependencies { - let targetDependencies = native.dependencies.compactMap { dependency in + let declaredDependencies = native.dependencies.compactMap { dependency in dependency.target?.name ?? dependency.name } + + /// A target can link a sibling target's framework through the Frameworks + /// phase without declaring a target dependency; Xcode resolves it implicitly. + let implicitDependencies = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard let identity = wrapped.frameworkIdentity else { return nil } + guard identity != name, project.targetNames.contains(identity) else { return nil } + return identity + } + + let targetDependencies = declaredDependencies + implicitDependencies let targetDependencyIdentities = Set(targetDependencies) let frameworks = frameworkBuildFiles.compactMap { buildFile -> String? in @@ -168,7 +180,13 @@ struct TargetLoader { return directory } - let packageProducts = (native.packageProductDependencies ?? []).map { dependency in + /// Xcode records a linked package product either on the target or on the + /// build file in the Frameworks phase, depending on how it was added. + let productDependencies = (native.packageProductDependencies ?? []) + frameworkBuildFiles.compactMap { buildFile in + buildFile.product + } + + let packageProducts = unique(productDependencies) { $0.productName }.map { dependency in XCode.PackageProductDependency( productName: dependency.productName, package: dependency.package?.repositoryURL, From ea0ccd0093e156e7f22cb546cadd71973d19ec34 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 17:28:13 +0800 Subject: [PATCH 071/173] Mirror Xcode's flat framework Headers directory --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 33 +++++++++---------- .../Codegen/Language/Codegen+Library.swift | 2 +- .../Language/Codegen+ObjcLibrary.swift | 2 +- .../Roadmap/BazelizeKit+Roadmap.swift | 26 +++++++++++++++ 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 6f10ffb..5047d57 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -22,6 +22,20 @@ extension Target { return exported.isEmpty ? Array(Set(headers)).sorted() : exported.sorted() } + /// The same headers, addressed through the flattened `Headers//` tree the + /// roadmap materializes. + /// + /// Xcode copies a framework's published headers into one flat directory, which is + /// why `#import ` works no matter where the header lives in the + /// project. Bazel needs that directory to exist for the same imports to resolve. + func flattenedModuleHeaderFiles(project: Project) -> [String] { + moduleHeaderFiles(project: project).map { header in + "\(Self.moduleHeaderRoot)/\(codegenModuleName)/\(Path(header).lastComponent)" + } + } + + static let moduleHeaderRoot = "Headers" + /// Headers that are compile inputs only. func internalHeaderFiles(project: Project) -> [String] { let module = Set(moduleHeaderFiles(project: project)) @@ -58,30 +72,13 @@ extension Target { Path(header).parent().string } + headerSearchPaths(project: project).map { path in "Sources/\(path)" - } + frameworkStyleIncludes(project: project) + } + [Self.moduleHeaderRoot, "\(Self.moduleHeaderRoot)/\(codegenModuleName)"] /// "." keeps a public header reachable by its own relative path. /// https://github.com/bazelbuild/bazel/issues/92 return Array(Set(directories + ["."])).sorted() } - /// Include paths that make `#import ` resolve. - /// - /// Xcode publishes a framework's headers under a directory named after the - /// framework, so dependents import them that way. In the generated tree the - /// headers keep their project-relative layout, which already has such a - /// directory whenever the sources live in a folder named after the module — the - /// path above it is what the compiler needs. - private func frameworkStyleIncludes(project: Project) -> [String] { - moduleHeaderFiles(project: project).compactMap { header in - let directory = Path(header).parent() - guard directory.lastComponent == codegenModuleName || directory.lastComponent == name else { - return nil - } - let parent = directory.parent().string - return parent == "." ? nil : parent - } - } /// The same include paths, spelled for `swiftc`'s clang importer. /// diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index a4cc648..1d32c12 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -71,7 +71,7 @@ extension Target { }, enable_modules: prefer(\.enableModules), hdrs: .build { - moduleHeaderFiles(project: project) + flattenedModuleHeaderFiles(project: project) /// A mixed target gets the bridging header's declarations through /// its own clang module: `swiftc` rejects `-import-objc-header` /// while building a module. diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 01af074..d0a3dc2 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -27,7 +27,7 @@ extension Target { }, hdrs: .build { // FIXME: (@yume190) TODO: pch - moduleHeaderFiles(project: project) + flattenedModuleHeaderFiles(project: project) }, deps: .build { frameworksLibrary diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 941f0ea..cdaa50f 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -61,6 +61,32 @@ extension Bazel { } try prepareSiblingHeaders(target: target, project: project, sourcesRoot: sourcesRoot) + try prepareModuleHeaders(target: target, project: project, targetRoot: targetRoot) + } + + /// Xcode copies a target's published headers into one flat directory inside + /// the product, which is what makes `#import ` work regardless + /// of where the header lives. The generated tree mirrors that directory. + private func prepareModuleHeaders( + target: Target, + project: Project, + targetRoot: Path) throws + { + let workspace = Path(project.workspacePath) + let moduleRoot = targetRoot + Target.moduleHeaderRoot + target.codegenModuleName + let headers = target.moduleHeaderFiles(project: project) + guard !headers.isEmpty else { return } + + try moduleRoot.mkpath() + + for header in headers { + let relativePath = header.delete(prefix: "Sources/") ?? header + let source = workspace + relativePath + guard source.exists, !source.isSelfReferentialSymlink else { continue } + + let destination = moduleRoot + source.lastComponent + try materialize(source: source, destination: destination) + } } /// Xcode's implicit header map makes every header in the target reachable by From 92e81ce58ea876557d4ccaeba7fa5ef4c3b8db5c Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 18:10:15 +0800 Subject: [PATCH 072/173] Escape quotes and backslashes in Starlark strings Build-setting values reach Starlark verbatim: a preprocessor definition like `ID=@"com.example"` used to emit an unterminated string literal. --- Sources/Starlark/Starlark/Value/Starlark+Value.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Starlark/Starlark/Value/Starlark+Value.swift b/Sources/Starlark/Starlark/Value/Starlark+Value.swift index 983495d..6a492f5 100644 --- a/Sources/Starlark/Starlark/Value/Starlark+Value.swift +++ b/Sources/Starlark/Starlark/Value/Starlark+Value.swift @@ -73,8 +73,13 @@ extension Starlark { case .label(let value): return value.text case .string(let value): + /// Values come from Xcode build settings and can carry quotes, e.g. + /// a preprocessor definition like `ID=@"com.example"`. + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") return """ - "\(value)" + "\(escaped)" """ case .int(let value): return "\(value)" From 06dc3bef07154536102c956a55c967e49fa16b1a Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 18:10:22 +0800 Subject: [PATCH 073/173] Generate Swift asset symbols with actool `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` makes Xcode emit a `GeneratedAssetSymbols.swift` next to the compiled catalog; targets that reference `.image(.foo)` or `ColorResource` need it to compile. A genrule runs the same `actool` invocation and feeds the result into srcs. --- Sources/BazelRules/Rules+Builtin.swift | 19 +++++ .../BazelizeKit/Codegen/Codegen+Target.swift | 1 + .../Codegen/Language/Codegen+Library.swift | 1 + .../Language/Codegen+SwiftLibrary.swift | 1 + .../Resource/Codegen+AssetSymbols.swift | 83 +++++++++++++++++++ .../XCode+BuildSettings+AssetCatalog.swift | 8 ++ 6 files changed, 113 insertions(+) create mode 100644 Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift diff --git a/Sources/BazelRules/Rules+Builtin.swift b/Sources/BazelRules/Rules+Builtin.swift index 4f0bb30..10dd000 100644 --- a/Sources/BazelRules/Rules+Builtin.swift +++ b/Sources/BazelRules/Rules+Builtin.swift @@ -57,6 +57,25 @@ extension Rules.Builtin.Call { } } + public static func genrule( + name: String, + srcs: Starlark.Value, + outs: [String], + cmd: String, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + .init("genrule") { + "name" => name + "srcs" => srcs + "outs" => outs + "cmd" => Starlark.custom("\"\"\"\(cmd)\"\"\"") + if let visibility { + visibility.argument + } + } + } + public static func alias( name: String, actual: Starlark.Label, diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 25c4193..98d51c2 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -4,6 +4,7 @@ extension Target { func generateCode(_ kit: Kit) -> String { let builder = CodeBuilder() generateIntentLibraries(builder, kit) + generateAssetSymbols(builder, kit) generateLibrary(builder, kit) generateLoadPlistFragment(builder, kit) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 1d32c12..083e93d 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -86,6 +86,7 @@ extension Target { swift_srcs: .build { srcs_swift intentSources + assetSymbolSources }, weak_sdk_frameworks: weakFrameworksSDK, deps: .build { diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index fb70758..6c6b48d 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -29,6 +29,7 @@ extension Target { srcs: .build { srcs_swift intentSources + assetSymbolSources }, deps: .build { extraDeps diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift new file mode 100644 index 0000000..12b10ba --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift @@ -0,0 +1,83 @@ +import BazelRules +import Foundation +import PathKit +import Starlark +import XCode2 + +/// `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` +/// +/// Xcode 15+ runs `actool` to turn every asset into a Swift member +/// (`ImageResource.icon`, `ColorResource.accent`, `Color.accent`, …) and compiles +/// the result into the target. Without it the sources referencing those members do +/// not build, so the same `actool` invocation is wired up as a `genrule`. +extension Target { + // MARK: Internal + + static let assetSymbolsTarget = "AssetSymbols" + static let assetSymbolsFile = "GeneratedAssetSymbols.swift" + + var assetSymbolSources: [Starlark.Label] { + generatesAssetSymbols ? [.named(":\(Self.assetSymbolsTarget)")] : [] + } + + func generateAssetSymbols(_ builder: CodeBuilder, _: Kit) { + guard generatesAssetSymbols else { return } + + builder.call( + Rules.Builtin.Call.genrule( + name: Self.assetSymbolsTarget, + srcs: Starlark.glob(assets.map { "\($0)/**" }), + outs: [Self.assetSymbolsFile], + cmd: assetSymbolsCommand, + visibility: .private)) + } + + // MARK: Private + + private var generatesAssetSymbols: Bool { + guard prefer(\.assetCatalog.generatesSwiftSymbols) == true else { return false } + return !assets.isEmpty + } + + /// `actool` refuses to emit symbols without a bundle identifier, and it needs to + /// know the platform it is compiling for. Catalog paths are derived from + /// `$(SRCS)` so the command stays correct when a target carries several catalogs. + /// + /// Starlark rejects unknown escape sequences inside the string, so the command + /// avoids backslashes entirely. + private var assetSymbolsCommand: String { + let bundleID = prefer(\.metadata.bundleID) ?? "com.bazelize.\(codegenModuleName)" + let arguments = [ + "--platform \(assetSymbolsPlatform)", + "--minimum-deployment-target \(assetSymbolsMinimumOS)", + "--bundle-identifier \(bundleID)", + "--output-format human-readable-text", + "--generate-swift-asset-symbol-extensions YES", + ].joined(separator: " ") + + return """ + set -e + catalogs=$$(for src in $(SRCS); do echo "$${src%%.xcassets/*}.xcassets"; done | sort -u) + compile=$$(mktemp -d) + xcrun actool $$catalogs --compile "$$compile" \(arguments) --generate-swift-asset-symbols $@ > /dev/null + """ + } + + private var assetSymbolsPlatform: String { + switch platformSDK { + case .macOS: return "macosx" + case .tvOS: return "appletvos" + case .watchOS: return "watchos" + default: return "iphoneos" + } + } + + private var assetSymbolsMinimumOS: String { + switch platformSDK { + case .macOS: return prefer(\.platform.macOS) ?? "11.0" + case .tvOS: return prefer(\.platform.tvOS) ?? "15.0" + case .watchOS: return prefer(\.platform.watchOS) ?? "8.0" + default: return prefer(\.platform.iOS) ?? "15.0" + } + } +} diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift index 7009f3a..0dbc30b 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift @@ -15,5 +15,13 @@ extension XCode.BuildSettings { public var accentColorName: String? { settings["ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME"] } + + /// `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` + /// + /// Xcode 15+ generates `ImageResource`/`ColorResource` members from the + /// catalogs and compiles them into the target. + public var generatesSwiftSymbols: Bool { + settings["ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS"] == "YES" + } } } From d2b7c082902e91b198336266c5b5fd65e8e0cc44 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 18:11:39 +0800 Subject: [PATCH 074/173] Add the Targets root to include paths Xcode publishes a framework's generated Swift header inside the framework, so sources import it as ; the same spelling is used for cross-target imports. Adding ".." makes the package-per-target layout resolve those, in the source tree and in the generated tree alike. --- Sources/BazelizeKit/Codegen/Codegen+Headers.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 5047d57..66fc75e 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -76,7 +76,14 @@ extension Target { /// "." keeps a public header reachable by its own relative path. /// https://github.com/bazelbuild/bazel/issues/92 - return Array(Set(directories + ["."])).sorted() + /// "." keeps a public header reachable by its own relative path. + /// https://github.com/bazelbuild/bazel/issues/92 + /// + /// ".." is the `Targets/` root: a package is named after its target, so it + /// makes `#import ` — the generated Swift header Xcode + /// publishes inside the framework — and cross-target framework-style imports + /// resolve, in both the source and the generated file tree. + return Array(Set(directories + [".", ".."])).sorted() } From 124f926cd997171d0ce6207573357eea5aa734b0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 18:12:04 +0800 Subject: [PATCH 075/173] Support GCC_PREPROCESSOR_DEFINITIONS Emitted as a generated header that every compile force-includes, because neither route on the command line survives a value like ID=@"com.example": Bazel re-tokenizes the defines attribute, and the rules_swift persistent worker mangles the quoting of a -D copt. --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 26 +++++++++++++++-- .../Codegen/Language/Codegen+Library.swift | 3 +- .../Language/Codegen+ObjcLibrary.swift | 3 +- .../Language/Codegen+SwiftLibrary.swift | 5 ++-- .../Roadmap/BazelizeKit+Roadmap.swift | 19 +++++++++++++ .../Model/Config/XCode+BuildSettings.swift | 28 +++++++++++++++++++ 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 66fc75e..87e18b7 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -74,8 +74,6 @@ extension Target { "Sources/\(path)" } + [Self.moduleHeaderRoot, "\(Self.moduleHeaderRoot)/\(codegenModuleName)"] - /// "." keeps a public header reachable by its own relative path. - /// https://github.com/bazelbuild/bazel/issues/92 /// "." keeps a public header reachable by its own relative path. /// https://github.com/bazelbuild/bazel/issues/92 /// @@ -86,6 +84,30 @@ extension Target { return Array(Set(directories + [".", ".."])).sorted() } + /// `GCC_PREPROCESSOR_DEFINITIONS`, materialized as a force-included header. + /// + /// Neither the rules' `defines` attribute nor a `-D` copt survives a value like + /// `ID=@"com.x"`: Bazel re-tokenizes the former and the rules_swift worker's + /// param files mangle the quoting of the latter. A header force-included with + /// `-include` needs no quoting at all, and Xcode does not propagate these + /// definitions to dependents either. + static let definesHeaderPath = "Generated/BazelizeDefines.h" + + var definesHeader: String? { + (prefer(\.preprocessorDefinitions) ?? []).isEmpty ? nil : Self.definesHeaderPath + } + + var clangDefineFlags: [String] { + guard let definesHeader else { return [] } + return ["-include", "Targets/\(name)/\(definesHeader)"] + } + + /// The same header, force-included into `swiftc`'s clang importer so a bridging + /// or umbrella header can rely on the definitions. + func clangDefineCopts() -> [String] { + guard let definesHeader else { return [] } + return ["-Xcc", "-include", "-Xcc", "Targets/\(name)/\(definesHeader)"] + } /// The same include paths, spelled for `swiftc`'s clang importer. /// diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 083e93d..88bf62a 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -54,13 +54,14 @@ extension Target { "-fobjc-arc", "-fPIC", "-fmodule-name=\(codegenModuleName)", - ] + clangDialectCopts, + ] + clangDialectCopts + clangDefineFlags, clang_srcs: .build { srcs_c srcs_cpp srcs_objc srcs_objcpp internalHeaderFiles(project: project) + definesHeader }, data: .build { if !assets.isEmpty { diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index d0a3dc2..37fa382 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -24,6 +24,7 @@ extension Target { srcs_objc srcs_objcpp internalHeaderFiles(project: project) + definesHeader }, hdrs: .build { // FIXME: (@yume190) TODO: pch @@ -37,7 +38,7 @@ extension Target { "-fobjc-arc", "-fPIC", "-fmodule-name=\(codegenModuleName)", - ], + ] + clangDefineFlags, enable_modules: prefer(\.enableModules), includes: headerIncludes(project: project), module_name: codegenModuleName, diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 6c6b48d..0887652 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -49,6 +49,7 @@ extension Target { linkopts: sdkLinkopts, swiftc_inputs: .build { bridgingHeader + definesHeader }, testonly: isTest, visibility: .private)) @@ -99,7 +100,7 @@ extension Target { func swiftCopts(project: Project) -> [String]? { var copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts if bridgingHeader != nil { - copts += swiftIncludeCopts(project: project) + copts += swiftIncludeCopts(project: project) + clangDefineCopts() } return copts.isEmpty ? nil : copts } @@ -108,7 +109,7 @@ extension Target { /// declarations through its own clang module instead. The module's headers can /// still reach for the target's include paths, so `swiftc` needs them too. func moduleSwiftCopts(project: Project) -> [String]? { - let copts = parseAsLibraryCopts + swiftIncludeCopts(project: project) + let copts = parseAsLibraryCopts + swiftIncludeCopts(project: project) + clangDefineCopts() return copts.isEmpty ? nil : copts } diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index cdaa50f..4305d2f 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -62,6 +62,25 @@ extension Bazel { try prepareSiblingHeaders(target: target, project: project, sourcesRoot: sourcesRoot) try prepareModuleHeaders(target: target, project: project, targetRoot: targetRoot) + try prepareDefinesHeader(target: target, targetRoot: targetRoot) + } + + /// `GCC_PREPROCESSOR_DEFINITIONS` as a header the compiles force-include. + private func prepareDefinesHeader(target: Target, targetRoot: Path) throws { + guard let relativePath = target.definesHeader else { return } + + let definitions = (target.prefer(\.preprocessorDefinitions) ?? []).map { definition in + guard let separator = definition.firstIndex(of: "=") else { + return "#define \(definition) 1" + } + let key = definition[.. 1 { + return String(dropFirst().dropLast()) + } + return String(self) + } +} + extension XCode.BuildSettings { /// Xcode spells a reference `$(NAME)` or `${NAME}` and allows a modifier: /// `$(PRODUCT_NAME:rfc1034identifier)`. From 4061f602c04d4b0fdcac11208a01cd17f2740cd3 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 18:12:18 +0800 Subject: [PATCH 076/173] Honor OTHER_LDFLAGS Carries link-time contracts a build cannot do without, such as the weak undefined symbol (-Wl,-U,...) a target needs when it references a class from a newer SDK. Passed as linkopts, with $ escaped so Bazel does not read the mangled symbol name as a Make variable. --- .../BazelizeKit/Codegen/Language/Codegen+Library.swift | 1 + .../Codegen/Language/Codegen+ObjcLibrary.swift | 1 + .../Codegen/Language/Codegen+SwiftLibrary.swift | 7 ++++++- Sources/Xcode2/Model/Config/XCode+BuildSettings.swift | 10 ++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 88bf62a..b1cdf40 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -79,6 +79,7 @@ extension Target { bridgingHeader }, includes: headerIncludes(project: project), + linkopts: sdkLinkopts, module_name: codegenModuleName, sdk_dylibs: dylibsSDK, sdk_frameworks: frameworksSDK, diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 37fa382..ea5b2e9 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -41,6 +41,7 @@ extension Target { ] + clangDefineFlags, enable_modules: prefer(\.enableModules), includes: headerIncludes(project: project), + linkopts: sdkLinkopts, module_name: codegenModuleName, sdk_dylibs: dylibsSDK, sdk_frameworks: frameworksSDK, diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 0887652..beaaa35 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -123,7 +123,12 @@ extension Target { "-l\(name.delete(prefix: "lib") ?? name)" } - let flags = searchPaths + frameworks + weakFrameworks + dylibs + // Bazel expands `$` in `linkopts` as a Make variable; a weak-symbol flag like + // `-Wl,-U,_OBJC_CLASS_$_X` has to escape it. + let extra = (prefer(\.otherLinkerFlags) ?? []).map { flag in + flag.replacingOccurrences(of: "$", with: "$$") + } + let flags = searchPaths + frameworks + weakFrameworks + dylibs + extra return flags.isEmpty ? nil : flags } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 7e5ce2e..b2078cf 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -81,6 +81,16 @@ extension XCode.BuildSettings { } } + /// `OTHER_LDFLAGS`, without Xcode's `$(inherited)` marker. + public var otherLinkerFlags: [String] { + (self["OTHER_LDFLAGS"] ?? "") + .split(separator: " ") + .map { flag in + String(flag).unquoted + } + .filter { !$0.isEmpty && $0 != "$(inherited)" } + } + /// `GCC_PREPROCESSOR_DEFINITIONS`, without Xcode's `$(inherited)` marker. /// /// Xcode passes each entry through a shell, so a value is often quoted From 3a7d5c5a12d39a0bb74be642e3601bcdde2be050 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 18:12:22 +0800 Subject: [PATCH 077/173] Do not link sibling applications and tools into a bundle Xcode embeds a nested app or command line tool instead of linking it; linking its library duplicated main, which broke VirtualBuddy's link step. --- Sources/BazelizeKit/XCode2Compat.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XCode2Compat.swift index 5731082..f2699ef 100644 --- a/Sources/BazelizeKit/XCode2Compat.swift +++ b/Sources/BazelizeKit/XCode2Compat.swift @@ -44,8 +44,25 @@ extension Target { return productType.contains("app-extension") } + /// A product that carries its own entry point or is a standalone bundle cannot be + /// linked into another target: Xcode embeds it instead, and linking it would + /// duplicate `main`. + fileprivate func isLinkableTarget(_ name: String, in project: Project) -> Bool { + guard let productType = project.target(named: name)?.productType else { return true } + + switch productType { + case "com.apple.product-type.application", + "com.apple.product-type.tool", + "com.apple.product-type.bundle.unit-test", + "com.apple.product-type.bundle.ui-testing": + return false + default: + return !productType.contains("app-extension") + } + } + fileprivate func linkedTargetDependencyNames(project: Project) -> [String] { - dependencies.targets.filter { !isExtensionTarget($0, in: project) } + dependencies.targets.filter { isLinkableTarget($0, in: project) } } fileprivate func embeddedExtensionTargetNames(project: Project) -> [String] { From 001838709c8df11e0796873f0e8be1aeaea0c41f Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 18:25:29 +0800 Subject: [PATCH 078/173] Resolve entitlements into the generated tree Xcode expands build settings and the team prefix while signing; rules_apple substitutes neither and its plisttool fails the build on a variable it cannot resolve, which is what an entitlements file referencing $(AppIdentifierPrefix) or a project-specific $(BUNDLE_ID_PREFIX) hit. The file is now rewritten into Generated/ with the settings expanded, $(AppIdentifierPrefix) taken from DEVELOPMENT_TEAM, and any entry that stays unresolvable dropped. Applications pass it too: only extensions used to. --- Sources/BazelRules/Rules+Apple.swift | 4 + .../Codegen/CodeGen+Extension.swift | 7 +- .../Codegen/Codegen+Application.swift | 2 + .../BazelizeKit/Codegen/Codegen+Plist.swift | 18 +-- .../Resource/Codegen+Entitlements.swift | 118 ++++++++++++++++++ .../Roadmap/BazelizeKit+Roadmap.swift | 16 +++ 6 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index b8d6105..4efb295 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -157,6 +157,7 @@ extension Rules.Apple.IOS { bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, extensions: [Starlark.Label]? = nil, families: [String]? = nil, infoplists: Starlark.Value? = nil, @@ -175,6 +176,7 @@ extension Rules.Apple.IOS { if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } if let extensions { "extensions" => extensions } if let families { "families" => families } if let infoplists { "infoplists" => infoplists } @@ -442,6 +444,7 @@ extension Rules.Apple.MacOS { bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, resources: Starlark.Value? = nil, @@ -455,6 +458,7 @@ extension Rules.Apple.MacOS { if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } if let resources { "resources" => resources } diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index aa2903f..e232500 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -29,7 +29,7 @@ extension Target { ":\(name)_library" frameworks }, - entitlements: entitlementsLabel, + entitlements: entitlementsLabel(project: kit.project), families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { plistFile(kit) @@ -39,9 +39,4 @@ extension Target { minimum_os_version: prefer(\.platform.iOS), visibility: .public)) } - - private var entitlementsLabel: Starlark.Label? { - guard let entitlements = metadata.entitlements else { return nil } - return .named("Sources/\(entitlements)") - } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index b257545..4c6d614 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -78,6 +78,7 @@ extension Target { ":\(name)_library" linkedFrameworks(project: project) }, + entitlements: entitlementsLabel(project: project), extensions: embeddedExtensions(project: project), families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { @@ -106,6 +107,7 @@ extension Target { deps: .build { ":\(name)_library" }, + entitlements: entitlementsLabel(project: kit.project), infoplists: .build { plistFile(kit) plist_auto diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index 754dbbb..d24397f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -128,7 +128,7 @@ extension Target { guard !keys.contains(key.name) else { continue } - if xml.hasUnresolvedBuildSettingReference { + if xml.hasUnresolvedBuildSettingReference() { Log.codeGenerate.warning(""" Drop Info.plist key \(key.name, privacy: .public) of \ \(name, privacy: .public): unresolved build setting reference @@ -146,11 +146,11 @@ extension Target { extension String { /// Xcode accepts both `$(SETTING)` and `${SETTING}`. - fileprivate static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)(?::[A-Za-z0-9_]+)?[)}]"# + static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)(?::[A-Za-z0-9_]+)?[)}]"# /// Variables `plisttool` substitutes itself; leaving them intact keeps /// rules_apple in charge of the bundle identity it also validates. - fileprivate static let plistToolVariables: Set = [ + static let plistToolVariables: Set = [ "BUNDLE_NAME", "DEVELOPMENT_LANGUAGE", "EXECUTABLE_NAME", @@ -163,7 +163,11 @@ extension String { /// settings. `plisttool` only knows a handful of variables, so anything else /// copied out of an Xcode `Info.plist` would either reach the bundle verbatim /// or collide with a resolved value in another fragment. - fileprivate func resolvingBuildSettingReferences(with settings: BuildSettings) -> String { + func resolvingBuildSettingReferences( + with settings: BuildSettings, + reserved: Set = Self.plistToolVariables) + -> String + { guard let regex = try? NSRegularExpression(pattern: Self.buildSettingPattern) else { return self } let matches = regex.matches(in: self, range: NSRange(startIndex..., in: self)) @@ -178,7 +182,7 @@ extension String { } let key = String(self[keyRange]) - guard !Self.plistToolVariables.contains(key), let value = settings[key] else { continue } + guard !reserved.contains(key), let value = settings[key] else { continue } result.replaceSubrange(wholeRange, with: value) } @@ -200,12 +204,12 @@ extension String { /// `$(SETTING)` references left after resolution, excluding the ones /// `plisttool` substitutes itself. - fileprivate var hasUnresolvedBuildSettingReference: Bool { + func hasUnresolvedBuildSettingReference(reserved: Set = Self.plistToolVariables) -> Bool { guard let regex = try? NSRegularExpression(pattern: Self.buildSettingPattern) else { return false } return regex.matches(in: self, range: NSRange(startIndex..., in: self)).contains { match in guard let keyRange = Range(match.range(at: 1), in: self) else { return false } - return !Self.plistToolVariables.contains(String(self[keyRange])) + return !reserved.contains(String(self[keyRange])) } } } diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift new file mode 100644 index 0000000..c62df0b --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift @@ -0,0 +1,118 @@ +import Foundation +import PathKit +import Starlark +import Util +import XCode2 + +extension Target { + /// The entitlements Xcode signs with, rewritten into the generated tree. + /// + /// The source file is written for Xcode, which expands build settings and the + /// team prefix while signing; rules_apple substitutes neither, and its + /// `plisttool` fails the build on a variable it does not know. + /// Variables rules_apple's `plisttool` substitutes into entitlements itself. + fileprivate static let entitlementVariables: Set = ["CFBundleIdentifier"] + + var entitlementsPath: String? { + guard let entitlements = metadata.entitlements, !entitlements.isEmpty else { return nil } + return "Generated/\(Path(entitlements).lastComponent)" + } + + func entitlementsLabel(project: Project?) -> Starlark.Label? { + entitlementsContent(project: project) == nil ? nil : .named(entitlementsPath ?? "") + } + + /// `nil` when the target declares no entitlements, or when the file is missing: + /// the rule attribute has to disappear with it. + func entitlementsContent(project: Project?) -> String? { + guard let project, let entitlements = metadata.entitlements, !entitlements.isEmpty else { return nil } + + let path = Path(project.workspacePath) + entitlements + guard + let content: String = try? path.read(), + let document = try? XMLDocument(xmlString: content, options: .documentXInclude), + let root = document.rootElement(), + let dict = root.elements(forName: "dict").first + else { + return nil + } + + let entries = resolvedEntitlementEntries(dict.children ?? []) + dict.setChildren(nil) + for entry in entries { + dict.addChild(entry) + } + + return document.xmlString(options: [.nodePrettyPrint, .nodePreserveAll]) + "\n" + } + + // MARK: Private + + /// The `dict` is a flat ``/value sequence, so a dropped key takes the + /// element that follows it with it. + private func resolvedEntitlementEntries(_ nodes: [XMLNode]) -> [XMLElement] { + let settings = selectedSettings + var result: [XMLElement] = [] + var pendingKey: XMLElement? + + for node in nodes { + guard let element = node as? XMLElement else { continue } + element.detach() + element.resolveEntitlementVariables(with: settings, teamPrefix: teamPrefix) + + if element.name == "key" { + pendingKey = element + continue + } + + guard let key = pendingKey else { + result.append(element) + continue + } + pendingKey = nil + + let xml = element.xmlString(options: [.nodePreserveAll]) + if xml.hasUnresolvedBuildSettingReference(reserved: Self.entitlementVariables) { + Log.codeGenerate.warning(""" + Drop entitlement \(key.stringValue ?? "", privacy: .public) of \ + \(name, privacy: .public): unresolved build setting reference + """) + continue + } + + result.append(key) + result.append(element) + } + + return result + } + + /// What Xcode expands `$(AppIdentifierPrefix)` to: the team that signs the + /// bundle, followed by a dot. rules_apple reads it off a provisioning profile, + /// which a generated workspace has none of. + private var teamPrefix: String? { + guard let team = prefer(\.metadata.developmentTeam), !team.isEmpty else { return nil } + return "\(team)." + } +} + +extension XMLElement { + fileprivate func resolveEntitlementVariables(with settings: BuildSettings, teamPrefix: String?) { + let elements = (children ?? []).compactMap { $0 as? XMLElement } + + // Setting `stringValue` replaces the children, so only a leaf is rewritten: + // an `` or a nested `` recurses instead. + if elements.isEmpty { + if let value = stringValue { + stringValue = value + .replacingOccurrences(of: "$(AppIdentifierPrefix)", with: teamPrefix ?? "$(AppIdentifierPrefix)") + .resolvingBuildSettingReferences(with: settings, reserved: Target.entitlementVariables) + } + return + } + + for element in elements { + element.resolveEntitlementVariables(with: settings, teamPrefix: teamPrefix) + } + } +} diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 4305d2f..67ba194 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -63,6 +63,22 @@ extension Bazel { try prepareSiblingHeaders(target: target, project: project, sourcesRoot: sourcesRoot) try prepareModuleHeaders(target: target, project: project, targetRoot: targetRoot) try prepareDefinesHeader(target: target, targetRoot: targetRoot) + try prepareEntitlements(target: target, project: project, targetRoot: targetRoot) + } + + /// The entitlements Xcode signs with, expanded: rules_apple substitutes no + /// build setting, and its `plisttool` fails on a variable it cannot resolve. + private func prepareEntitlements(target: Target, project: Project, targetRoot: Path) throws { + guard + let relativePath = target.entitlementsPath, + let content = target.entitlementsContent(project: project) + else { + return + } + + let destination = targetRoot + relativePath + try destination.parent().mkpath() + try destination.write(content) } /// `GCC_PREPROCESSOR_DEFINITIONS` as a header the compiles force-include. From 85633b50616a5c6d5b1b010aef08f4f4f5e90903 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 21:40:20 +0800 Subject: [PATCH 079/173] Expand a variant group into its localized files One build file references the whole group, and XcodeProj resolves it to a single child, so every localization but one went missing: MacPass shipped no .strings at all. The group's children are read relative to its parent, which is where each .lproj directory actually lives. --- Sources/Xcode2/Loader/XCode+FileLoader.swift | 7 +++- .../Xcode2/Loader/XCode+TargetLoader.swift | 35 +++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index fc69742..8dfb481 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -58,6 +58,10 @@ struct FileLoader { let native: PBXFileElement unowned let project: ProjectLoader + /// A variant group's child resolves against the group, which carries no + /// directory of its own, so its path has to be supplied. + var pathOverride: String? + var name: String? { native.name ?? native.path } @@ -74,7 +78,8 @@ struct FileLoader { } var fullPath: String? { - try? native.fullPath(sourceRoot: project.workspacePath.string) + if let pathOverride { return pathOverride } + return try? native.fullPath(sourceRoot: project.workspacePath.string) } var fileType: String? { diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index b5a4c84..db62486 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -330,12 +330,35 @@ struct TargetLoader { } private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [XCode.File] { - buildFiles.compactMap { buildFile in - guard let file = buildFile.file else { return nil } - return FileLoader(native: file, project: project).file( - buildPhase: buildPhase.rawValue, - compilerFlags: buildFile.compilerFlags, - attributes: buildFile.attributes ?? []) + buildFiles.flatMap { buildFile -> [XCode.File] in + guard let file = buildFile.file else { return [] } + + /// A localized resource is one build file referencing a variant group; + /// what Xcode copies into the bundle are its children, one `.lproj` + /// directory per language. + guard let variant = file as? PBXVariantGroup else { + return [ + FileLoader(native: file, project: project).file( + buildPhase: buildPhase.rawValue, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? []), + ] + } + + let root = project.workspacePath.string + let base = try? variant.parent?.fullPath(sourceRoot: root) + + return variant.children.compactMap { child in + guard let path = child.path else { return nil } + return FileLoader( + native: child, + project: project, + pathOverride: base.map { "\($0)/\(path)" }) + .file( + buildPhase: buildPhase.rawValue, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? []) + } } } } From 19d61848edec821aa44fa9a37e92a3825b54208d Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 21:40:35 +0800 Subject: [PATCH 080/173] Bundle a target's resources Xcode copies everything in the Resources build phase into the product; the generated rules carried only the asset catalog, so an app linked and bundled but shipped no nib and no localization and died on launch. A Resources filegroup now feeds every bundle rule, and the localized .strings reach the macOS, tvOS and watchOS rules the way they already did on iOS. --- .../Codegen/Codegen+Application.swift | 26 ++++++- .../Codegen/Codegen+TestHost.swift | 39 ++++++++++ .../Codegen/Language/Codegen+Library.swift | 1 + .../Codegen/Resource/Codegen+Resources.swift | 78 +++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 Sources/BazelizeKit/Codegen/Codegen+TestHost.swift create mode 100644 Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 4c6d614..2187a4f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -61,7 +61,12 @@ extension Target { }, minimum_os_version: prefer(\.platform.watchOS), resources: .build { - resources + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } }, visibility: .public)) } @@ -88,6 +93,9 @@ extension Target { }, // "launch_storyboard" => ":Base.lproj/LaunchScreen.storyboard" minimum_os_version: prefer(\.platform.iOS), + resources: .build { + bundleResources(project: project) + }, sdk_frameworks: frameworksSDK, strings: .build { if !allStrings.isEmpty { @@ -114,6 +122,14 @@ extension Target { plistDefault(kit) }, minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, visibility: .public)) } @@ -134,7 +150,12 @@ extension Target { }, minimum_os_version: prefer(\.platform.tvOS), resources: .build { - resources + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } }, visibility: .public)) } @@ -154,4 +175,5 @@ extension Target { return iconGlobs.isEmpty ? nil : Starlark.glob(iconGlobs) } + } diff --git a/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift b/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift new file mode 100644 index 0000000..d2a9969 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift @@ -0,0 +1,39 @@ +import Foundation +import PathKit +import Starlark +import XCode2 + +extension Target { + /// The application a unit-test bundle is loaded into, from `TEST_HOST` or + /// `BUNDLE_LOADER`. + /// + /// Xcode resolves the bundle's undefined symbols against the host executable + /// with `-bundle_loader`, and its project-wide header map lets the test include + /// the host's headers by name. A Bazel test bundle has neither, so the host's + /// library is linked into it: that covers both. + func testHostLibraries(project: Project?) -> [Starlark.Label] { + guard isTest, let host = testHostTarget(project: project) else { return [] } + + let label = Starlark.Label.named("//Targets/\(host.name):\(host.name)_library") + /// A test target usually also declares the host as a target dependency, and + /// Bazel rejects a duplicated label in `deps`. + return frameworksLibrary.contains(label) ? [] : [label] + } + + // MARK: Private + + private func testHostTarget(project: Project?) -> Target? { + guard let project, let name = hostBundleName else { return nil } + return project.targets.first { $0.name == name } + } + + /// `TEST_HOST` points at the executable inside the host bundle, e.g. + /// `$(BUILT_PRODUCTS_DIR)/MacPass.app/Contents/MacOS/MacPass`. + private var hostBundleName: String? { + guard let setting = prefer(\.testHost) ?? prefer(\.bundleLoader) else { return nil } + + let components = Path(setting).components + guard let bundle = components.first(where: { $0.hasSuffix(".app") }) else { return nil } + return String(bundle.dropLast(".app".count)) + } +} diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index b1cdf40..d777a9e 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -19,6 +19,7 @@ extension Target { let cFamily = srcs_c + srcs_cpp + srcs_objc + srcs_objcpp generateAssets(builder, kit) + generateResources(builder, kit) switch (cFamily.isEmpty, srcs_swift.isEmpty) { case (true, false): diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift new file mode 100644 index 0000000..0bd4dd3 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift @@ -0,0 +1,78 @@ +import BazelRules +import Foundation +import PathKit +import Starlark +import XCode2 + +extension Target { + static let resourceGroupName = "Resources" + + /// Everything Xcode's Resources build phase copies into the bundle, minus what a + /// dedicated rule attribute already owns: an asset catalog, a `.strings` table + /// and an app icon set. + /// + /// Without this a generated app links and bundles, but ships no nib and no + /// localization, so it dies the moment it is launched. + func generateResources(_ builder: CodeBuilder, _ kit: Kit) { + let patterns = resourcePatterns(project: kit.project) + guard !patterns.isEmpty else { return } + + builder.call( + Rules.Builtin.Call.filegroup( + name: Self.resourceGroupName, + srcs: Starlark.glob(patterns), + visibility: .private)) + } + + /// The bundle rule's `resources`: the group above plus the asset catalog. + func bundleResources(project: Project?) -> [Starlark.Label] { + var labels: [Starlark.Label] = [] + if let project, !resourcePatterns(project: project).isEmpty { + labels.append(.named(":\(Self.resourceGroupName)")) + } + if !assets.isEmpty { + labels.append(.named(":Assets")) + } + return labels + } + + // MARK: Private + + /// A resource is either a file or a folder reference — Xcode copies a folder + /// whole — so a directory becomes a recursive glob. + private func resourcePatterns(project: Project) -> [String] { + let workspace = Path(project.workspacePath) + + let patterns = resources.compactMap { resource -> String? in + guard !Self.ownedResourceExtensions.contains(Path(resource).extension ?? "") else { return nil } + + /// The model already addresses a file through the target's `Sources/` + /// tree; the project is where it is read from. + let source = workspace + Path(resource.droppingSourcesPrefix) + guard source.exists else { return nil } + return source.isDirectory ? "\(resource)/**" : resource + } + + return Array(Set(patterns)).sorted() + } + + /// Resources another attribute of the same rule already carries: passing them + /// twice makes rules_apple fail on a duplicated bundle path. + /// + /// An Icon Composer `.icon` bundle is dropped for a different reason: `actool` + /// refuses one whose `icon.json` is a symlink, and every file a Bazel action + /// sees is a symlink. Leaving it in the resources also makes rules_apple reject + /// the `.appiconset` the same project still ships. + private static let ownedResourceExtensions: Set = [ + "icon", + "intentdefinition", + "strings", + "xcassets" + ] +} + +extension String { + fileprivate var droppingSourcesPrefix: String { + hasPrefix("Sources/") ? String(dropFirst("Sources/".count)) : self + } +} From 974646fcf51fee8a2d3cc020b064109f7b0e5030 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 21:40:44 +0800 Subject: [PATCH 081/173] Fall back when a version is not a version MacPass ships a literal UNDEFINED as CFBundleVersion and sets CURRENT_PROJECT_VERSION to ${CURRENT_PROJECT_VERSION}; Xcode copies both through and lets a release script fill them in, while rules_apple validates the format and fails the build. An unusable value is dropped so the template default applies. --- .../BazelizeKit/Codegen/Codegen+Plist.swift | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index d24397f..b0a55ec 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -136,12 +136,45 @@ extension Target { continue } + if isInvalidVersion(key: key.name, value: element.stringValue ?? "") { + Log.codeGenerate.warning(""" + Drop Info.plist key \(key.name, privacy: .public) of \ + \(name, privacy: .public): value is not a valid version + """) + continue + } + result.append(key.xml) result.append(xml) } return result } + + /// Xcode ships whatever the `Info.plist` says and lets a release script fill + /// the real number in later — MacPass writes a literal `UNDEFINED`. rules_apple + /// validates the format instead, so an unusable value is dropped and the + /// build-setting default takes over. + private func isInvalidVersion(key: String, value: String) -> Bool { + guard let pattern = Self.versionPatterns[key] else { return false } + guard !value.contains("$(") else { return false } + return value.range(of: pattern, options: .regularExpression) == nil + } + + /// What rules_apple accepts for each key, mirroring its `plisttool`. + private static let versionPatterns: [String: String] = [ + "CFBundleVersion": #"^[0-9]+(\.[0-9]+){0,3}([a-z]+[0-9]{1,3})?$"#, + "CFBundleShortVersionString": #"^[0-9]+(\.[0-9]+){0,3}$"#, + ] + + /// A build setting is no better a source than the `Info.plist`: MacPass sets + /// `CURRENT_PROJECT_VERSION` to `${CURRENT_PROJECT_VERSION}`, which is neither a + /// version nor something `plist_fragment` can carry. + private func version(_ value: String?, key: String) -> String? { + guard let value, !value.isEmpty, !value.contains("$") else { return nil } + guard let pattern = Self.versionPatterns[key] else { return value } + return value.range(of: pattern, options: .regularExpression) == nil ? nil : value + } } extension String { @@ -319,10 +352,12 @@ extension Target { /// `plisttool` cannot resolve these, and rules_apple rejects a bundle /// without them, so an unset setting falls back to Xcode's own template /// values instead of a literal `$(SETTING)`. - ("CFBundleVersion", settings.generatedPlist.currentProjectVersion ?? "1"), + ("CFBundleVersion", version(settings.generatedPlist.currentProjectVersion, key: "CFBundleVersion") ?? "1"), ("CFBundleExecutable", "$(EXECUTABLE_NAME)"), ("CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"), - ("CFBundleShortVersionString", settings.generatedPlist.marketingVersion ?? "1.0"), + ( + "CFBundleShortVersionString", + version(settings.generatedPlist.marketingVersion, key: "CFBundleShortVersionString") ?? "1.0"), ] return defaults From 36eeb2f0b70ffc7d86b3549550017cf5a12c2823 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 21:41:23 +0800 Subject: [PATCH 082/173] Support GCC_PREFIX_HEADER Xcode force-includes the prefix header into every C-family compile, which is how a MacPass header gets away with using APPKIT_EXTERN without importing AppKit. Passed with -include, next to the generated defines header, and declared as an input so the sandbox stages it. --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 24 ++++++++++++++----- .../Codegen/Language/Codegen+Library.swift | 3 ++- .../Language/Codegen+ObjcLibrary.swift | 5 ++-- .../Language/Codegen+SwiftLibrary.swift | 5 ++-- .../Roadmap/BazelizeKit+Roadmap.swift | 1 + .../Model/Config/XCode+BuildSettings.swift | 8 +++++++ 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 87e18b7..4b8e19b 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -97,16 +97,28 @@ extension Target { (prefer(\.preprocessorDefinitions) ?? []).isEmpty ? nil : Self.definesHeaderPath } - var clangDefineFlags: [String] { - guard let definesHeader else { return [] } - return ["-include", "Targets/\(name)/\(definesHeader)"] + /// `GCC_PREFIX_HEADER`, relative to the target's `Sources/` tree. + var prefixHeader: String? { + guard let header = prefer(\.prefixHeader), !header.hasPrefix("/") else { return nil } + return "Sources/\(Path(header).normalize().string)" + } + + private var prefixHeaderFlags: [String] { + guard let prefixHeader else { return [] } + return ["-include", "Targets/\(name)/\(prefixHeader)"] + } + + var forceIncludeFlags: [String] { + guard let definesHeader else { return prefixHeaderFlags } + return ["-include", "Targets/\(name)/\(definesHeader)"] + prefixHeaderFlags } /// The same header, force-included into `swiftc`'s clang importer so a bridging /// or umbrella header can rely on the definitions. - func clangDefineCopts() -> [String] { - guard let definesHeader else { return [] } - return ["-Xcc", "-include", "-Xcc", "Targets/\(name)/\(definesHeader)"] + func forceIncludeCopts() -> [String] { + forceIncludeFlags.flatMap { flag in + ["-Xcc", flag] + } } /// The same include paths, spelled for `swiftc`'s clang importer. diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index d777a9e..f942451 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -55,7 +55,7 @@ extension Target { "-fobjc-arc", "-fPIC", "-fmodule-name=\(codegenModuleName)", - ] + clangDialectCopts + clangDefineFlags, + ] + clangDialectCopts + forceIncludeFlags, clang_srcs: .build { srcs_c srcs_cpp @@ -74,6 +74,7 @@ extension Target { enable_modules: prefer(\.enableModules), hdrs: .build { flattenedModuleHeaderFiles(project: project) + prefixHeader /// A mixed target gets the bridging header's declarations through /// its own clang module: `swiftc` rejects `-import-objc-header` /// while building a module. diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index ea5b2e9..764c2e1 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -27,18 +27,19 @@ extension Target { definesHeader }, hdrs: .build { - // FIXME: (@yume190) TODO: pch flattenedModuleHeaderFiles(project: project) + prefixHeader }, deps: .build { frameworksLibrary + applicationHost(project: project) }, copts: [ "-fblocks", "-fobjc-arc", "-fPIC", "-fmodule-name=\(codegenModuleName)", - ] + clangDefineFlags, + ] + forceIncludeFlags, enable_modules: prefer(\.enableModules), includes: headerIncludes(project: project), linkopts: sdkLinkopts, diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index beaaa35..5211447 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -50,6 +50,7 @@ extension Target { swiftc_inputs: .build { bridgingHeader definesHeader + prefixHeader }, testonly: isTest, visibility: .private)) @@ -100,7 +101,7 @@ extension Target { func swiftCopts(project: Project) -> [String]? { var copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts if bridgingHeader != nil { - copts += swiftIncludeCopts(project: project) + clangDefineCopts() + copts += swiftIncludeCopts(project: project) + forceIncludeCopts() } return copts.isEmpty ? nil : copts } @@ -109,7 +110,7 @@ extension Target { /// declarations through its own clang module instead. The module's headers can /// still reach for the target's include paths, so `swiftc` needs them too. func moduleSwiftCopts(project: Project) -> [String]? { - let copts = parseAsLibraryCopts + swiftIncludeCopts(project: project) + clangDefineCopts() + let copts = parseAsLibraryCopts + swiftIncludeCopts(project: project) + forceIncludeCopts() return copts.isEmpty ? nil : copts } diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 67ba194..7b28250 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -231,6 +231,7 @@ extension XCode2.XCode.Target { fileprivate var settingReferencedPaths: [String] { [ prefer(\.bridgingHeader), + prefer(\.prefixHeader), metadata.entitlements, ] .compactMap { $0 } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index b2078cf..a923359 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -65,6 +65,14 @@ extension XCode.BuildSettings { public var swiftDefine: String? { self["OTHER_SWIFT_FLAGS"] } public var bridgingHeader: String? { self["SWIFT_OBJC_BRIDGING_HEADER"] } + /// `GCC_PREFIX_HEADER`: a header Xcode force-includes into every C-family + /// compile of the target, which is how a source file gets away without + /// importing the framework it uses. + public var prefixHeader: String? { + guard let header = self["GCC_PREFIX_HEADER"]?.unquoted, !header.isEmpty else { return nil } + return header + } + /// `HEADER_SEARCH_PATHS` plus `USER_HEADER_SEARCH_PATHS`, without Xcode's /// `$(inherited)` marker. public var headerSearchPaths: [String] { From 923c378a3178eeb0a81712d98da2b593883fb8b5 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 21:41:42 +0800 Subject: [PATCH 083/173] Link a test bundle against its host TEST_HOST is how a unit test reaches the application it tests: Xcode resolves the bundle's symbols with -bundle_loader and its project-wide header map exposes the host's headers. A Bazel test bundle has neither, so the host's library is linked in, which covers both. Replaces the Swift-only applicationHost, so an Objective-C test target gets the same treatment, and keeps a sibling application out of any other target's deps. --- .../Codegen/Language/Codegen+Library.swift | 2 +- .../Language/Codegen+ObjcLibrary.swift | 2 +- .../Language/Codegen+SwiftLibrary.swift | 21 +------------------ Sources/BazelizeKit/XCode2Compat.swift | 7 +++++++ 4 files changed, 10 insertions(+), 22 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index f942451..8610056 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -95,7 +95,7 @@ extension Target { weak_sdk_frameworks: weakFrameworksSDK, deps: .build { linkedFrameworksLibrary(project: project) - applicationHost(project: project) + testHostLibraries(project: project) plugin builtins }, diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 764c2e1..995cab4 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -32,7 +32,7 @@ extension Target { }, deps: .build { frameworksLibrary - applicationHost(project: project) + testHostLibraries(project: project) }, copts: [ "-fblocks", diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 5211447..cd2a10f 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -34,7 +34,7 @@ extension Target { deps: .build { extraDeps linkedFrameworksLibrary(project: project) - applicationHost(project: project) + testHostLibraries(project: project) plugin builtins }, @@ -158,23 +158,4 @@ extension Target { }.starlark } - /// Unittest's dependency from application - /// - /// BUNDLE_LOADER - /// $(TEST_HOST) - /// TEST_HOST - /// $(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example - /// build/Debug-iphoneos/Example.app//Example - func applicationHost(project: Project) -> String? { - guard let host = prefer(\.testHost) else { return nil } - guard let _ = prefer(\.bundleLoader) else { return nil } - guard let targetName = host.components(separatedBy: "/").last else { return nil } - - let label = "//Targets/\(targetName):\(targetName)_library" - /// A test target usually also declares the host as a target dependency, and - /// Bazel rejects a duplicated label in `deps`. - guard !linkedFrameworksLibrary(project: project).contains(where: { $0.value == label }) else { return nil } - - return label - } } diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XCode2Compat.swift index f2699ef..bc99974 100644 --- a/Sources/BazelizeKit/XCode2Compat.swift +++ b/Sources/BazelizeKit/XCode2Compat.swift @@ -50,6 +50,13 @@ extension Target { fileprivate func isLinkableTarget(_ name: String, in project: Project) -> Bool { guard let productType = project.target(named: name)?.productType else { return true } + /// A test bundle is the exception: Xcode loads it into the host process, so + /// the host's code has to be reachable. Bazel has no `-bundle_loader` + /// equivalent for a logic test, so the host is linked in. + if isTest, productType == "com.apple.product-type.application" { + return true + } + switch productType { case "com.apple.product-type.application", "com.apple.product-type.tool", From 5f7203338220b272c1d256cd0c8c72f2d8be38bb Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 22:01:41 +0800 Subject: [PATCH 084/173] Bundle a framework's and an extension's resources too Only the application rules carried them, so a framework shipped no resource at all: every Stats module reads a config.plist out of its own bundle and the app died on launch. --- Sources/BazelizeKit/Codegen/CodeGen+Extension.swift | 8 ++++++++ Sources/BazelizeKit/Codegen/Codegen+Framework.swift | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index e232500..e81b524 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -37,6 +37,14 @@ extension Target { plistDefault(kit) }, minimum_os_version: prefer(\.platform.iOS), + resources: .build { + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, visibility: .public)) } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index 074ec9e..d4a371a 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -26,6 +26,14 @@ extension Target { plistDefault(kit) }, minimum_os_version: prefer(\.platform.iOS), + resources: .build { + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, visibility: .public)) } @@ -44,6 +52,9 @@ extension Target { plistDefault(kit) }, minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: kit.project) + }, visibility: .public)) } } From 640fb5ac475160be077225eabf69028f9fdfa243 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 22:01:48 +0800 Subject: [PATCH 085/173] Filter an Objective-C library's deps like the others It used the unfiltered dependency list, so a sibling application or test bundle ended up in its deps. --- Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 995cab4..b00d499 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -31,7 +31,7 @@ extension Target { prefixHeader }, deps: .build { - frameworksLibrary + linkedFrameworksLibrary(project: project) testHostLibraries(project: project) }, copts: [ From 7681b1090e8dbfc53a29523052b224030a0373b1 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 22:02:18 +0800 Subject: [PATCH 086/173] Embed the frameworks Xcode copies into a bundle A framework in the Copy Files phase is loaded at runtime, so its resources live in it and Bundle(for:) resolves there; linking its library into the app instead loses both, which is how every Stats module lost its config.plist. Passed in the rule's frameworks attribute, where rules_apple keeps those symbols out of the embedding binary. rules_apple also enforces what Xcode does not: an embedded bundle carries the parent's version and an identifier prefixed with the parent's. Both are now derived from the embedding bundle, and a framework two applications embed keeps being linked statically because one bundle cannot prefix both. --- Sources/BazelRules/Rules+Apple.swift | 4 + .../Codegen/CodeGen+Extension.swift | 2 +- .../Codegen/Codegen+Application.swift | 12 +-- .../Codegen/Codegen+Framework.swift | 4 +- .../BazelizeKit/Codegen/Codegen+Plist.swift | 51 ++++++++++++- Sources/BazelizeKit/XCode2Compat.swift | 75 ++++++++++++++++++- 6 files changed, 135 insertions(+), 13 deletions(-) diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index 4efb295..0dca476 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -159,6 +159,7 @@ extension Rules.Apple.IOS { deps: Starlark.Value? = nil, entitlements: Starlark.Label? = nil, extensions: [Starlark.Label]? = nil, + frameworks: [Starlark.Label]? = nil, families: [String]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, @@ -178,6 +179,7 @@ extension Rules.Apple.IOS { if let deps { "deps" => deps } if let entitlements { "entitlements" => entitlements } if let extensions { "extensions" => extensions } + if let frameworks, !frameworks.isEmpty { "frameworks" => frameworks } if let families { "families" => families } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } @@ -445,6 +447,7 @@ extension Rules.Apple.MacOS { bundle_name: String? = nil, deps: Starlark.Value? = nil, entitlements: Starlark.Label? = nil, + frameworks: [Starlark.Label]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, resources: Starlark.Value? = nil, @@ -459,6 +462,7 @@ extension Rules.Apple.MacOS { if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } if let entitlements { "entitlements" => entitlements } + if let frameworks, !frameworks.isEmpty { "frameworks" => frameworks } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } if let resources { "resources" => resources } diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index e81b524..e36a745 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -24,7 +24,7 @@ extension Target { builder.call( Rules.Apple.IOS.Call.ios_extension( name: name, - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" frameworks diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 2187a4f..87bc7b4 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -29,7 +29,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_command_line_application( name: name, - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" }, @@ -49,7 +49,7 @@ extension Target { builder.call( Rules.Apple.WatchOS.Call.watchos_application( name: name, - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" frameworks @@ -78,13 +78,14 @@ extension Target { Rules.Apple.IOS.Call.ios_application( name: name, app_icons: appIcons(project: kit.project), - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" linkedFrameworks(project: project) }, entitlements: entitlementsLabel(project: project), extensions: embeddedExtensions(project: project), + frameworks: embeddedFrameworks(project: project), families: prefer(\.platform.deviceFamily)?.map(\.code), infoplists: .build { plistFile(kit) @@ -111,11 +112,12 @@ extension Target { Rules.Apple.MacOS.Call.macos_application( name: name, app_icons: appIcons(project: kit.project), - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" }, entitlements: entitlementsLabel(project: kit.project), + frameworks: embeddedFrameworks(project: kit.project), infoplists: .build { plistFile(kit) plist_auto @@ -138,7 +140,7 @@ extension Target { builder.call( Rules.Apple.TVOS.Call.tvos_application( name: name, - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" frameworks diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index d4a371a..fb55dfa 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -13,7 +13,7 @@ extension Target { builder.call( Rules.Apple.IOS.Call.ios_framework( name: name, - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), /// Only the target's own code: a sibling framework is linked through /// its library, never nested inside this bundle. deps: .build { @@ -42,7 +42,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_framework( name: name, - bundle_id: prefer(\.metadata.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" }, diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index b0a55ec..eb10c2b 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -66,6 +66,30 @@ extension Target { // MARK: Private + /// One resolved value out of the target's own `Info.plist`, which outranks the + /// build setting a default would fall back to. + func infoPlistString(_ key: String, project: Project?) -> String? { + guard let nodes = infoPlistNodes(project: project) else { return nil } + + let settings = selectedSettings + var pendingKey: String? + + for node in nodes { + guard let element = node as? XMLElement else { continue } + + if element.name == "key" { + pendingKey = element.stringValue + continue + } + + defer { pendingKey = nil } + guard pendingKey == key, element.name == "string" else { continue } + return element.stringValue?.resolvingBuildSettingReferences(with: settings) + } + + return nil + } + private func infoPlistNodes(project: Project?) -> [XMLNode]? { guard let project else { return nil } guard let plistPath = prefer(\.plist.infoPlist) else { return nil } @@ -83,7 +107,11 @@ extension Target { private func plistContent(project: Project?) -> String? { guard let nodes = infoPlistNodes(project: project) else { return nil } - let dropped = appIcons(project: project) == nil ? [] : Self.iconKeys + var dropped = appIcons(project: project) == nil ? [] : Self.iconKeys + /// The version an embedded bundle declares has to give way to its parent's. + if embeddingBundle(project: project) != nil { + dropped.formUnion(Self.versionPatterns.keys) + } return entries(nodes, dropping: dropped).withNewLine.escapedForPlistFragment } @@ -298,12 +326,14 @@ extension Target { func plistDefault(_ kit: Kit) -> Starlark.Label? { defaultPlistFragments( for: selectedSettings, + project: kit.project, skipping: infoPlistKeys(project: kit.project)).isEmpty ? nil : ":plist_default" } func generatePlistDefault(_ builder: CodeBuilder, _ kit: Kit) { let plist = defaultPlistFragments( for: selectedSettings, + project: kit.project, skipping: infoPlistKeys(project: kit.project)) if !plist.isEmpty { builder.call( @@ -323,7 +353,7 @@ extension Target { private func isGeneratePlistDefault(project: Project?) -> Bool { guard project != nil else { return false } - return !defaultPlistFragments(for: selectedSettings).isEmpty + return !defaultPlistFragments(for: selectedSettings, project: project).isEmpty } /// `plisttool` substitutes only a handful of variables, so a default whose value @@ -343,21 +373,34 @@ extension Target { /// rejects two fragments that disagree on one key. private func defaultPlistFragments( for settings: BuildSettings, + project: Project?, skipping existing: Set = []) -> [String] { + /// rules_apple requires an embedded bundle to carry the version of the bundle + /// that embeds it — Apple's own rule, which Xcode never enforces. + let parent = embeddingBundle(project: project) + let currentVersion = parent.map { bundle in + bundle.infoPlistString("CFBundleVersion", project: project) + ?? bundle.prefer(\.generatedPlist.currentProjectVersion) + } ?? settings.generatedPlist.currentProjectVersion + let shortVersion = parent.map { bundle in + bundle.infoPlistString("CFBundleShortVersionString", project: project) + ?? bundle.prefer(\.generatedPlist.marketingVersion) + } ?? settings.generatedPlist.marketingVersion + let defaults = [ ("CFBundleName", "$(PRODUCT_NAME)"), ("CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"), /// `plisttool` cannot resolve these, and rules_apple rejects a bundle /// without them, so an unset setting falls back to Xcode's own template /// values instead of a literal `$(SETTING)`. - ("CFBundleVersion", version(settings.generatedPlist.currentProjectVersion, key: "CFBundleVersion") ?? "1"), + ("CFBundleVersion", version(currentVersion, key: "CFBundleVersion") ?? "1"), ("CFBundleExecutable", "$(EXECUTABLE_NAME)"), ("CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"), ( "CFBundleShortVersionString", - version(settings.generatedPlist.marketingVersion, key: "CFBundleShortVersionString") ?? "1.0"), + version(shortVersion, key: "CFBundleShortVersionString") ?? "1.0"), ] return defaults diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XCode2Compat.swift index bc99974..1d25d54 100644 --- a/Sources/BazelizeKit/XCode2Compat.swift +++ b/Sources/BazelizeKit/XCode2Compat.swift @@ -72,7 +72,7 @@ extension Target { dependencies.targets.filter { isLinkableTarget($0, in: project) } } - fileprivate func embeddedExtensionTargetNames(project: Project) -> [String] { + func embeddedExtensionTargetNames(project: Project) -> [String] { dependencies.targets.filter { isExtensionTarget($0, in: project) } } @@ -101,6 +101,9 @@ extension Target { } func linkedFrameworksLibrary(project: Project) -> [Starlark.Label] { + /// Every dependency is compiled and linked against as a library; a bundle + /// that embeds one of them passes it in `frameworks` as well, and rules_apple + /// then keeps those symbols out of the embedding binary. let targetLabels = linkedTargetDependencyNames(project: project) .sorted() .map { target in @@ -124,6 +127,76 @@ extension Target { return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } } + /// Sibling frameworks Xcode copies into the bundle: the app links against the + /// framework and loads it at runtime, so its resources stay in the framework and + /// `Bundle(for:)` resolves there. Linking its library instead loses both. + func embeddedFrameworkNames(project: Project) -> [String] { + let siblings = Set(project.targets.map(\.name)) + + let names = files.copyFiles.compactMap { file -> String? in + guard file.fileType == "wrapper.framework" else { return nil } + guard let component = file.name ?? file.path else { return nil } + return Path(component).lastComponentWithoutExtension + } + + return Array(Set(names).intersection(siblings)).sorted() + } + + /// The bundle that embeds this target, if any: an embedded bundle inherits the + /// parent's version and identifier prefix, which rules_apple insists on. + /// + /// `nil` when two applications embed it: one bundle cannot carry both prefixes, + /// so the target stays a library linked into each of them, the way it was before + /// it was recognized as embedded at all. + func embeddingBundle(project: Project?) -> Target? { + guard let project else { return nil } + + let parents = project.targets.filter { parent in + parent.name != name + && (parent.embeddedFrameworkNames(project: project).contains(name) + || parent.embeddedExtensionTargetNames(project: project).contains(name)) + } + + let applications = parents.filter { parent in + parent.productType == "com.apple.product-type.application" + } + guard applications.count <= 1 else { return nil } + + /// A framework embedded in both the app and one of its extensions follows + /// the app: that is what rules_apple compares everything to. + return applications.first ?? parents.first + } + + /// `PRODUCT_BUNDLE_IDENTIFIER`, prefixed with the identifier of the bundle that + /// embeds this one. + /// + /// Apple requires the prefix and rules_apple enforces it; Xcode does not, so a + /// framework in the same project routinely carries an unrelated identifier. + func bundleIdentifier(project: Project?) -> String? { + let own = prefer(\.metadata.bundleID) + + guard + let parent = embeddingBundle(project: project), + let parentID = parent.bundleIdentifier(project: project), + let own, !own.hasPrefix("\(parentID).") + else { + return own + } + + let suffix = own.components(separatedBy: ".").last ?? name + return "\(parentID).\(suffix)" + } + + func embeddedFrameworks(project: Project) -> [Starlark.Label] { + embeddedFrameworkNames(project: project) + .filter { target in + project.target(named: target)?.embeddingBundle(project: project)?.name == name + } + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + } + func embeddedExtensions(project: Project) -> [Starlark.Label] { embeddedExtensionTargetNames(project: project) .sorted() From 23d6d30f977f8ace29e877c3032dff3dd085bac1 Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 22:44:26 +0800 Subject: [PATCH 087/173] Bundle a macOS app extension Only iOS got an extension rule, so a macOS widget extension generated a library and nothing else: Stats shipped no PlugIns directory. The macos application now embeds what Xcode embeds, and the extension carries the same entitlements, resources and embedded frameworks as every other bundle. --- Sources/BazelRules/Rules+Apple.swift | 6 ++++ .../Codegen/CodeGen+Extension.swift | 30 +++++++++++++++++++ .../Codegen/Codegen+Application.swift | 1 + 3 files changed, 37 insertions(+) diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index 0dca476..e653416 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -447,6 +447,7 @@ extension Rules.Apple.MacOS { bundle_name: String? = nil, deps: Starlark.Value? = nil, entitlements: Starlark.Label? = nil, + extensions: [Starlark.Label]? = nil, frameworks: [Starlark.Label]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, @@ -462,6 +463,7 @@ extension Rules.Apple.MacOS { if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } if let entitlements { "entitlements" => entitlements } + if let extensions, !extensions.isEmpty { "extensions" => extensions } if let frameworks, !frameworks.isEmpty { "frameworks" => frameworks } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } @@ -500,6 +502,8 @@ extension Rules.Apple.MacOS { bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, + frameworks: [Starlark.Label]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, resources: Starlark.Value? = nil, @@ -512,6 +516,8 @@ extension Rules.Apple.MacOS { if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } + if let frameworks, !frameworks.isEmpty { "frameworks" => frameworks } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } if let resources { "resources" => resources } diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index e36a745..565fe2f 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -13,9 +13,39 @@ extension Target { func generateExtension(_ builder: CodeBuilder, _ kit: Kit) { switch platformSDK { case .iOS: buildIOS(builder, kit) + case .macOS: buildMac(builder, kit) default: break } } + + private func buildMac(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project + builder.load(.macos_extension) + builder.call( + Rules.Apple.MacOS.Call.macos_extension( + name: name, + bundle_id: bundleIdentifier(project: project), + deps: .build { + ":\(name)_library" + }, + entitlements: entitlementsLabel(project: project), + frameworks: embeddedFrameworks(project: project), + infoplists: .build { + plistFile(kit) + plist_auto + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, + visibility: .public)) + } private func buildIOS(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.ios_extension) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index 87bc7b4..e289580 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -117,6 +117,7 @@ extension Target { ":\(name)_library" }, entitlements: entitlementsLabel(project: kit.project), + extensions: embeddedExtensions(project: kit.project), frameworks: embeddedFrameworks(project: kit.project), infoplists: .build { plistFile(kit) From 9816419ec3e1447d81efc6092e8f06fe0a7772bc Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 22:57:19 +0800 Subject: [PATCH 088/173] Copy the products a bundle's copy phases embed A login-item helper app, a privileged helper tool, an XPC service and a nested app all reach the bundle through a Copy Files phase, and none of them was generated: MonitorControl, Rectangle and Stats shipped no login item, Stats no SMC helper, VirtualBuddy no guest app. They are now passed as additional_contents, keyed by the subdirectory of Contents the phase names, and a tool whose PRODUCT_NAME renames the binary is copied to that name first. --- Sources/BazelRules/Rules+Apple.swift | 8 + .../Codegen/CodeGen+Extension.swift | 1 + .../Codegen/Codegen+Application.swift | 1 + .../Codegen/Codegen+CopyFiles.swift | 148 ++++++++++++++++++ .../BazelizeKit/Codegen/Codegen+Target.swift | 2 + .../XCode2Tests/RoadmapTreeBuilderTests.swift | 4 +- 6 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index e653416..c636402 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -442,6 +442,7 @@ extension Rules.Apple.MacOS { /// Builds a `macos_application` target. public static func macos_application( name: String, + additional_contents: [String: String]? = nil, app_icons: Starlark.Value? = nil, bundle_id: String? = nil, bundle_name: String? = nil, @@ -458,6 +459,9 @@ extension Rules.Apple.MacOS { { Rules.Apple.MacOS.macos_application.call { "name" => name + if let additional_contents, !additional_contents.isEmpty { + "additional_contents" => .init(additional_contents) ?? None + } if let app_icons { "app_icons" => app_icons } if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } @@ -499,6 +503,7 @@ extension Rules.Apple.MacOS { public static func macos_extension( name: String, + additional_contents: [String: String]? = nil, bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, @@ -513,6 +518,9 @@ extension Rules.Apple.MacOS { { Rules.Apple.MacOS.macos_extension.call { "name" => name + if let additional_contents, !additional_contents.isEmpty { + "additional_contents" => .init(additional_contents) ?? None + } if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index 565fe2f..58bd9e1 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -24,6 +24,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_extension( name: name, + additional_contents: additionalContents(project: project), bundle_id: bundleIdentifier(project: project), deps: .build { ":\(name)_library" diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index e289580..fe7645e 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -111,6 +111,7 @@ extension Target { builder.call( Rules.Apple.MacOS.Call.macos_application( name: name, + additional_contents: additionalContents(project: kit.project), app_icons: appIcons(project: kit.project), bundle_id: bundleIdentifier(project: kit.project), deps: .build { diff --git a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift new file mode 100644 index 0000000..0a7b498 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift @@ -0,0 +1,148 @@ +import BazelRules +import Foundation +import PathKit +import Starlark +import XCode2 + +extension Target { + /// Products Xcode copies into the bundle outside the framework and extension + /// phases: a login-item helper app, a privileged helper tool, an XPC service. + /// + /// rules_apple takes them as `additional_contents`, keyed by the subdirectory of + /// `Contents` they belong in, and knows how to place an app bundle, a bare + /// executable or a plain file. + func additionalContents(project: Project?) -> [String: String] { + copiedProducts(project: project).reduce(into: [:]) { result, copied in + result[copied.label] = copied.subdirectory + } + } + + /// A tool's binary is named after its rule, so one whose `PRODUCT_NAME` differs + /// is copied to that name first — the app looks it up by name, and a launch + /// daemon's plist points at it. + func generateCopiedProducts(_ builder: CodeBuilder, _ kit: Kit) { + for copied in copiedProducts(project: kit.project) where copied.rename != nil { + guard let rename = copied.rename else { continue } + + builder.call( + Rules.Builtin.Call.genrule( + name: rename.rule, + srcs: .build { + [rename.product] + }, + outs: [rename.name], + cmd: "cp $(location \(rename.product)) $@", + visibility: .private)) + } + } + + // MARK: Private + + private struct CopiedProduct { + let label: String + let subdirectory: String + let rename: (rule: String, product: String, name: String)? + } + + private func copiedProducts(project: Project?) -> [CopiedProduct] { + guard let project else { return [] } + + /// A build phase entry does not say where it comes from, so the copied files + /// answer that: only a product of another target is a rule dependency, a file + /// out of the source tree is just a file. + let products = Set(files.copyFiles.filter { file in + file.sourceTree == "BUILT_PRODUCTS_DIR" + }.compactMap { file in + file.name ?? file.path + }) + + var result: [CopiedProduct] = [] + var seen = Set() + + for phase in buildPhases where phase.type == "CopyFiles" { + guard let subdirectory = phase.contentsSubdirectory else { continue } + + for file in phase.files { + guard + let component = file.name ?? file.path, + products.contains(component), + let sibling = project.product(named: component), + seen.insert(component).inserted + else { + continue + } + + let product = "//Targets/\(sibling.name):\(sibling.name)" + guard + component != sibling.name, + sibling.productType == "com.apple.product-type.tool" + else { + result.append(.init(label: product, subdirectory: subdirectory, rename: nil)) + continue + } + + let rule = "\(sibling.name)_product" + result.append( + .init( + label: ":\(rule)", + subdirectory: subdirectory, + rename: (rule: rule, product: product, name: component))) + } + } + + return result + } +} + +extension Project { + /// The target whose product is copied under this file name. + /// + /// A copy phase names the product, which `PRODUCT_NAME` can rename: the Stats + /// `SMC` target builds `smc`, and its `Helper` builds a tool named after the + /// bundle identifier. + fileprivate func product(named component: String) -> Target? { + let base = Path(component).lastComponentWithoutExtension + + return targets.first { target in + let names = [target.name, target.productName, target.prefer(\.metadata.productName)] + .compactMap { $0 } + return names.contains(component) || names.contains(base) + } + } +} + +extension XCode2.XCode.BuildPhase { + /// Where a copy phase lands, relative to `Contents`. + /// + /// `nil` for a destination another rule attribute owns — a framework or an + /// extension — and for one no bundle subdirectory can express. + fileprivate var contentsSubdirectory: String? { + guard let destination else { return nil } + + let path = (destination.path ?? "") + .replacingOccurrences(of: "$(CONTENTS_FOLDER_PATH)", with: "") + .replacingOccurrences(of: "${CONTENTS_FOLDER_PATH}", with: "") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + /// `PBXCopyFilesBuildPhase.SubFolder`, as Xcode writes it. + switch destination.subfolderSpec { + case 1, 16: + /// Relative to the wrapper, so the `Contents` prefix is already there. + let trimmed = path.delete(prefix: "Contents/") ?? path + return trimmed.isEmpty ? nil : trimmed + case 6: + return join("MacOS", path) + case 7: + return join("Resources", path) + case 12: + return join("SharedSupport", path) + default: + /// 10 is `frameworks`, 13 is `extensions`, 0 is an absolute path. + return nil + } + } + + private func join(_ base: String, _ path: String) -> String { + path.isEmpty ? base : "\(base)/\(path)" + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 98d51c2..5c5afea 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -17,6 +17,7 @@ extension Target { switch productType { case "com.apple.product-type.application": generateStrings(builder, kit) + generateCopiedProducts(builder, kit) generateApplicationCode(builder, kit) case "com.apple.product-type.tool": generateCommandLineApplicationCode(builder, kit) @@ -29,6 +30,7 @@ extension Target { case "com.apple.product-type.bundle.ui-testing": generateUITest(builder, kit) case "com.apple.product-type.app-extension": + generateCopiedProducts(builder, kit) generateExtension(builder, kit) default: Log.codeGenerate.warning(""" diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index ed3f971..b4490cd 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -150,7 +150,9 @@ struct RoadmapTreeBuilderTests { #expect(appBuild.contains("\"libXau.6\"")) #expect(appBuild.contains("\"libXdmcp.6\"")) #expect(!appBuild.contains("cc_import(")) - #expect(!appBuild.contains("additional_contents = {")) + /// The two command line tools Xcode copies into `Contents/MacOS`. + #expect(appBuild.contains("\"//Targets/iina-cli:iina-cli\": \"MacOS\"")) + #expect(appBuild.contains("\"//Targets/iina-plugin:iina-plugin\": \"MacOS\"")) #expect(appBuild.contains("@swiftpkg_grmustache.swift//:Mustache")) #expect(appBuild.contains("macos_application(")) #expect(appBuild.contains("minimum_os_version = \"10.15\"")) From 842aefb10a07580e8024e16bd989446acc35c5dc Mon Sep 17 00:00:00 2001 From: yume190 Date: Mon, 14 Sep 2026 23:06:16 +0800 Subject: [PATCH 089/173] Copy the files a bundle's copy phases place in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xcode's Copy Files phases also carry plain files — Stats' uninstall and updater scripts, its launch daemon plist, VirtualCore's software catalog and preview library — and none of them reached the bundle. The roadmap stages each one under the destination the phase names, so a rule sees the path the bundle wants; a destination inside Resources travels with the target's library as structured resources, and anything else is copied flat and passed as additional_contents. --- Sources/BazelRules/Rules+Apple.swift | 4 + .../Codegen/Codegen+CopyFiles.swift | 133 +++++++++++++++++- .../BazelizeKit/Codegen/Codegen+Target.swift | 4 + .../Codegen/Language/Codegen+Library.swift | 1 + .../Language/Codegen+ObjcLibrary.swift | 8 ++ .../Language/Codegen+SwiftLibrary.swift | 1 + .../Roadmap/BazelizeKit+Roadmap.swift | 21 +++ 7 files changed, 170 insertions(+), 2 deletions(-) diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index c636402..ef6ea87 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -1161,6 +1161,7 @@ extension Rules.Apple.Resources { public static func apple_resource_group( name: String, resources: Starlark.Value? = nil, + strip_structured_resources_prefixes: [String]? = nil, structured_resources: Starlark.Value? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call @@ -1168,6 +1169,9 @@ extension Rules.Apple.Resources { Rules.Apple.Resources.apple_resource_group.call { "name" => name if let resources { "resources" => resources } + if let strip_structured_resources_prefixes, !strip_structured_resources_prefixes.isEmpty { + "strip_structured_resources_prefixes" => strip_structured_resources_prefixes + } if let structured_resources { "structured_resources" => structured_resources } if let visibility { visibility } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift index 0a7b498..104e44d 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift @@ -12,9 +12,71 @@ extension Target { /// `Contents` they belong in, and knows how to place an app bundle, a bare /// executable or a plain file. func additionalContents(project: Project?) -> [String: String] { - copiedProducts(project: project).reduce(into: [:]) { result, copied in + var result = copiedProducts(project: project).reduce(into: [String: String]()) { result, copied in result[copied.label] = copied.subdirectory } + + for group in copiedFileGroups(project: project) where !group.isBundleResource { + result[":\(group.subdirectory.copyFilesRuleName)"] = group.subdirectory + } + + return result + } + + /// Files copied into `Resources` travel with the target's library as structured + /// resources, which keep the destination directory they are staged under and + /// reach whichever bundle links or embeds the library. + func generateCopiedResourceGroup(_ builder: CodeBuilder, _ kit: Kit) { + let prefix = "\(Self.copyFilesRoot)/Resources" + let sources = copiedFileGroups(project: kit.project) + .filter(\.isBundleResource) + .flatMap { group in + group.files.map { file in + "\(Self.copyFilesRoot)/\(group.subdirectory)/\(Path(file).lastComponent)" + } + } + + guard !sources.isEmpty else { return } + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_group) + builder.call( + Rules.Apple.Resources.Call.apple_resource_group( + name: Self.copyFilesRoot, + strip_structured_resources_prefixes: [prefix], + structured_resources: .build { + sources.sorted() + }, + visibility: .private)) + } + + func hasCopiedResources(project: Project?) -> Bool { + copiedFileGroups(project: project).contains(where: \.isBundleResource) + } + + /// The resource group, for the library's deps. + func copiedResourceGroups(project: Project?) -> [Starlark.Label] { + hasCopiedResources(project: project) ? [.named(":\(Self.copyFilesRoot)")] : [] + } + + /// The copied files, flattened into the package so that rules_apple places them + /// directly in the destination: it appends the path a file has inside its own + /// package to the destination. + func generateCopiedFiles(_ builder: CodeBuilder, _ kit: Kit) { + for group in copiedFileGroups(project: kit.project) where !group.isBundleResource { + let sources = group.files.map { file in + "\(Self.copyFilesRoot)/\(group.subdirectory)/\(Path(file).lastComponent)" + } + + builder.call( + Rules.Builtin.Call.genrule( + name: group.subdirectory.copyFilesRuleName, + srcs: .build { + sources + }, + outs: sources.map { Path($0).lastComponent }, + cmd: "for src in $(SRCS); do cp $$src $(RULEDIR)/$$(basename $$src); done", + visibility: .private)) + } } /// A tool's binary is named after its rule, so one whose `PRODUCT_NAME` differs @@ -94,6 +156,66 @@ extension Target { } } +extension Target { + static let copyFilesRoot = "CopyFiles" + + struct CopiedFileGroup { + let subdirectory: String + let files: [String] + + /// A destination inside the bundle's resource directory, which the target's + /// own library can carry. + var isBundleResource: Bool { + subdirectory == "Resources" || subdirectory.hasPrefix("Resources/") + } + } + + /// Files — not products — a copy phase places in the bundle, grouped by the + /// subdirectory of `Contents` they belong in. + /// + /// The roadmap stages them under `CopyFiles//` so the path a rule + /// sees is the path the bundle wants; a build phase entry itself only names the + /// file, and the same name can be copied to two different places. + func copiedFileGroups(project: Project?) -> [CopiedFileGroup] { + guard let project else { return [] } + + let workspace = Path(project.workspacePath) + + let sources = files.copyFiles.filter { file in + file.sourceTree != "BUILT_PRODUCTS_DIR" + } + let byName = Dictionary( + sources.compactMap { file -> (String, String)? in + guard let path = file.path, let name = file.name ?? file.path else { return nil } + return (name, path) + }, + uniquingKeysWith: { first, _ in first }) + + var groups: [String: [String]] = [:] + + for phase in buildPhases where phase.type == "CopyFiles" { + guard let subdirectory = phase.contentsSubdirectory else { continue } + + for file in phase.files { + guard + let component = file.name ?? file.path, + let path = byName[component], + /// A project routinely references a file nobody ships; a rule + /// naming one fails analysis. + (workspace + Path(path.delete(prefix: "Sources/") ?? path)).exists + else { + continue + } + groups[subdirectory, default: []].append(path) + } + } + + return groups + .map { CopiedFileGroup(subdirectory: $0.key, files: $0.value.sorted()) } + .sorted { $0.subdirectory < $1.subdirectory } + } +} + extension Project { /// The target whose product is copied under this file name. /// @@ -116,7 +238,7 @@ extension XCode2.XCode.BuildPhase { /// /// `nil` for a destination another rule attribute owns — a framework or an /// extension — and for one no bundle subdirectory can express. - fileprivate var contentsSubdirectory: String? { + var contentsSubdirectory: String? { guard let destination else { return nil } let path = (destination.path ?? "") @@ -146,3 +268,10 @@ extension XCode2.XCode.BuildPhase { path.isEmpty ? base : "\(base)/\(path)" } } + +extension String { + /// `Resources/Scripts` names the rule `CopyFiles_Resources_Scripts`. + fileprivate var copyFilesRuleName: String { + "\(Target.copyFilesRoot)_\(replacingOccurrences(of: "/", with: "_"))" + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 5c5afea..0679222 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -5,6 +5,7 @@ extension Target { let builder = CodeBuilder() generateIntentLibraries(builder, kit) generateAssetSymbols(builder, kit) + generateCopiedResourceGroup(builder, kit) generateLibrary(builder, kit) generateLoadPlistFragment(builder, kit) @@ -18,10 +19,12 @@ extension Target { case "com.apple.product-type.application": generateStrings(builder, kit) generateCopiedProducts(builder, kit) + generateCopiedFiles(builder, kit) generateApplicationCode(builder, kit) case "com.apple.product-type.tool": generateCommandLineApplicationCode(builder, kit) case "com.apple.product-type.framework": + generateStrings(builder, kit) generateFrameworkCode(builder, kit) case "com.apple.product-type.library.static": generateStaticLibrary(builder, kit) @@ -31,6 +34,7 @@ extension Target { generateUITest(builder, kit) case "com.apple.product-type.app-extension": generateCopiedProducts(builder, kit) + generateCopiedFiles(builder, kit) generateExtension(builder, kit) default: Log.codeGenerate.warning(""" diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 8610056..25d02d7 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -70,6 +70,7 @@ extension Target { } xibs storyboards + copiedResourceGroups(project: project) }, enable_modules: prefer(\.enableModules), hdrs: .build { diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index b00d499..a0e783d 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -34,6 +34,14 @@ extension Target { linkedFrameworksLibrary(project: project) testHostLibraries(project: project) }, + data: .build { + if !assets.isEmpty { + ":Assets" + } + xibs + storyboards + copiedResourceGroups(project: project) + }, copts: [ "-fblocks", "-fobjc-arc", diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index cd2a10f..1be50ee 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -44,6 +44,7 @@ extension Target { } xibs storyboards + copiedResourceGroups(project: project) }, defines: defines(project: project), linkopts: sdkLinkopts, diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 7b28250..7a57c06 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -64,6 +64,27 @@ extension Bazel { try prepareModuleHeaders(target: target, project: project, targetRoot: targetRoot) try prepareDefinesHeader(target: target, targetRoot: targetRoot) try prepareEntitlements(target: target, project: project, targetRoot: targetRoot) + try prepareCopiedFiles(target: target, project: project, targetRoot: targetRoot) + } + + /// Files a copy phase places in the bundle, staged under the destination the + /// phase names: the rules address a resource by its path, and Xcode copies + /// the same file name to more than one destination. + private func prepareCopiedFiles(target: Target, project: Project, targetRoot: Path) throws { + let workspace = Path(project.workspacePath) + + for group in target.copiedFileGroups(project: project) { + let destination = targetRoot + Target.copyFilesRoot + group.subdirectory + + for file in group.files { + let relativePath = file.delete(prefix: "Sources/") ?? file + let source = workspace + relativePath + guard source.exists else { continue } + + try destination.mkpath() + try materialize(source: source, destination: destination + Path(relativePath).lastComponent) + } + } } /// The entitlements Xcode signs with, expanded: rules_apple substitutes no From cd1d7cc47879155e4fa6d8489f17eb181235db22 Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 09:25:28 +0800 Subject: [PATCH 090/173] Default to propagating an imported framework's module map Clang finds the module map inside the framework, which is how iina's Objective-C half compiles `@import Sparkle;` against the xcframework SwiftPM ships. Bazel stopped handing those module maps to compile actions, and rules_apple gates the old behaviour behind a define; the generated config.bazelrc now sets it. --- Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift b/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift index 46cefce..94c22c3 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+BazelRC.swift @@ -34,9 +34,17 @@ extension Bazel { "build:\(config) --//:mode=\(config)" }.sorted() - code = (Self.minimumOSFlags(targets: targets) + modes).withNewLine + code = (Self.defaults + Self.minimumOSFlags(targets: targets) + modes).withNewLine } + /// Clang finds an imported framework's module map inside the framework, which + /// is how `@import Sparkle;` compiles in Xcode. Bazel stopped passing those + /// module maps to a compile action, so an `objc_library` that imports a + /// framework module no longer builds; this restores it. + private static let defaults = [ + "build --define=apple.incompatible.objc_framework_propagate_modulemap=true", + ] + /// Xcode resolves deployment targets per target; Bazel needs a default for /// everything outside a bundle rule's transition, otherwise SwiftPM /// dependencies fail analysis against Bazel's own (much older) defaults. From 569e2ceda1d108592629eee2d680fb58c1077cb0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 09:25:28 +0800 Subject: [PATCH 091/173] Import a project's own dylib by path A dylib in the Frameworks phase was linked by name through sdk_dylibs no matter where it came from, so iina's `deps/lib/libX11.6.dylib` turned into `-lX11.6` and the linker went looking in the SDK. Only an SDK dylib is linked by name now; anything the project carries is a prebuilt cc_import, and one that does not exist yet is dropped rather than left dangling. --- Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift | 7 ++++++- Sources/Xcode2/Loader/XCode+FileLoader.swift | 1 - Sources/Xcode2/Loader/XCode+TargetLoader.swift | 5 +++-- Tests/XCode2Tests/RoadmapTreeBuilderTests.swift | 10 ++++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift index 94f958f..e61702d 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift @@ -19,7 +19,12 @@ extension Bazel { let imported = kit.project.targets .flatMap(\.files.frameworks) .filter { file in - file.label?.hasPrefix("//Prebuilt:") == true + guard file.label?.hasPrefix("//Prebuilt:") == true else { return false } + /// A binary that only exists after a bootstrap script has run is + /// not importable, and declaring it leaves the workspace + /// unloadable — iina references dylibs it builds separately. + guard let fullPath = file.fullPath else { return false } + return Path(fullPath).exists } let frameworks = imported.filter { file in diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index 8dfb481..c9a60bb 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -176,7 +176,6 @@ struct FileLoader { if buildPhase == BuildPhase.frameworks.rawValue, canUsePrebuiltLabel, - !isDylibLike, !isSDKFramework, !isSDKDylib { diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index db62486..cb02226 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -123,7 +123,6 @@ struct TargetLoader { guard let file = buildFile.file else { return nil } let wrapped = FileLoader(native: file, project: project) guard !wrapped.isSDKFramework, !wrapped.isSDKDylib else { return nil } - guard !wrapped.isDylibLike else { return nil } if let identity = wrapped.frameworkIdentity, targetDependencyIdentities.contains(identity) { return nil @@ -162,7 +161,9 @@ struct TargetLoader { let sdkDylibs = frameworkBuildFiles.compactMap { buildFile -> String? in guard let file = buildFile.file else { return nil } let wrapped = FileLoader(native: file, project: project) - guard wrapped.isDylibLike else { return nil } + /// Only a dylib the SDK ships is linked by name; one the project carries + /// is imported by path, like any other prebuilt binary. + guard wrapped.isSDKDylib else { return nil } return wrapped.sdkDylibName ?? wrapped.name.flatMap { Path($0).lastComponentWithoutExtension } } diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index b4490cd..82fbf6d 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -145,10 +145,12 @@ struct RoadmapTreeBuilderTests { #expect(appBuild.contains("\"PIP\"")) #expect(!appBuild.contains("\"CoreDisplay.framework\"")) #expect(!appBuild.contains("\"PIP.framework\"")) - #expect(appBuild.contains("sdk_dylibs = [")) - #expect(appBuild.contains("\"libX11.6\"")) - #expect(appBuild.contains("\"libXau.6\"")) - #expect(appBuild.contains("\"libXdmcp.6\"")) + /// iina links its own dylibs out of `deps/lib`, which the SDK knows nothing + /// about: they are imported by path when the checkout has them, never linked + /// by name. + #expect(!appBuild.contains("\"libX11.6\"")) + #expect(!appBuild.contains("\"libXau.6\"")) + #expect(!appBuild.contains("\"libXdmcp.6\"")) #expect(!appBuild.contains("cc_import(")) /// The two command line tools Xcode copies into `Contents/MacOS`. #expect(appBuild.contains("\"//Targets/iina-cli:iina-cli\": \"MacOS\"")) From 820da1e96c7c733aa74789671cfed3a8d4ed4fac Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 09:25:28 +0800 Subject: [PATCH 092/173] Drop the bundle identifier default for a target without one plisttool substitutes $(PRODUCT_BUNDLE_IDENTIFIER) from the rule's bundle_id, which iina's command line tools never set. --- Sources/BazelizeKit/Codegen/Codegen+Plist.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index eb10c2b..6d78d2a 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -364,6 +364,12 @@ extension Target { if productType == "com.apple.product-type.tool" { keys.insert("CFBundleExecutable") } + /// `plisttool` substitutes `$(PRODUCT_BUNDLE_IDENTIFIER)` from the rule's + /// `bundle_id`, which a target without one — iina's command line tools — + /// never sets. + if prefer(\.metadata.bundleID) == nil { + keys.insert("CFBundleIdentifier") + } return keys } From 97da3cf0dea127cb643ceb2120af3860be6ebdc3 Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 09:54:26 +0800 Subject: [PATCH 093/173] Declare the SDK frameworks Objective-C sources import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clang records an autolink directive for every framework module it imports, which is how iina links Accelerate without the project mentioning it. Bazel compiles with -fno-autolink and expects the dependency declared, so the imports are read out of the sources and added to sdk_frameworks — the same set clang would have recorded. --- .../Codegen/Codegen+Autolink.swift | 124 ++++++++++++++++++ .../Codegen/Language/Codegen+Library.swift | 2 +- .../Language/Codegen+ObjcLibrary.swift | 2 +- Tests/XCode2Tests/ProjectLoaderTests.swift | 6 +- .../XCode2Tests/RoadmapTreeBuilderTests.swift | 9 +- 5 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 Sources/BazelizeKit/Codegen/Codegen+Autolink.swift diff --git a/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift b/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift new file mode 100644 index 0000000..54d0ee3 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift @@ -0,0 +1,124 @@ +import Foundation +import PathKit +import Util +import XCode2 + +extension Target { + /// What the rule declares: the frameworks the project links plus the ones its + /// Objective-C sources import. + func sdkFrameworks(project: Project) -> [String]? { + let all = Set(frameworksSDK).union(autolinkedFrameworks(project: project)) + .subtracting(weakFrameworksSDK) + return all.isEmpty ? nil : all.sorted() + } + + /// SDK frameworks the Objective-C half imports as modules. + /// + /// Xcode links them without anyone declaring them: clang records an autolink + /// directive for every framework module it imports. Bazel compiles with + /// `-fno-autolink` — it wants the dependency declared — so the imports are read + /// out of the sources instead, exactly the set clang would have recorded. + func autolinkedFrameworks(project: Project) -> [String] { + let available = SDKFrameworks.names(for: platformSDK) + guard !available.isEmpty else { return [] } + + let workspace = Path(project.workspacePath) + let sources = srcs_c + srcs_objc + srcs_cpp + srcs_objcpp + + moduleHeaderFiles(project: project) + internalHeaderFiles(project: project) + + var result = Set() + + for source in sources { + let path = workspace + Path(source.delete(prefix: "Sources/") ?? source) + guard let content: String = try? path.read() else { continue } + + for name in content.importedModuleNames where available.contains(name) { + result.insert(name) + } + } + + return result.sorted() + } +} + +/// The frameworks an SDK ships, which is what makes an import autolinkable. +private enum SDKFrameworks { + static func names(for platform: SDK?) -> Set { + let sdk = sdkName(for: platform) + + if let cached = cache[sdk] { + return cached + } + + let names = read(sdk: sdk) + cache[sdk] = names + return names + } + + private nonisolated(unsafe) static var cache: [String: Set] = [:] + + private static func sdkName(for platform: SDK?) -> String { + switch platform { + case .iOS: return "iphoneos" + case .tvOS: return "appletvos" + case .watchOS: return "watchos" + default: return "macosx" + } + } + + private static func read(sdk: String) -> Set { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--sdk", sdk, "--show-sdk-path"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + guard (try? process.run()) != nil else { return [] } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty + else { + return [] + } + + let roots = [ + Path(output) + "System/Library/Frameworks", + Path(output) + "System/iOSSupport/System/Library/Frameworks" + ] + + var names = Set() + for root in roots { + guard let children = try? root.children() else { continue } + for child in children where child.extension == "framework" { + names.insert(child.lastComponentWithoutExtension) + } + } + + return names + } +} + +extension String { + /// `@import Accelerate;`, `#import ` and the `#include` + /// spelling of the same. + fileprivate var importedModuleNames: [String] { + let patterns = [ + #"@import\s+([A-Za-z_][A-Za-z0-9_]*)"#, + #"#\s*(?:import|include)\s+<([A-Za-z_][A-Za-z0-9_]*)/"# + ] + + return patterns.flatMap { pattern -> [String] in + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + return regex.matches(in: self, range: NSRange(startIndex..., in: self)).compactMap { match in + guard let range = Range(match.range(at: 1), in: self) else { return nil } + return String(self[range]) + } + } + } +} diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 25d02d7..4c94479 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -85,7 +85,7 @@ extension Target { linkopts: sdkLinkopts, module_name: codegenModuleName, sdk_dylibs: dylibsSDK, - sdk_frameworks: frameworksSDK, + sdk_frameworks: sdkFrameworks(project: project), swift_copts: moduleSwiftCopts(project: project), swift_defines: defines(project: project), swift_srcs: .build { diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index a0e783d..625580c 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -53,7 +53,7 @@ extension Target { linkopts: sdkLinkopts, module_name: codegenModuleName, sdk_dylibs: dylibsSDK, - sdk_frameworks: frameworksSDK, + sdk_frameworks: sdkFrameworks(project: project), testonly: isTest, visibility: .private, weak_sdk_frameworks: weakFrameworksSDK)) diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XCode2Tests/ProjectLoaderTests.swift index d43ea64..1b4195b 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XCode2Tests/ProjectLoaderTests.swift @@ -68,7 +68,11 @@ struct ProjectLoaderTests { #expect(target.dependencies.sdkFrameworks.contains("PIP")) #expect(!target.dependencies.frameworks.contains("CoreDisplay.framework")) #expect(!target.dependencies.frameworks.contains("PIP.framework")) - #expect(!target.dependencies.frameworks.contains("//Prebuilt:libX11.6")) + /// A dylib the project carries is imported by path, never linked by name + /// out of the SDK. + #expect(!target.dependencies.sdkDylibs.contains("libX11.6")) + #expect((current + "app/iina/deps/lib/libX11.6.dylib").exists + == target.dependencies.frameworks.contains("//Prebuilt:libX11.6")) #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libX11.6.dylib" }) #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libXau.6.dylib" }) #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libXdmcp.6.dylib" }) diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 82fbf6d..2e8b88a 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -169,9 +169,12 @@ struct RoadmapTreeBuilderTests { #expect(nightlyBuild.contains("Sources/iina/Assets.xcassets/AppIconNightly.appiconset/**")) let prebuiltBuild = try String(contentsOfFile: (output + "Prebuilt/BUILD").string) - #expect(!prebuiltBuild.contains("libX11.6")) - #expect(!prebuiltBuild.contains("libXau.6")) - #expect(!prebuiltBuild.contains("libXdmcp.6")) + /// The dylibs iina downloads into `deps/lib` are imported from there when the + /// checkout has them, and left out entirely when it does not. + let hasDylibs = (current + "app/iina/deps/lib/libX11.6.dylib").exists + #expect(prebuiltBuild.contains("libX11.6") == hasDylibs) + #expect(prebuiltBuild.contains("libXau.6") == hasDylibs) + #expect(prebuiltBuild.contains("libXdmcp.6") == hasDylibs) #expect(!prebuiltBuild.contains("name = \"PIP\"")) #expect(!prebuiltBuild.contains("name = \"CoreDisplay\"")) From 27921012b6a588240e005cccf24544f079996aa4 Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 15:05:51 +0800 Subject: [PATCH 094/173] Declare a nib once The resources filegroup already carries every xib and storyboard, and the library's data carried them too, so two rules compiled the same storyboard to the same output path and CotEditor failed analysis on conflicting actions. --- Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift | 2 -- .../BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift | 2 -- .../BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift | 2 -- Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift | 4 +++- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 4c94479..34ec81a 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -68,8 +68,6 @@ extension Target { if !assets.isEmpty { ":Assets" } - xibs - storyboards copiedResourceGroups(project: project) }, enable_modules: prefer(\.enableModules), diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 625580c..c57fe5b 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -38,8 +38,6 @@ extension Target { if !assets.isEmpty { ":Assets" } - xibs - storyboards copiedResourceGroups(project: project) }, copts: [ diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 1be50ee..facc4e4 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -42,8 +42,6 @@ extension Target { if !assets.isEmpty { ":Assets" } - xibs - storyboards copiedResourceGroups(project: project) }, defines: defines(project: project), diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift index 0bd4dd3..3ed955a 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift @@ -12,7 +12,9 @@ extension Target { /// and an app icon set. /// /// Without this a generated app links and bundles, but ships no nib and no - /// localization, so it dies the moment it is launched. + /// localization, so it dies the moment it is launched. It is the only place a + /// nib or a storyboard is declared: passing one through the library's `data` as + /// well makes two rules compile it to the same path. func generateResources(_ builder: CodeBuilder, _ kit: Kit) { let patterns = resourcePatterns(project: kit.project) guard !patterns.isEmpty else { return } From 390d95cc81374c4136446cc6752a3f282789babe Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 15:06:05 +0800 Subject: [PATCH 095/173] Bundle a macOS XPC service UTM's QEMUHelper is an XPC service, a product type nothing generated, so the application referenced a rule that did not exist. --- Sources/BazelRules/Rules+Apple.swift | 32 +++++++++++++++++++ .../Codegen/CodeGen+Extension.swift | 31 ++++++++++++++++++ .../BazelizeKit/Codegen/Codegen+Target.swift | 5 +++ 3 files changed, 68 insertions(+) diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index ef6ea87..af51fb8 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -534,6 +534,38 @@ extension Rules.Apple.MacOS { } } + /// Builds a `macos_xpc_service` target. + public static func macos_xpc_service( + name: String, + additional_contents: [String: String]? = nil, + bundle_id: String? = nil, + bundle_name: String? = nil, + deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, + infoplists: Starlark.Value? = nil, + minimum_os_version: String? = nil, + resources: Starlark.Value? = nil, + strings: Starlark.Value? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.MacOS.macos_xpc_service.call { + "name" => name + if let additional_contents, !additional_contents.isEmpty { + "additional_contents" => .init(additional_contents) ?? None + } + if let bundle_id { "bundle_id" => bundle_id } + if let bundle_name { "bundle_name" => bundle_name } + if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } + if let infoplists { "infoplists" => infoplists } + if let minimum_os_version { "minimum_os_version" => minimum_os_version } + if let resources { "resources" => resources } + if let strings { "strings" => strings } + if let visibility { visibility } + } + } + /// Builds a `macos_command_line_application` target. public static func macos_command_line_application( name: String, diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index 58bd9e1..b690870 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -18,6 +18,37 @@ extension Target { } } + /// An XPC service is its own bundle inside `Contents/XPCServices`, which the + /// application's copy phase puts it in. + func generateXPCService(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project + builder.load(.macos_xpc_service) + builder.call( + Rules.Apple.MacOS.Call.macos_xpc_service( + name: name, + additional_contents: additionalContents(project: project), + bundle_id: bundleIdentifier(project: project), + deps: .build { + ":\(name)_library" + }, + entitlements: entitlementsLabel(project: project), + infoplists: .build { + plistFile(kit) + plist_auto + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, + visibility: .public)) + } + private func buildMac(_ builder: CodeBuilder, _ kit: Kit) { let project = kit.project builder.load(.macos_extension) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 0679222..e735d95 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -32,6 +32,11 @@ extension Target { generateUnitTest(builder, kit) case "com.apple.product-type.bundle.ui-testing": generateUITest(builder, kit) + case "com.apple.product-type.xpc-service": + generateStrings(builder, kit) + generateCopiedProducts(builder, kit) + generateCopiedFiles(builder, kit) + generateXPCService(builder, kit) case "com.apple.product-type.app-extension": generateCopiedProducts(builder, kit) generateCopiedFiles(builder, kit) From 14a0651129682cdde92339d5d9e28b6bbfe46dae Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 15:06:19 +0800 Subject: [PATCH 096/173] Expand a build setting's default modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xcode resolves $(SETTING:default=value) when the setting is unset, which is how UTM spells every bundle identifier; the reference reached the generated rules verbatim, where rules_apple substitutes nothing and Bazel reads $(…) in a genrule command as a Make variable. Identifiers and the actool command now expand it, and an identifier that stays unresolved is dropped. --- Sources/BazelizeKit/Codegen/Codegen+Plist.swift | 10 +++++++--- .../Codegen/Resource/Codegen+AssetSymbols.swift | 8 +++++++- Sources/BazelizeKit/XCode2Compat.swift | 9 +++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index 6d78d2a..610bca6 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -206,8 +206,9 @@ extension Target { } extension String { - /// Xcode accepts both `$(SETTING)` and `${SETTING}`. - static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)(?::[A-Za-z0-9_]+)?[)}]"# + /// Xcode accepts both `$(SETTING)` and `${SETTING}`, each with a modifier — + /// `$(SETTING:default=value)` is the one that carries information. + static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)(?::([^)}]*))?[)}]"# /// Variables `plisttool` substitutes itself; leaving them intact keeps /// rules_apple in charge of the bundle identity it also validates. @@ -243,7 +244,10 @@ extension String { } let key = String(self[keyRange]) - guard !reserved.contains(key), let value = settings[key] else { continue } + guard !reserved.contains(key) else { continue } + + let modifier = Range(match.range(at: 2), in: self).map { String(self[$0]) } + guard let value = settings[key] ?? modifier?.delete(prefix: "default=") else { continue } result.replaceSubrange(wholeRange, with: value) } diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift index 12b10ba..9cc1e43 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift @@ -46,7 +46,13 @@ extension Target { /// Starlark rejects unknown escape sequences inside the string, so the command /// avoids backslashes entirely. private var assetSymbolsCommand: String { - let bundleID = prefer(\.metadata.bundleID) ?? "com.bazelize.\(codegenModuleName)" + /// `actool` only needs an identifier to key the generated symbols with; one + /// that still references a build setting Xcode would have expanded is no use + /// to it, and `$(…)` in a genrule command is a Make variable to Bazel. + let resolved = (prefer(\.metadata.bundleID) ?? "") + .resolvingBuildSettingReferences(with: selectedSettings, reserved: []) + let fallback = "com.bazelize.\(codegenModuleName)" + let bundleID = resolved.isEmpty || resolved.contains("$") ? fallback : resolved let arguments = [ "--platform \(assetSymbolsPlatform)", "--minimum-deployment-target \(assetSymbolsMinimumOS)", diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XCode2Compat.swift index 1d25d54..39f6368 100644 --- a/Sources/BazelizeKit/XCode2Compat.swift +++ b/Sources/BazelizeKit/XCode2Compat.swift @@ -173,7 +173,16 @@ extension Target { /// Apple requires the prefix and rules_apple enforces it; Xcode does not, so a /// framework in the same project routinely carries an unrelated identifier. func bundleIdentifier(project: Project?) -> String? { + /// rules_apple substitutes nothing here, so a reference Xcode would have + /// expanded — UTM spells every identifier + /// `$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).X` — is expanded first. let own = prefer(\.metadata.bundleID) + .map { identifier in + identifier.resolvingBuildSettingReferences(with: selectedSettings, reserved: []) + } + .flatMap { identifier in + identifier.contains("$") ? nil : identifier + } guard let parent = embeddingBundle(project: project), From 90721c2dc51c8e2e3313963285303aec22599cdb Mon Sep 17 00:00:00 2001 From: yume190 Date: Tue, 15 Sep 2026 15:06:50 +0800 Subject: [PATCH 097/173] Generate nothing for a target with no sources UTM's QEMURenderServer is an app bundle around an externally built binary: it has no sources, so no library exists to link and the rule referenced one that was never emitted. Such a target is skipped, and nothing else embeds, copies or links it either. --- .../BazelizeKit/Codegen/Codegen+CopyFiles.swift | 1 + Sources/BazelizeKit/Codegen/Codegen+Target.swift | 15 +++++++++++++++ Sources/BazelizeKit/XCode2Compat.swift | 9 +++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift index 104e44d..738368b 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift @@ -129,6 +129,7 @@ extension Target { let component = file.name ?? file.path, products.contains(component), let sibling = project.product(named: component), + sibling.hasSources, seen.insert(component).inserted else { continue diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index e735d95..ace8d4b 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -1,6 +1,13 @@ import Util extension Target { + /// A target with no sources of its own has no library to link, so no rule can + /// produce its product: UTM wraps an externally built binary in a bundle that + /// way. Nothing references a rule that is not emitted either. + var hasSources: Bool { + !(srcs_c + srcs_cpp + srcs_objc + srcs_objcpp + srcs_swift).isEmpty + } + func generateCode(_ kit: Kit) -> String { let builder = CodeBuilder() generateIntentLibraries(builder, kit) @@ -15,6 +22,14 @@ extension Target { let name = name + guard hasSources else { + Log.codeGenerate.warning(""" + Name: \(name, privacy: .public) + Type: \(productType ?? "") has no sources + """) + return builder.build() + } + switch productType { case "com.apple.product-type.application": generateStrings(builder, kit) diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XCode2Compat.swift index 39f6368..9bf1ba4 100644 --- a/Sources/BazelizeKit/XCode2Compat.swift +++ b/Sources/BazelizeKit/XCode2Compat.swift @@ -69,7 +69,9 @@ extension Target { } fileprivate func linkedTargetDependencyNames(project: Project) -> [String] { - dependencies.targets.filter { isLinkableTarget($0, in: project) } + dependencies.targets.filter { target in + isLinkableTarget(target, in: project) && project.target(named: target)?.hasSources != false + } } func embeddedExtensionTargetNames(project: Project) -> [String] { @@ -139,7 +141,9 @@ extension Target { return Path(component).lastComponentWithoutExtension } - return Array(Set(names).intersection(siblings)).sorted() + return Array(Set(names).intersection(siblings)).sorted().filter { name in + project.target(named: name)?.hasSources == true + } } /// The bundle that embeds this target, if any: an embedded bundle inherits the @@ -208,6 +212,7 @@ extension Target { func embeddedExtensions(project: Project) -> [Starlark.Label] { embeddedExtensionTargetNames(project: project) + .filter { project.target(named: $0)?.hasSources == true } .sorted() .map { target in Starlark.Label.named("//Targets/\(target):\(target)") From 803d07491d4275bef844d27b959bbf1248e739f3 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 09:48:50 +0800 Subject: [PATCH 098/173] Patch rules_swift_package_manager in the generated workspace Two of its generated build files are incomplete in ways real packages depend on, and both are invisible to bazelize's own code generation: - A clang target that lists its sources explicitly loses every header that is neither a declared source nor a public header, so tree-sitter-typescript's `tsx/src/scanner.c` cannot find `../../common/scanner.h` in a sandbox. - A `.metal` resource is compiled, not copied, and the shader includes a header of its own target that the resource bundle does not carry; UTM's CocoaSpice cannot find `include/CSShaderTypes.h`. rules_apple already treats a header in the same resource group as a metal include. The patches ship with the generated workspace so it stands on its own, and belong upstream. --- .../Plugin/Plugin+SwiftPM+Patch.swift | 111 ++++++++++++++++++ .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 11 +- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift new file mode 100644 index 0000000..7190f00 --- /dev/null +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift @@ -0,0 +1,111 @@ +// +// Plugin+SwiftPM+Patch.swift +// +// +// Patches for rules_swift_package_manager, applied to the generated workspace. +// + +extension PluginSwiftPM { + /// Files rules_swift_package_manager generates for a package are incomplete in + /// two ways that keep real projects from building. Both are patched in the + /// generated workspace so it stands on its own; both belong upstream. + static let patchDirectory = "Patches" + + static let patches: [(name: String, content: String)] = [ + (name: "rspm-clang-target-headers.patch", content: clangTargetHeadersPatch), + (name: "rspm-metal-headers.patch", content: metalHeadersPatch) + ] + + /// The `patches` attribute of the module override. + static var patchLabels: String { + patches.map { patch in + " \"//\(patchDirectory):\(patch.name)\"," + }.joined(separator: "\n") + } + + /// The patch files themselves, plus the package that exports them. + static var patchFiles: [PluginBuiltin.Custom] { + let exports = patches.map { patch in + " \"\(patch.name)\"," + }.joined(separator: "\n") + + let build = PluginBuiltin.Custom( + path: "\(patchDirectory)/BUILD", + content: """ + exports_files([ + \(exports) + ]) + """) + + return [build] + patches.map { patch in + PluginBuiltin.Custom(path: "\(patchDirectory)/\(patch.name)", content: patch.content) + } + } + + /// A clang target that lists its sources explicitly loses every header that is + /// not a declared source or a public header, so a source including one by + /// relative path cannot compile in a sandbox. + /// + /// Example: tree-sitter-typescript, where `tsx/src/scanner.c` includes + /// `../../common/scanner.h`. + private static let clangTargetHeadersPatch = #""" +--- a/swiftpkg/internal/pkginfos.bzl ++++ b/swiftpkg/internal/pkginfos.bzl +@@ -1502,6 +1502,23 @@ + exclude_paths = abs_exclude_paths, + )) + ++ # A manifest that lists its sources explicitly still lets clang read any ++ # header under the target path: a source can include one by relative path ++ # without a header search path pointing at it. SPM compiles such a target ++ # straight out of the checkout, so nothing has to be declared; Bazel only ++ # stages declared files, so the headers are collected here. ++ # Example: tree-sitter-typescript, where `tsx/src/scanner.c` includes ++ # `../../common/scanner.h`. ++ if source_paths != None: ++ for f in repository_files.list_files_under( ++ repository_ctx, ++ abs_target_path, ++ exclude_paths = abs_exclude_paths, ++ ): ++ _, hdr_ext = paths.split_extension(f) ++ if hdr_ext in _HEADER_EXTS: ++ all_srcs.append(f) ++ + # SPM's exclude list only excludes files from being compiled as sources, + # but headers in excluded directories are still available for inclusion. + # We need to find all header files in excluded directories and add them +"""# + + /// A `.metal` resource is compiled, and the shader includes a header of its own + /// target, which the generated resource bundle does not carry. rules_apple + /// already treats a header in the same resource group as a metal include. + /// + /// Example: UTM's CocoaSpice, where `CSShaders.metal` includes + /// `include/CSShaderTypes.h`. + private static let metalHeadersPatch = #""" +--- a/swiftpkg/internal/swiftpkg_build_files.bzl ++++ b/swiftpkg/internal/swiftpkg_build_files.bzl +@@ -930,6 +930,20 @@ + for r in sorted_resources + if not r.endswith(".bundle") + ] ++ ++ # A `.metal` resource is compiled, not copied, and a shader routinely ++ # includes a header that is part of the target. rules_apple passes any ++ # header in the same resource group to `metal` as an input and does not ++ # bundle it, so the target's headers go in alongside the shaders. ++ if lists.contains([r.endswith(".metal") for r in resources], True): ++ clang_src_info = getattr(target, "clang_src_info", None) ++ if clang_src_info != None: ++ hdrs = [ ++ hdr ++ for hdr in clang_src_info.hdrs + clang_src_info.textual_hdrs ++ if hdr.endswith(".h") and not lists.contains(resources, hdr) ++ ] ++ resources = resources + sorted(hdrs) + precompiled_bundles_and_labels = [ + (r, "{}_{}".format(bundle_label_name, _sanitized_bundle_file_name(r.split("/")[-1]))) + for r in sorted_resources +"""# +} diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 67dff83..4e3c117 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -61,6 +61,15 @@ final class PluginSwiftPM: PluginBuiltin { name: "rules_swift_package_manager", version: dep.rawValue) builder.custom(""" + single_version_override( + module_name = "rules_swift_package_manager", + patch_strip = 1, + patches = [ + \(Self.patchLabels) + ], + ) + """) + builder.custom(""" swift_deps = use_extension( "@rules_swift_package_manager//:extensions.bzl", "swift_deps", @@ -226,7 +235,7 @@ final class PluginSwiftPM: PluginBuiltin { override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } - return [package, packageResolved].compactMap { $0 } + return [package, packageResolved].compactMap { $0 } + Self.patchFiles } override var tip: String? { From 94fc3c4e1e38d585bb994273f70463f9c23f2520 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 09:49:00 +0800 Subject: [PATCH 099/173] Pass a plain preprocessor definition as a -D copt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clang module is compiled in its own clang instance, which ignores a force-included header but hashes the -D flags: UTM's `#if !defined(WITH_USB)` sits in a header the mixed target modularizes, so the header form never reached it. A definition that survives a command line is passed as -D now, and only a value like `ID=@"com.x"` — which Bazel's defines attribute and the rules_swift worker both mangle — stays in the generated header. --- .../BazelizeKit/Codegen/Codegen+Headers.swift | 41 +++++++++++++++---- .../Roadmap/BazelizeKit+Roadmap.swift | 2 +- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index 4b8e19b..da010cb 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -84,17 +84,35 @@ extension Target { return Array(Set(directories + [".", ".."])).sorted() } - /// `GCC_PREPROCESSOR_DEFINITIONS`, materialized as a force-included header. + /// `GCC_PREPROCESSOR_DEFINITIONS`, split by what survives a command line. /// - /// Neither the rules' `defines` attribute nor a `-D` copt survives a value like - /// `ID=@"com.x"`: Bazel re-tokenizes the former and the rules_swift worker's - /// param files mangle the quoting of the latter. A header force-included with - /// `-include` needs no quoting at all, and Xcode does not propagate these - /// definitions to dependents either. + /// A plain `NAME=1` goes in as a `-D` copt, because that is the only form a + /// clang module build sees: a module is compiled in its own clang instance, + /// which ignores a force-included header but hashes the `-D` flags. UTM's + /// `#if !defined(WITH_USB)` in a header the mixed target modularizes needs + /// exactly that. + /// + /// Anything else — `ID=@"com.x"` — cannot survive: Bazel re-tokenizes the + /// rules' `defines` attribute and the rules_swift worker's param files mangle + /// the quoting of a copt, so those are force-included as a header instead. static let definesHeaderPath = "Generated/BazelizeDefines.h" + /// `NAME`, or `NAME=` followed by characters no shell or param file rewrites. + private static let plainDefinePattern = #"^[A-Za-z_][A-Za-z0-9_]*(=[A-Za-z0-9_./+-]*)?$"# + + private var definitions: (plain: [String], quoted: [String]) { + let all = prefer(\.preprocessorDefinitions) ?? [] + return ( + plain: all.filter { $0.range(of: Self.plainDefinePattern, options: .regularExpression) != nil }, + quoted: all.filter { $0.range(of: Self.plainDefinePattern, options: .regularExpression) == nil }) + } + var definesHeader: String? { - (prefer(\.preprocessorDefinitions) ?? []).isEmpty ? nil : Self.definesHeaderPath + definitions.quoted.isEmpty ? nil : Self.definesHeaderPath + } + + var headerDefinitions: [String] { + definitions.quoted } /// `GCC_PREFIX_HEADER`, relative to the target's `Sources/` tree. @@ -109,8 +127,13 @@ extension Target { } var forceIncludeFlags: [String] { - guard let definesHeader else { return prefixHeaderFlags } - return ["-include", "Targets/\(name)/\(definesHeader)"] + prefixHeaderFlags + let defines = definitions.plain.map { definition in + "-D\(definition)" + } + let header = definesHeader.map { path in + ["-include", "Targets/\(name)/\(path)"] + } ?? [] + return defines + header + prefixHeaderFlags } /// The same header, force-included into `swiftc`'s clang importer so a bridging diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 7a57c06..3d90609 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -106,7 +106,7 @@ extension Bazel { private func prepareDefinesHeader(target: Target, targetRoot: Path) throws { guard let relativePath = target.definesHeader else { return } - let definitions = (target.prefer(\.preprocessorDefinitions) ?? []).map { definition in + let definitions = target.headerDefinitions.map { definition in guard let separator = definition.firstIndex(of: "=") else { return "#define \(definition) 1" } From 46111f333421fd4fae70e8b945ac87e75147d461 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 09:49:13 +0800 Subject: [PATCH 100/173] Honor SWIFT_ACTIVE_COMPILATION_CONDITIONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setting Xcode dedicates to Swift's `#if` was ignored — only `-D` flags smuggled through OTHER_SWIFT_FLAGS came through — so UTM imported the wrong SPICE module. A condition that is not a Swift identifier is dropped, because a project routinely leaves a build setting reference in there and swiftc rejects it outright. --- .../Language/Codegen+SwiftLibrary.swift | 21 +--------- .../Model/Config/XCode+BuildSettings.swift | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index facc4e4..5e5cc46 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -135,26 +135,7 @@ extension Target { // MARK: Private func defines(project: Project) -> Starlark.Value { - select(\.swiftDefine, project: project).map { text -> [String] in - let flags: [String] = (text ?? "").split(separator: " ").map(String.init) - - var isPreviousDefine = false - var result: [String] = [] - for flag in flags { - if flag == "-D" { - isPreviousDefine = true - } else if isPreviousDefine { - /// -D ABC - result.append(flag) - isPreviousDefine = false - } else if flag.hasPrefix("-D") { - /// -DABC - result.append(flag.delete(prefix: "-D")) - } - } - - return result - }.starlark + select(\.swiftDefines, project: project).starlark } } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index a923359..46d5fe7 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -63,6 +63,45 @@ extension XCode { extension XCode.BuildSettings { public var swiftVersion: String? { self["SWIFT_VERSION"] } public var swiftDefine: String? { self["OTHER_SWIFT_FLAGS"] } + + /// Everything Swift compiles with `-D`: the conditions Xcode dedicates a + /// setting to, plus any `-D` smuggled through `OTHER_SWIFT_FLAGS`. + /// + /// `SWIFT_ACTIVE_COMPILATION_CONDITIONS` is how a project spells `#if FEATURE` + /// for Swift — UTM decides which SPICE module to import with it. + public var swiftDefines: [String] { + let conditions = (self["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] ?? "") + .split(separator: " ") + .map(String.init) + .filter { !$0.isEmpty && $0 != "$(inherited)" } + + var flagged: [String] = [] + var isPreviousDefine = false + for flag in (swiftDefine ?? "").split(separator: " ").map(String.init) { + if flag == "-D" { + isPreviousDefine = true + } else if isPreviousDefine { + /// `-D ABC` + flagged.append(flag) + isPreviousDefine = false + } else if flag.hasPrefix("-D") { + /// `-DABC` + flagged.append(String(flag.dropFirst(2))) + } + } + + var result: [String] = [] + for define in conditions + flagged where !result.contains(define) { + /// `swiftc` rejects anything that is not an identifier, and a project + /// routinely leaves a build setting reference in here — iina spells one + /// condition `$AVAILABLE_$(SDK_VERSION_MAJOR)`. + guard define.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil else { + continue + } + result.append(define) + } + return result + } public var bridgingHeader: String? { self["SWIFT_OBJC_BRIDGING_HEADER"] } /// `GCC_PREFIX_HEADER`: a header Xcode force-includes into every C-family From bcf06ecc26e70956b101836f02abf1b6fc0dce04 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 09:49:17 +0800 Subject: [PATCH 101/173] Drop an unresolvable plist key from the target's own Info.plist The keys plisttool cannot substitute for a product were only dropped from the generated defaults: UTM's utmctl carries CFBundleExecutable in its own Info.plist, and a command line tool has no executable name to substitute. --- Sources/BazelizeKit/Codegen/Codegen+Plist.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index 610bca6..f0023bc 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -107,7 +107,11 @@ extension Target { private func plistContent(project: Project?) -> String? { guard let nodes = infoPlistNodes(project: project) else { return nil } - var dropped = appIcons(project: project) == nil ? [] : Self.iconKeys + /// Keys `plisttool` cannot resolve for this product are as unusable in the + /// target's own `Info.plist` as they are in a default: a command line tool + /// has no `CFBundleExecutable` to substitute. + var dropped = unsupportedDefaultPlistKeys + dropped.formUnion(appIcons(project: project) == nil ? [] : Self.iconKeys) /// The version an embedded bundle declares has to give way to its parent's. if embeddingBundle(project: project) != nil { dropped.formUnion(Self.versionPatterns.keys) @@ -363,7 +367,7 @@ extension Target { /// `plisttool` substitutes only a handful of variables, so a default whose value /// it cannot resolve has to be dropped: `macos_command_line_application` bundles /// no executable. - private var unsupportedDefaultPlistKeys: Set { + var unsupportedDefaultPlistKeys: Set { var keys: Set = [] if productType == "com.apple.product-type.tool" { keys.insert("CFBundleExecutable") From 694065f9392bcd31e177d9a859f853f24eb71244 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 11:12:01 +0800 Subject: [PATCH 102/173] Honor SWIFT_DEFAULT_ACTOR_ISOLATION Xcode compiles the whole module with the default actor isolation this setting names (SE-0466), and code written against MainActor by default does not compile without it. --- .../Codegen/Language/Codegen+SwiftLibrary.swift | 11 +++++++++-- Sources/Xcode2/Model/Config/XCode+BuildSettings.swift | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 5e5cc46..404f6d6 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -97,8 +97,14 @@ extension Target { return ["-parse-as-library"] } + /// `-default-isolation`, which `swiftc` takes for the whole module. + var defaultIsolationCopts: [String] { + guard let isolation = prefer(\.swiftDefaultActorIsolation) else { return [] } + return ["-default-isolation", isolation] + } + func swiftCopts(project: Project) -> [String]? { - var copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts + var copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts + defaultIsolationCopts if bridgingHeader != nil { copts += swiftIncludeCopts(project: project) + forceIncludeCopts() } @@ -109,7 +115,8 @@ extension Target { /// declarations through its own clang module instead. The module's headers can /// still reach for the target's include paths, so `swiftc` needs them too. func moduleSwiftCopts(project: Project) -> [String]? { - let copts = parseAsLibraryCopts + swiftIncludeCopts(project: project) + forceIncludeCopts() + let copts = parseAsLibraryCopts + defaultIsolationCopts + + swiftIncludeCopts(project: project) + forceIncludeCopts() return copts.isEmpty ? nil : copts } diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 46d5fe7..3737b19 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -62,6 +62,14 @@ extension XCode { extension XCode.BuildSettings { public var swiftVersion: String? { self["SWIFT_VERSION"] } + + /// `SWIFT_DEFAULT_ACTOR_ISOLATION`: the module-wide default Xcode compiles with + /// (SE-0466). Code written against `MainActor` by default does not compile + /// without it. + public var swiftDefaultActorIsolation: String? { + guard let value = self["SWIFT_DEFAULT_ACTOR_ISOLATION"], !value.isEmpty else { return nil } + return value + } public var swiftDefine: String? { self["OTHER_SWIFT_FLAGS"] } /// Everything Swift compiles with `-D`: the conditions Xcode dedicates a From f19577b81e15fc3ae3ebabf2508cd86ad1ef3491 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 11:12:12 +0800 Subject: [PATCH 103/173] Generate the strings filegroup for an app extension The extension rule already took a strings attribute, but the filegroup it points at was only generated for an application, a framework and an XPC service, so an extension with a localized table failed analysis. --- Sources/BazelizeKit/Codegen/Codegen+Target.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index ace8d4b..9000bc4 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -53,6 +53,7 @@ extension Target { generateCopiedFiles(builder, kit) generateXPCService(builder, kit) case "com.apple.product-type.app-extension": + generateStrings(builder, kit) generateCopiedProducts(builder, kit) generateCopiedFiles(builder, kit) generateExtension(builder, kit) From 6c239bce97fee7eea126b0fd4334fbf8440b4248 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 11:12:15 +0800 Subject: [PATCH 104/173] Patch SwiftSetting.defaultIsolation into rules_swift_package_manager It parses the setting and then drops it as unrecognized, so a package that declares MainActor as its default isolation compiles as if it had not. It maps to swiftc's -default-isolation. Two files, so two patches: Bazel applies one patch per file. --- .../Plugin/Plugin+SwiftPM+Patch.swift | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift index 7190f00..346eef7 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift @@ -13,7 +13,9 @@ extension PluginSwiftPM { static let patches: [(name: String, content: String)] = [ (name: "rspm-clang-target-headers.patch", content: clangTargetHeadersPatch), - (name: "rspm-metal-headers.patch", content: metalHeadersPatch) + (name: "rspm-metal-headers.patch", content: metalHeadersPatch), + (name: "rspm-default-isolation-settings.patch", content: defaultIsolationSettingsPatch), + (name: "rspm-default-isolation-copts.patch", content: defaultIsolationCoptsPatch) ] /// The `patches` attribute of the module override. @@ -108,4 +110,85 @@ extension PluginSwiftPM { (r, "{}_{}".format(bundle_label_name, _sanitized_bundle_file_name(r.split("/")[-1]))) for r in sorted_resources """# + + /// `SwiftSetting.defaultIsolation` (SE-0466) is parsed and then dropped as an + /// unrecognized setting, so a package written against `MainActor` by default + /// does not compile. Bazel applies one patch per file, so the setting and the + /// flag it maps to come as a pair. + /// + /// Example: IceCubesApp, whose local packages all declare it. + private static let defaultIsolationSettingsPatch = #""" +--- a/swiftpkg/internal/pkginfos.bzl ++++ b/swiftpkg/internal/pkginfos.bzl +@@ -1862,6 +1879,7 @@ + language_modes = [] + experimental_features = [] + upcoming_features = [] ++ default_isolations = [] + for bs in build_settings: + if bs.kind == build_setting_kinds.define: + defines.append(bs) +@@ -1873,6 +1891,8 @@ + experimental_features.append(bs) + elif bs.kind == build_setting_kinds.upcoming_features: + upcoming_features.append(bs) ++ elif bs.kind == build_setting_kinds.default_isolation: ++ default_isolations.append(bs) + else: + # We do not recognize the setting. + pass +@@ -1880,7 +1900,8 @@ + len(unsafe_flags) == 0 and \ + len(language_modes) == 0 and \ + len(experimental_features) == 0 and \ +- len(upcoming_features) == 0: ++ len(upcoming_features) == 0 and \ ++ len(default_isolations) == 0: + return None + return struct( + defines = defines, +@@ -1888,6 +1909,7 @@ + language_modes = language_modes, + experimental_features = experimental_features, + upcoming_features = upcoming_features, ++ default_isolations = default_isolations, + ) + + def _new_linker_settings(build_settings): +@@ -2083,6 +2105,7 @@ + ) + + build_setting_kinds = struct( ++ default_isolation = "defaultIsolation", + define = "define", + header_search_path = "headerSearchPath", + linked_framework = "linkedFramework", +"""# + + /// The other half: `swiftc`'s `-default-isolation`. + private static let defaultIsolationCoptsPatch = #""" +--- a/swiftpkg/internal/swiftpkg_build_files.bzl ++++ b/swiftpkg/internal/swiftpkg_build_files.bzl +@@ -176,6 +176,20 @@ + condition = experimental_feature.condition, + ) + features.append(new_experimental_feature) ++ for bs in target.swift_settings.default_isolations: ++ for default_isolation in lists.flatten(bzl_selects.new_from_build_setting(bs)): ++ # SE-0466: the manifest setting maps to the compiler flag that ++ # controls the module's default actor isolation. ++ copts.append(bzl_selects.new( ++ value = "-default-isolation", ++ kind = default_isolation.kind, ++ condition = default_isolation.condition, ++ )) ++ copts.append(bzl_selects.new( ++ value = default_isolation.value, ++ kind = default_isolation.kind, ++ condition = default_isolation.condition, ++ )) + for bs in target.swift_settings.upcoming_features: + for upcoming_feature in lists.flatten(bzl_selects.new_from_build_setting(bs)): + new_upcoming_feature = bzl_selects.new( +"""# } From 969174dab00fdfda14e41a06a01ef51b4e329c23 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 11:19:24 +0800 Subject: [PATCH 105/173] Emit the rules_swift_package_manager patches only for the release they fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A patch is a diff against the files it was taken from, so applying it to any other release either fails outright or silently lands in the wrong place. The generated workspace now carries the patches — and the override that applies them — only while the pinned version is the one they were written against, and warns when it is not. --- .../Plugin/Plugin+SwiftPM+Patch.swift | 37 ++++++++++++++++--- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 24 ++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift index 346eef7..840478f 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift @@ -4,29 +4,54 @@ // // Patches for rules_swift_package_manager, applied to the generated workspace. // +import Util extension PluginSwiftPM { /// Files rules_swift_package_manager generates for a package are incomplete in - /// two ways that keep real projects from building. Both are patched in the - /// generated workspace so it stands on its own; both belong upstream. + /// ways that keep real projects from building, and no amount of code generation + /// on this side can make up for them. They are patched in the generated + /// workspace so it stands on its own; all of it belongs upstream. static let patchDirectory = "Patches" - static let patches: [(name: String, content: String)] = [ + /// The release the patches were written against. + /// + /// A patch is a diff, so it only applies to the file it was taken from: for any + /// other release the workspace is generated without it, and whatever the newer + /// release does — fixed upstream or still broken — is what the build sees. + static let patchedVersion: BazelDep.SwiftPM = .v1_15_0 + + private static let allPatches: [(name: String, content: String)] = [ (name: "rspm-clang-target-headers.patch", content: clangTargetHeadersPatch), (name: "rspm-metal-headers.patch", content: metalHeadersPatch), (name: "rspm-default-isolation-settings.patch", content: defaultIsolationSettingsPatch), (name: "rspm-default-isolation-copts.patch", content: defaultIsolationCoptsPatch) ] + var patches: [(name: String, content: String)] { + guard dep == Self.patchedVersion else { + let pinned = dep.rawValue + let patched = Self.patchedVersion.rawValue + Log.codeGenerate.warning(""" + rules_swift_package_manager \(pinned, privacy: .public) is not \ + \(patched, privacy: .public): generating without the patches written for it + """) + return [] + } + return Self.allPatches + } + /// The `patches` attribute of the module override. - static var patchLabels: String { + var patchLabels: String { patches.map { patch in - " \"//\(patchDirectory):\(patch.name)\"," + " \"//\(Self.patchDirectory):\(patch.name)\"," }.joined(separator: "\n") } /// The patch files themselves, plus the package that exports them. - static var patchFiles: [PluginBuiltin.Custom] { + var patchFiles: [PluginBuiltin.Custom] { + guard !patches.isEmpty else { return [] } + + let patchDirectory = Self.patchDirectory let exports = patches.map { patch in " \"\(patch.name)\"," }.joined(separator: "\n") diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 4e3c117..a68a2d2 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -17,7 +17,7 @@ final class PluginSwiftPM: PluginBuiltin { /// with a higher floor. Xcode never enforces that, so real projects (e.g. /// SimplyCoreAudio declaring macOS 10.12 while depending on swift-atomics /// declaring 10.13) stop analyzing on versions past 1.15.0. - private let dep: BazelDep.SwiftPM = .v1_15_0 + let dep: BazelDep.SwiftPM = .v1_15_0 let remotes: [RemotePackage] let locals: [LocalPackage] private var packages: [String] = [] @@ -60,15 +60,17 @@ final class PluginSwiftPM: PluginBuiltin { builder.bazel_dep( name: "rules_swift_package_manager", version: dep.rawValue) - builder.custom(""" - single_version_override( - module_name = "rules_swift_package_manager", - patch_strip = 1, - patches = [ - \(Self.patchLabels) - ], - ) - """) + if !patches.isEmpty { + builder.custom(""" + single_version_override( + module_name = "rules_swift_package_manager", + patch_strip = 1, + patches = [ + \(patchLabels) + ], + ) + """) + } builder.custom(""" swift_deps = use_extension( "@rules_swift_package_manager//:extensions.bzl", @@ -235,7 +237,7 @@ final class PluginSwiftPM: PluginBuiltin { override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } - return [package, packageResolved].compactMap { $0 } + Self.patchFiles + return [package, packageResolved].compactMap { $0 } + patchFiles } override var tip: String? { From e903888566bde43feddf1f477ba5c95abda0fc07 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 11:39:15 +0800 Subject: [PATCH 106/173] Treat a resource wrapper's contents as the wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file system synchronized group lists the files inside an asset catalog, not the catalog, and those files carry every extension imaginable — so CodeEdit's catalog was classified as unrelated junk: no asset compile, no app icon, and no generated asset symbols, which its own code references. A path inside a wrapper Xcode treats as one resource is now a resource, and collapses to the wrapper the rules take as an attribute. --- Sources/Xcode2/Loader/XCode+FileLoader.swift | 29 +++++++++++++- .../Xcode2/Model/Target/XCode+Target.swift | 39 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode2/Loader/XCode+FileLoader.swift index c9a60bb..7ff2884 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode2/Loader/XCode+FileLoader.swift @@ -224,7 +224,13 @@ struct SynchronizedFile { } var category: Category { - typedFileType?.category ?? .other + /// A synchronized group lists the files inside a wrapper Xcode treats as one + /// resource — an asset catalog, a `.docc` bundle — and those files have + /// every extension imaginable. What owns them decides what they are. + if path.isInsideResourceWrapper { + return .resource + } + return typedFileType?.category ?? .other } var file: XCode.File { @@ -319,3 +325,24 @@ extension String { return String(dropFirst(prefix.count)) } } + +extension String { + /// Directories Xcode treats as one resource, whatever they contain. + fileprivate static let resourceWrapperExtensions: Set = [ + "bundle", + "docc", + "icon", + "mlpackage", + "scnassets", + "xcassets", + "xcdatamodeld", + "xcstickers" + ] + + fileprivate var isInsideResourceWrapper: Bool { + split(separator: "/").dropLast().contains { component in + let suffix = component.split(separator: ".").last.map(String.init) ?? "" + return Self.resourceWrapperExtensions.contains(suffix) + } + } +} diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode2/Model/Target/XCode+Target.swift index ba0192e..a497a99 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode2/Model/Target/XCode+Target.swift @@ -133,7 +133,10 @@ extension XCode.Target { ] public var resources: [String] { - filePaths(files.resources) + var seen = Set() + return filePaths(files.resources) + .map(\.resourceWrapperPath) + .filter { seen.insert($0).inserted } } public var xibs: [String] { @@ -204,3 +207,37 @@ extension XCode.Target { files.compactMap(filePath) } } + +extension String { + /// Directories Xcode treats as one resource, however they were discovered. + /// + /// A synchronized root group lists the files inside an asset catalog rather + /// than the catalog, and the catalog is what `actool` compiles and what the + /// rules take as an attribute. + fileprivate static let resourceWrapperExtensions: Set = [ + "bundle", + "docc", + "icon", + "mlpackage", + "scnassets", + "xcassets", + "xcdatamodeld", + "xcstickers" + ] + + /// The path truncated at the wrapper that owns it, or the path itself. + fileprivate var resourceWrapperPath: String { + var components: [String] = [] + + for component in split(separator: "/").map(String.init) { + components.append(component) + + let suffix = component.split(separator: ".").last.map(String.init) ?? "" + if Self.resourceWrapperExtensions.contains(suffix) { + return components.joined(separator: "/") + } + } + + return self + } +} From 1e4636574495a44b24cab9d2d752a55be403fbb8 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 11:39:25 +0800 Subject: [PATCH 107/173] Patch a local archive binary target into rules_swift_package_manager A binaryTarget whose path points at an archive in the checkout is ignored: the artifact scan looks for a directory, finds none and generates no target, while the package's own products still depend on it. SPM unzips such an archive itself. --- .../Plugin/Plugin+SwiftPM+Patch.swift | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift index 840478f..61e22c1 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift @@ -24,7 +24,8 @@ extension PluginSwiftPM { (name: "rspm-clang-target-headers.patch", content: clangTargetHeadersPatch), (name: "rspm-metal-headers.patch", content: metalHeadersPatch), (name: "rspm-default-isolation-settings.patch", content: defaultIsolationSettingsPatch), - (name: "rspm-default-isolation-copts.patch", content: defaultIsolationCoptsPatch) + (name: "rspm-default-isolation-copts.patch", content: defaultIsolationCoptsPatch), + (name: "rspm-local-archive-artifact.patch", content: localArchiveArtifactPatch) ] var patches: [(name: String, content: String)] { @@ -216,4 +217,31 @@ extension PluginSwiftPM { for upcoming_feature in lists.flatten(bzl_selects.new_from_build_setting(bs)): new_upcoming_feature = bzl_selects.new( """# + + /// A binary target whose `path` points at an archive in the checkout is ignored: + /// the artifact scan looks for a directory, finds nothing and generates no + /// target, while the package's own products still depend on it. SPM unzips such + /// an archive itself. + /// + /// Example: CodeEditLanguages, which ships + /// `CodeLanguagesContainer.xcframework.zip`. + private static let localArchiveArtifactPatch = #""" +--- a/swiftpkg/internal/repo_rules.bzl ++++ b/swiftpkg/internal/repo_rules.bzl +@@ -151,6 +151,14 @@ + repository_ctx.file(path, content = content, executable = False) + + def _artifact_infos_from_path(repository_ctx, path): ++ # A binary target can point at an archive in the checkout, which SPM unzips ++ # itself; nothing in it is visible until it is extracted. ++ if path.endswith(".zip") and not repository_files.is_directory(repository_ctx, path): ++ output = path + ".extracted" ++ if not repository_files.path_exists(repository_ctx, output): ++ repository_ctx.extract(archive = path, output = output) ++ path = output ++ + if path.endswith(".xcframework"): + xcframework_dirs = [path] + else: +"""# } From 625de6c3e8724c9b23539894e4ce2f03c6adadfe Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 12:16:21 +0800 Subject: [PATCH 108/173] Pin swift-package-manager to the toolchain's release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftPMDataModel is what the legacy XCode target parses manifests with, and staying on 6.2.4 meant building a library from one toolchain against another. 6.3 requires macOS 14, so the package's own floor moves with it — this is a command line tool built with the current Xcode, not a library anyone deploys back. --- Package.resolved | 53 ++++++++++++++++++++---------------------------- Package.swift | 10 ++++----- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/Package.resolved b/Package.resolved index 1dbf1f4..64f939d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b56c62d8ee7081df49e35cad75d735d86b3c168c73793b3e87772068182a960c", + "originHash" : "71e46f151f66c00f8708834f10a3b7777749f4ed6d5c3ce01606075637311f02", "pins" : [ { "identity" : "aexml", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "9f542610331815e29cc3821d3b6f488db8715517", - "version" : "1.6.0" + "revision" : "d9a5b37470adc940d22c3bcd5ca6953a516b727f", + "version" : "1.7.2" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "2f797305c1b5b982acaa6005d8a9f970cc4e97ff", - "version" : "1.5.0" + "branch" : "1.10.1", + "revision" : "386001a92200c70fd06217b3ccad58d7226edb84" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "c11818f3cae0780656baa430b49e7f163f08dffd", - "version" : "1.1.6" + "branch" : "1.1.6", + "revision" : "c11818f3cae0780656baa430b49e7f163f08dffd" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "629f0b679d0fd0a6ae823d7f750b9ab032c00b80", - "version" : "3.0.0" + "branch" : "3.12.5", + "revision" : "d79c573e1b400d670ed12c0cb29d33f2c0f5ab70" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-driver.git", "state" : { - "branch" : "release/6.2", - "revision" : "aedacc6c1583db4f2989a367e3c41968558a5b8e" + "branch" : "release/6.3", + "revision" : "7d6b844f0c2497a997770a11536598b187066be9" } }, { @@ -96,17 +96,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-llbuild.git", "state" : { - "branch" : "release/6.2", - "revision" : "073dff55529d7c4ecbd615ab5f5ac52ae5b380da" + "branch" : "release/6.3", + "revision" : "e38525ae3519021f014ad91e7bf86e7ae86044f5" } }, { "identity" : "swift-package-manager", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-package-manager", + "location" : "https://github.com/swiftlang/swift-package-manager", "state" : { - "branch" : "swift-6.2.4-RELEASE", - "revision" : "215e9f91823d7e44c379fa17bf1eef189438fc24" + "branch" : "swift-6.3.3-RELEASE", + "revision" : "5f6969f5b083b4415632114d4897c6f820761a7f" } }, { @@ -118,22 +118,13 @@ "version" : "1.0.0" } }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax.git", - "state" : { - "branch" : "release/6.2", - "revision" : "5a87516fc3dddbd23cb76358eb489915ee86b444" - } - }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version" : "1.8.1" + "branch" : "1.5.0", + "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6" } }, { @@ -141,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-toolchain-sqlite.git", "state" : { - "revision" : "d9b11cb79071d5ab992ff47dfd2e8d6e19418d97", - "version" : "1.0.13" + "branch" : "1.0.7", + "revision" : "b45b80b943e88db3cb8ddea798fa3fa9912375ff" } }, { @@ -150,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-tools-support-core.git", "state" : { - "branch" : "release/6.2", - "revision" : "5a993c8848487c934d53ffceef8e1cad0a241dc1" + "branch" : "release/6.3", + "revision" : "44be92e627f754f593ca99f1b0c982e389e9bb20" } }, { diff --git a/Package.swift b/Package.swift index ae9bfc7..f6f0b1f 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Bazelize", platforms: [ - .macOS(.v13), + .macOS(.v14), ], products: [ .executable(name: "bazelize", targets: ["Bazelize"]), @@ -23,11 +23,11 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), - /// SwiftPMDataModel for the legacy `XCode` target. - /// 6.3+ requires macOS 14, which would raise this package's platform floor. + /// SwiftPMDataModel for the legacy `XCode` target, pinned to the release + /// that matches the toolchain; it is what sets this package's macOS floor. .package( - url: "https://github.com/apple/swift-package-manager", - branch: "swift-6.2.4-RELEASE"), + url: "https://github.com/swiftlang/swift-package-manager", + branch: "swift-6.3.3-RELEASE"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. From a58b81f180c457755bb68d62e2fc3f9a5e12deeb Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 12:34:34 +0800 Subject: [PATCH 109/173] Move swift-package-manager to 6.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the toolchain again, now that Xcode 27 is selected. Its ResolvedPackage stopped being Sendable, which broke the only use of it — a package-graph walk nobody called — so that goes with it; the manifest parse the legacy XCode target actually needs is untouched. --- Package.resolved | 22 +++++++++++----------- Package.swift | 2 +- Sources/XCode/Model/SPMParser.swift | 14 -------------- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/Package.resolved b/Package.resolved index 64f939d..228516d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "71e46f151f66c00f8708834f10a3b7777749f4ed6d5c3ce01606075637311f02", + "originHash" : "3dd5a307c2bc9d60c7468d94a1bce6cef8c59cfc0a2c274a756a49f39eb215b1", "pins" : [ { "identity" : "aexml", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-driver.git", "state" : { - "branch" : "release/6.3", - "revision" : "7d6b844f0c2497a997770a11536598b187066be9" + "branch" : "release/6.4.x", + "revision" : "174567a5681a9a949bcd52f821deb8fa65105434" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-llbuild.git", "state" : { - "branch" : "release/6.3", - "revision" : "e38525ae3519021f014ad91e7bf86e7ae86044f5" + "branch" : "release/6.4.x", + "revision" : "ab6421207b9e4971c94e97c5832a3d8a4cae9092" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-package-manager", "state" : { - "branch" : "swift-6.3.3-RELEASE", - "revision" : "5f6969f5b083b4415632114d4897c6f820761a7f" + "branch" : "swift-6.4.0-RELEASE", + "revision" : "18da3eb1e770679f6910890fbd52e95af53f67d2" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-toolchain-sqlite.git", "state" : { - "branch" : "1.0.7", - "revision" : "b45b80b943e88db3cb8ddea798fa3fa9912375ff" + "branch" : "1.0.9", + "revision" : "c0ecc1e0fd1b4fbc38db1efa6113bc12c2a4e559" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-tools-support-core.git", "state" : { - "branch" : "release/6.3", - "revision" : "44be92e627f754f593ca99f1b0c982e389e9bb20" + "branch" : "release/6.4.x", + "revision" : "d45c8b38d2824498b7863d3d5f0227937a53c177" } }, { diff --git a/Package.swift b/Package.swift index f6f0b1f..a17d6a8 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( /// that matches the toolchain; it is what sets this package's macOS floor. .package( url: "https://github.com/swiftlang/swift-package-manager", - branch: "swift-6.3.3-RELEASE"), + branch: "swift-6.4.0-RELEASE"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/Sources/XCode/Model/SPMParser.swift b/Sources/XCode/Model/SPMParser.swift index b486081..596af36 100644 --- a/Sources/XCode/Model/SPMParser.swift +++ b/Sources/XCode/Model/SPMParser.swift @@ -45,18 +45,4 @@ public enum SPMParser { return (products, targets) } - - public static func allPackageNames(path: String) async throws -> [String] { - let packagePath = try Basics.AbsolutePath(validating: path) - let observability = ObservabilitySystem { _,_ in } - - let workspace = try Workspace(forRootPackage: packagePath) - let graph = try await workspace.loadPackageGraph( - rootPath: packagePath, - observabilityScope: observability.topScope) - - return graph.packages.filter { package in - !graph.isRootPackage(package) - }.map(\.manifest.displayName) - } } From a8542b735c0c34c4e6865abb60cd27709b9b0e52 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 13:10:15 +0800 Subject: [PATCH 110/173] Add the roadmap for generating SwiftPM build files Describes the output structure a self-hosted SwiftPM generator produces, what each SwiftPM concept maps to, and the staged path off rules_swift_package_manager. --- Roadmap.md | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 Roadmap.md diff --git a/Roadmap.md b/Roadmap.md new file mode 100644 index 0000000..34cf90f --- /dev/null +++ b/Roadmap.md @@ -0,0 +1,179 @@ +# Roadmap: 自己產生 SwiftPM 的 BUILD + +目前 SwiftPM 依賴是交給 `rules_swift_package_manager`(以下 rspm)處理的: +bazelize 只在 `MODULE.bazel` 裡宣告它、寫一份 `Package.swift`,剩下的 BUILD +由 rspm 在 fetch 階段產生在 external repo 裡。 + +這份文件描述「改成自己產生」之後,**輸出結構**長什麼樣,以及分階段的做法。 +它只談產物形狀與責任邊界,不談實作細節。 + +--- + +## 為什麼要換 + +1. rspm 產出的 BUILD 有幾處對真實專案不夠用,我們現在用 4 個 vendored patch + 補(`Patches/rspm-*.patch` + `single_version_override`),還得帶版本守門。 +2. 我們被釘在 rspm 1.15.0:≥1.16 會把每個 SwiftPM target 轉場到它自己宣告的 + platform floor,然後在依賴宣告更高版本時 analysis 失敗——Xcode 從不這樣做。 + 平台語義本來就是 bazelize 的主場,自己產生就不會打架。 +3. Xcode target 的 header/resource/plist 處理已經在 bazelize 裡了,package + target 走同一套才會行為一致。 +4. 產物變成簽入的檔案,出問題直接讀檔,不必追 repo rule。 +5. 少一段 `bazel mod tidy` 補 `use_repo` 清單的流程。 + +代價:SwiftPM 的語義(traits、registry、binary target、plugin、macro)從此是 +我們的責任。 + +--- + +## 現在的輸出(rspm 版,作為對照) + +``` +App/ +├── MODULE.bazel # bazel_dep(rules_swift_package_manager) +│ # + swift_deps.from_package + use_repo(...) +│ # + single_version_override(patches = …) +├── Patches/ # vendored rspm patch(版本守門) +│ ├── BUILD +│ └── rspm-*.patch +├── Package.swift # 給 rspm 讀的合成 manifest +├── Package.resolved # 由 Xcode 的 Package.resolved 播種 +├── config.bazelrc +├── BUILD +├── Prebuilt/ # 專案自帶的 .framework/.a/.dylib(symlink) +└── Targets// + ├── BUILD + ├── Sources/ # 指向原始碼的 symlink 樹 + ├── Headers// # 扁平化 header 樹 + ├── Generated/ # BazelizeDefines.h、entitlements、asset symbols + └── CopyFiles// # copy phase 目的地 +``` + +package 的 BUILD 不在這裡,而在 `external/rules_swift_package_manager++swift_deps+swiftpkg_/`。 + +--- + +## 新的輸出(自己產生) + +``` +App/ +├── MODULE.bazel # 不再有 rspm;改成每個遠端 package 一個 repo rule +├── Package.swift # 保留:仍用 SwiftPM 解析依賴圖 +├── Package.resolved # 保留:pin 的唯一來源 +├── config.bazelrc +├── BUILD +├── Prebuilt/ +├── Targets// # 完全不變 +└── Packages/ # ★ 新增 + ├── BUILD # exports_files:給 repo rule 的 build_file 用 + └── / + ├── BUILD.bazel # 該 package 全部 target 的規則(我們產生) + ├── Generated/ + │ ├── ResourceBundleAccessor.swift + │ ├── .modulemap + │ └── Defines.h + └── Sources/ # 僅本地 package:指向 checkout 的 symlink 樹 +``` + +`Patches/` 整組消失。 + +### 遠端 package 怎麼進來 + +`MODULE.bazel` 為每個遠端 package 產生一個 repo rule,revision 直接取自 +`Package.resolved`,BUILD 用我們簽入的那份: + +```python +git_repository( + name = "swiftpkg_sfsafesymbols", + remote = "https://github.com/SFSafeSymbols/SFSafeSymbols", + commit = "…", # Package.resolved 的 revision + build_file = "//Packages/SFSafeSymbols:BUILD.bazel", +) +``` + +這麼做的性質: + +- **hermetic**:由 Bazel 抓、pin 到 revision,不依賴 `.build/checkouts`。 +- **可讀**:BUILD 在我們的 repo 裡,不是產生在 external repo 裡。 +- **仍要 `swift package resolve`**:但只在「依賴變動時」跑一次,用來更新 + `Package.resolved` 與讓 bazelize 讀到 manifest;build 本身不需要它。 + +### 本地 package 怎麼進來 + +本地 package(`.package(path: "../Packages/Account")`)和 Xcode target 同一個 +workspace,走和 `Targets/` 相同的 symlink 樹,不需要 repo rule: + +``` +Packages/Account/ +├── BUILD.bazel +├── Sources/ → symlink 到 ../../../Packages/Account/Sources +└── Generated/ +``` + +label 形如 `//Packages/Account:Account`。 + +### Label 命名 + +| 對象 | 現在(rspm) | 新的 | +|---|---|---| +| 遠端 package 的 product | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | +| 本地 package 的 product | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | +| package 內部 target | `…//:Target.rspm` | `…//:Target`(不再有 `.rspm` 後綴) | + +遠端 repo 名沿用 `swiftpkg_`,避免一次改動太多;`Targets/*/BUILD` +裡對遠端 product 的引用因此**不必改**。 + +--- + +## SwiftPM 概念 → 產出的規則 + +| SwiftPM | 產出 | +|---|---| +| Swift target | `swift_library` | +| clang target(C/ObjC/C++) | `objc_library`,header/include 沿用 bazelize 現有邏輯 | +| 混合 target | `mixed_language_library` | +| system-library target | `cc_library` + 我們產生的 modulemap | +| binary target(xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | +| binary target(本地 archive) | 先解壓,再同上 | +| `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | +| auto-discovered resources(xib/xcassets/metal/xcstrings) | 同上,`.metal` 連同該 target 的 header 一起進 resource group | +| `defines` | `-D`(值不安全時走 `Generated/Defines.h`,與 Xcode target 同策略) | +| `headerSearchPath` | `includes` | +| `linkedLibrary` / `linkedFramework` | `linkopts` / `sdk_frameworks` | +| `swiftLanguageMode` | `-swift-version` | +| `enableUpcomingFeature` / `enableExperimentalFeature` | rules_swift 的 `features` | +| `defaultIsolation` | `-default-isolation ` | +| `unsafeFlags` | `copts` | +| build tool plugin(SwiftLint 等) | 階段 3;先跳過並警告 | +| macro / compiler plugin | 階段 3;`swift_compiler_plugin` | +| traits(SE-0450) | 依 enabled traits 展開成 `-D` 與條件依賴 | + +--- + +## 分階段與通過條件 + +每一階段的通過條件都一樣:**12 個 app 至少維持現狀**(7 個綠的仍綠、blocked 的 +理由不變),加上 114 單元測試與 iOS fixture。 + +| 階段 | 範圍 | 目標 app | +|---|---|---| +| 0 | 只做統計:掃現有 136 個 checkout,量出用到哪些 target 種類/setting/resource/plugin/macro | — | +| 1 | 純 Swift target、無 resource、無 plugin;flag 切換,預設仍走 rspm | Rectangle(2 個 package) | +| 2 | clang target、resource bundle + accessor、binary target | MonitorControl、SwiftBar、VirtualBuddy、iina | +| 3 | build tool plugin、macro | CotEditor、IceCubesApp | +| 4 | 預設切換,移除 rspm 依賴、`Patches/` 與版本守門 | 全部 | + +階段 1–3 期間 rspm 與自製產生器**不混用**:同一個 workspace 只走其中一條,由 +flag 決定;混用會產生兩張依賴圖。 + +--- + +## 待決事項 + +1. 遠端 package 用 `git_repository`(pin revision)還是 `http_archive` + (pin tarball + sha256)?後者快、可快取,但 `Package.resolved` 只給 revision, + 要自己組 tarball URL 並算 checksum。 +2. registry package(`.package(id:)`)階段幾支援?目前語料沒有。 +3. `Package.swift` 是否還需要出現在產物裡?只有 `swift package resolve` 需要它, + 可以改成只在更新 pin 時才產生。 +4. 階段 1–4 期間,上游 rspm PR 還要不要送?(我建議要,patch 很小,對別人也有用) From 84728fe2dfbc04632d5416008e8d121eecfac1b8 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 14:19:40 +0800 Subject: [PATCH 111/173] Reach a Swift package product through //Packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every target now depends on //Packages/:, and one generated BUILD per package aliases that to whatever implements it — today the external repository rules_swift_package_manager generates. Replacing that generator is then a change to Packages/ alone: no target's deps name a package repository, so none of them move, and the tests stop pinning rspm's naming rules. --- .../Plugin/Plugin+SwiftPM+Facade.swift | 82 +++++++++++++++++++ .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 68 +++++++++------ .../XCode2Tests/RoadmapTreeBuilderTests.swift | 19 ++++- 3 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift new file mode 100644 index 0000000..d81dbe1 --- /dev/null +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift @@ -0,0 +1,82 @@ +// +// Plugin+SwiftPM+Facade.swift +// +// +// One label shape for every Swift package product, whatever generates it. +// + +import Foundation +import PathKit +import Util + +extension PluginSwiftPM { + /// Every package product a target links reaches it through `//Packages`, not + /// through the repository whatever tool generated it happens to use. + /// + /// The generated rules behind a product are an implementation detail: today + /// rules_swift_package_manager produces them in an external repository, and the + /// facade is an `alias` pointing there. Replacing that with rules bazelize + /// generates itself is then a change to `Packages/` alone — no target's `deps` + /// mention a package repository, so none of them move. + static let packagesDirectory = "Packages" + + /// A product a target links: which package it belongs to, and the rule that + /// implements it today. + struct FacadeProduct { + let package: String + let product: String + let actual: String + } + + /// `//Packages/SFSafeSymbols:SFSafeSymbols` + func facadeLabel(package: String, product: String) -> String { + "//\(Self.packagesDirectory)/\(package):\(product)" + } + + /// One `BUILD` per package, aliasing each product a target actually links. + var facadeFiles: [PluginBuiltin.Custom] { + let grouped = Dictionary(grouping: facadeProducts) { product in + product.package + } + + return grouped.keys.sorted().compactMap { package -> PluginBuiltin.Custom? in + guard let products = grouped[package] else { return nil } + + var seen = Set() + let aliases = products + .sorted { $0.product < $1.product } + .filter { seen.insert($0.product).inserted } + .map { product in + """ + alias( + name = "\(product.product)", + actual = "\(product.actual)", + visibility = ["//visibility:public"], + ) + """ + } + + return .init( + path: "\(Self.packagesDirectory)/\(package)/BUILD", + content: ([Self.facadeHeader] + aliases).joined(separator: "\n") + "\n") + } + } + + // MARK: Private + + private static let facadeHeader = """ + # Generated using Bazelize + # + # The rules behind a package product live elsewhere; a target depends on the + # product, never on where it is generated. + + """ + + /// The products every target links, with the package they belong to and the + /// rule that currently implements them. + private var facadeProducts: [FacadeProduct] { + kit.project.targets + .flatMap(\.dependencies.packageProducts) + .compactMap(facadeProduct) + } +} diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index a68a2d2..b418d7a 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -92,18 +92,24 @@ final class PluginSwiftPM: PluginBuiltin { """) } - private func transformRemote(_ product: PackageProductDependency) -> String? { - /// NIO + /// The package a product belongs to, the product, and the rule that implements + /// it today. `nil` when the product cannot be traced back to a package. + func facadeProduct(_ product: PackageProductDependency) -> FacadeProduct? { + remoteProduct(product) ?? localProduct(product) + } + + /// NIO, from a remote package. + /// + /// Only the repository name is sanitized: rules_swift_package_manager keeps the + /// product name verbatim, dashes included (`SwiftUIIntrospect-Static`). + private func remoteProduct(_ product: PackageProductDependency) -> FacadeProduct? { let name = product.productName guard let url = product.package ?? remoteURL(forProduct: name) else { return nil } - /// @swiftpkg_swift_nio//:NIO - /// - /// Only the repository name is sanitized: rules_swift_package_manager keeps - /// the product name verbatim, dashes included (`SwiftUIIntrospect-Static`). - return """ - @\(Self.repositoryName(url: url))//:\(name) - """ + return .init( + package: Self.packageDirectoryName(url: url), + product: name, + actual: "@\(Self.repositoryName(url: url))//:\(name)") } /// Xcode can reference a package product without linking it back to the package. @@ -115,33 +121,35 @@ final class PluginSwiftPM: PluginBuiltin { } } - private func transformLocal(_ product: PackageProductDependency) -> String? { + private func localProduct(_ product: PackageProductDependency) -> FacadeProduct? { let product = product.productName - let path: String + let directory: String + let repository: String if let packagePath = kit.project.localPackagePathByProduct[product] { - path = Path(packagePath).lastComponent.lowercased() - } else if let packagePath = kit.project.localPackageRepoByProduct[product] { - path = packagePath.replacingOccurrences(of: "swiftpkg_", with: "") + directory = Path(packagePath).lastComponent + repository = directory.lowercased() + } else if let packageRepo = kit.project.localPackageRepoByProduct[product] { + repository = packageRepo.replacingOccurrences(of: "swiftpkg_", with: "") + directory = repository } else { return nil } - return """ - @swiftpkg_\(path)//:\(product) - """ + return .init( + package: directory, + product: product, + actual: "@swiftpkg_\(repository)//:\(product)") } override var target: [String : [String]]? { - let targets = kit.project.targets - - return targets.map { target -> (String, [String]) in - let deps = target.dependencies.packageProducts - - let remote = deps.compactMap(transformRemote) - let local = deps.compactMap(transformLocal) - let all: [String] = Set(remote + local).sorted() - return (target.name, all) + kit.project.targets.map { target -> (String, [String]) in + let labels = target.dependencies.packageProducts + .compactMap(facadeProduct) + .map { product in + facadeLabel(package: product.package, product: product.product) + } + return (target.name, Set(labels).sorted()) }.toDictionary() } @@ -237,7 +245,7 @@ final class PluginSwiftPM: PluginBuiltin { override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } - return [package, packageResolved].compactMap { $0 } + patchFiles + return [package, packageResolved].compactMap { $0 } + patchFiles + facadeFiles } override var tip: String? { @@ -280,6 +288,12 @@ final class PluginSwiftPM: PluginBuiltin { value.replacingOccurrences(of: "-", with: "_") } + /// The directory a package's products are exposed under, named the way a human + /// refers to the package. + static func packageDirectoryName(url: String) -> String { + repositoryModuleName(url: url) + } + private static func repositoryModuleName(url: String) -> String { let component = Path(url).lastComponent if component.hasSuffix(".git") { diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 2e8b88a..4dcc40e 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -57,9 +57,12 @@ struct RoadmapTreeBuilderTests { #expect(exampleBuild.contains("name = \"Example_library\"")) #expect(exampleBuild.contains("//Targets/Framework1:Framework1_library")) #expect(exampleBuild.contains("//Prebuilt:SVProgressHUD")) - #expect(exampleBuild.contains("@swiftpkg_anycodable//:AnyCodable")) - #expect(exampleBuild.contains("@swiftpkg_local1//:LocalLib1")) - #expect(exampleBuild.contains("@swiftpkg_local1//:LocalLib2")) + /// A target depends on the package product, never on the repository that + /// happens to implement it. + #expect(exampleBuild.contains("//Packages/AnyCodable:AnyCodable")) + #expect(exampleBuild.contains("//Packages/Local1:LocalLib1")) + #expect(exampleBuild.contains("//Packages/Local1:LocalLib2")) + #expect(!exampleBuild.contains("@swiftpkg_")) #expect(exampleBuild.contains("plist_fragment(")) let frameworkBuild = try String(contentsOfFile: (output + "Targets/Framework1/BUILD").string) @@ -84,6 +87,13 @@ struct RoadmapTreeBuilderTests { #expect(module.contains("rules_swift_package_manager")) #expect(module.contains("swift_deps = use_extension")) #expect(module.contains("swiftpkg_local1")) + + /// The facade is where the repository that implements a product is named. + let localFacade = try String(contentsOfFile: (output + "Packages/Local1/BUILD").string) + #expect(localFacade.contains("name = \"LocalLib1\"")) + #expect(localFacade.contains("actual = \"@swiftpkg_local1//:LocalLib1\"")) + let remoteFacade = try String(contentsOfFile: (output + "Packages/AnyCodable/BUILD").string) + #expect(remoteFacade.contains("actual = \"@swiftpkg_anycodable//:AnyCodable\"")) } @Test @@ -155,7 +165,8 @@ struct RoadmapTreeBuilderTests { /// The two command line tools Xcode copies into `Contents/MacOS`. #expect(appBuild.contains("\"//Targets/iina-cli:iina-cli\": \"MacOS\"")) #expect(appBuild.contains("\"//Targets/iina-plugin:iina-plugin\": \"MacOS\"")) - #expect(appBuild.contains("@swiftpkg_grmustache.swift//:Mustache")) + #expect(appBuild.contains("//Packages/GRMustache.swift:Mustache")) + #expect(!appBuild.contains("@swiftpkg_")) #expect(appBuild.contains("macos_application(")) #expect(appBuild.contains("minimum_os_version = \"10.15\"")) From 9a00ab17b89c97f72140b7ab3f740bf12cddc64b Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 14:20:41 +0800 Subject: [PATCH 112/173] Record the package facade in the roadmap --- Roadmap.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/Roadmap.md b/Roadmap.md index 34cf90f..c248be4 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -112,16 +112,31 @@ Packages/Account/ label 形如 `//Packages/Account:Account`。 -### Label 命名 +### Label 命名:facade(已實作) -| 對象 | 現在(rspm) | 新的 | +所有 package product——遠端或本地——在 `Targets/*/BUILD` 裡都是同一個形狀: + +| 對象 | 之前 | 現在 | |---|---|---| -| 遠端 package 的 product | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | +| 遠端 package 的 product | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `//Packages/SFSafeSymbols:SFSafeSymbols` | | 本地 package 的 product | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | -| package 內部 target | `…//:Target.rspm` | `…//:Target`(不再有 `.rspm` 後綴) | -遠端 repo 名沿用 `swiftpkg_`,避免一次改動太多;`Targets/*/BUILD` -裡對遠端 product 的引用因此**不必改**。 +`Packages//BUILD` 是一層 alias,指向目前實作它的東西: + +```python +alias( + name = "SFSafeSymbols", + actual = "@swiftpkg_sfsafesymbols//:SFSafeSymbols", + visibility = ["//visibility:public"], +) +``` + +目錄名取人看得懂的 package 名(remote 用 URL 最後一段去掉 `.git`,local 用 +目錄名),所以 `//Packages/GRMustache.swift:Mustache` 這種帶點的名字也成立。 + +意義:**換掉 SPM 實作只動 `Packages/` 底下的檔案**。階段 4 把 alias 換成規則 +本體時,沒有任何 target 的 `deps` 需要改;中途要回退,把 alias 指回 rspm 即可。 +測試也不再釘 rspm 的 repo 命名規則。 --- From 969b4556b0cadb22d6c68a0cc1ecc1c9415fb567 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 14:28:06 +0800 Subject: [PATCH 113/173] Record the SwiftPM feature survey in the roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 119 packages and 208 non-test targets, read out of the manifest dumps the current generator already produces. The result reshapes the plan: no macro targets at all, and every plugin in the corpus is SwiftLint — lint only, no generated sources — so macros and real plugins move out of the critical path and clang, resources, binary and system targets are all that stand between stage 2 and the whole corpus. --- Roadmap.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/Roadmap.md b/Roadmap.md index c248be4..1623ebb 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -165,17 +165,94 @@ alias( --- +## 階段 0 的結果(已量測) + +語料:12 個 app 目前展開出的 **119 個 package/208 個非測試 target**(讀 rspm 產生 +在 external repo 裡的 `dump.json` 與 `desc.json`;`swift package dump-package` +與 `describe` 的輸出)。 + +### target 種類 + +| module type | 數量 | +|---|---| +| SwiftTarget | 170 | +| ClangTarget | 50 | +| BinaryTarget | 2 | +| SystemLibraryTarget | 2 | +| PluginTarget | 1 | + +沒有 macro target,也沒有混合語言 target(SwiftPM 本來就不允許)。 + +### build settings(用到的 target 數/package 數) + +| setting | targets | packages | +|---|---|---| +| `swift.enableUpcomingFeature` | 116 | 5 | +| `c.headerSearchPath` | 44 | 28 | +| `swift.strictMemorySafety` | 23 | 4 | +| `swift.enableExperimentalFeature` | 21 | 14 | +| `swift.define` | 14 | 4 | +| `swift.swiftLanguageMode` | 13 | 13 | +| `swift.defaultIsolation` | 10 | 10 | +| `c.define` | 6 | 3 | +| `linker.linkedLibrary` | 1 | 1 | +| `linker.linkedFramework` | 1 | 1 | +| `swift.unsafeFlags` | 1 | 1 | + +### 其他形狀 + +- **resources**:32 個 package(`.copy` 37 處、`.process` 4 處)→ 需要 resource + bundle 與 `Bundle.module` accessor。 +- **manifest 形狀**:明列 `sources` 30 個 target、`exclude` 32、 + `publicHeadersPath` 33 → clang target 的檔案收集不能只靠慣例。 +- **tools-version** 從 4.2 到 6.3 都有(最多的是 5.3,30 個)。 +- **plugin 使用**:9 個 package,**全部是 SwiftLint** + (`SwiftLintPlugin` 5 個、`SwiftLintPlugins` 4 個)——只做 lint,不產生原始碼。 +- **plugin target**:只有 1 個,swift-argument-parser 的 `GenerateManual`, + 語料裡沒有人消費它。 +- **binary target**:2 個(Sparkle 的遠端 xcframework、CodeEditLanguages 的本地 + `.zip`)。 + +### 這代表什麼 + +把「lint-only 的 build tool plugin 略過(印警告)」和「不產生也不輸出沒人消費的 +plugin target/product」當成規則,語料裡**119 個 package 全部落在階段 1–2**: + +| 階段支援的範圍 | 覆蓋 package | +|---|---| +| 純 Swift library、無 resource | 58 | +| + clang/resources/binary/system | 61(累計 119) | +| macro、會產生原始碼的 plugin | 0(語料裡沒有) | + +每個 app 需要的最低階段(用各 workspace 的 `Package.resolved` 展開): + +| app | pins | 需要到 | +|---|---|---| +| Rectangle | 2 | 階段 2 | +| SwiftBar | 5 | 階段 2 | +| MonitorControl | 6 | 階段 2 | +| iina | 4 | 階段 2 | +| IceCubesApp | 20 | 階段 2 | +| VirtualBuddy | 6 | 階段 2(只差 argument-parser 的 plugin target 要略過) | +| UTM | 15 | 階段 2(同上) | +| PlayCover | 8 | 階段 2(同上) | +| CotEditor | 29 | 階段 2(+SwiftLint plugin 略過) | +| CodeEdit | 34 | 階段 2(+SwiftLint plugin 略過) | + +也就是說:**macro 與真 plugin 可以整段延後**,階段 2 做完就能覆蓋全部語料。 + ## 分階段與通過條件 每一階段的通過條件都一樣:**12 個 app 至少維持現狀**(7 個綠的仍綠、blocked 的 理由不變),加上 114 單元測試與 iOS fixture。 -| 階段 | 範圍 | 目標 app | +| 階段 | 範圍 | 目標 | |---|---|---| -| 0 | 只做統計:掃現有 136 個 checkout,量出用到哪些 target 種類/setting/resource/plugin/macro | — | -| 1 | 純 Swift target、無 resource、無 plugin;flag 切換,預設仍走 rspm | Rectangle(2 個 package) | -| 2 | clang target、resource bundle + accessor、binary target | MonitorControl、SwiftBar、VirtualBuddy、iina | -| 3 | build tool plugin、macro | CotEditor、IceCubesApp | +| 0 ✅ | 量測語料 | 見上 | +| 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | +| 1 | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`unsafeFlags`;lint-only plugin 略過並警告;plugin target 不產生。flag 切換,預設仍 rspm | 58 個 package 能單獨建起來 | +| 2 | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 全部 12 個 app 至少維持現狀 | +| 3 | macro/會產生原始碼的 build tool plugin | 語料外的需求出現時再做 | | 4 | 預設切換,移除 rspm 依賴、`Patches/` 與版本守門 | 全部 | 階段 1–3 期間 rspm 與自製產生器**不混用**:同一個 workspace 只走其中一條,由 From b12e8e2c08d7d67f1df51639d4db5fc71ce3200d Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 14:52:08 +0800 Subject: [PATCH 114/173] Let a swift_library declare developer search paths and tags --- Sources/BazelRules/Rules+Swift.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index b7977fc..f76ff14 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -128,6 +128,7 @@ extension Rules.Swift { public static func swift_library( name: String, alwayslink: Bool = true, + always_include_developer_search_paths: Bool? = nil, copts: [String]? = nil, module_name: String? = nil, srcs: Starlark.Value, @@ -140,6 +141,7 @@ extension Rules.Swift { linkstatic: Bool? = nil, private_deps: Starlark.Value? = nil, swiftc_inputs: Starlark.Value? = nil, + tags: [String]? = nil, testonly: Bool? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call @@ -147,6 +149,9 @@ extension Rules.Swift { Rules.Swift.swift_library.call { "name" => name "alwayslink" => alwayslink + if let always_include_developer_search_paths { + "always_include_developer_search_paths" => always_include_developer_search_paths + } if let copts { "copts" => copts } @@ -182,6 +187,9 @@ extension Rules.Swift { if let swiftc_inputs { "swiftc_inputs" => swiftc_inputs } + if let tags { + "tags" => tags + } if let testonly { "testonly" => testonly } From ec5bf54a6b1bbbae833bf6937fd7eaa497da4db7 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 14:52:08 +0800 Subject: [PATCH 115/173] Generate the rules for a pure-Swift package from its manifest A project's Swift packages are handled by rules_swift_package_manager, which pins us to 1.15.0: later versions transition every package target to the platform floor it declares, and Xcode never does that. The rules for a package are now generated here instead, behind --spm native. SwiftPM still owns resolution: swift package resolve writes the pins, and each checkout's manifest is read with swift package dump-package, which is offline and spans every tools version in the graph. A package becomes a directory under Packages/ holding a generated BUILD and one symlink to those sources, the same shape Targets/ already has. A target kind that is not generated yet is skipped with a warning, and so is every target depending on it: a library missing a target it links is worse than a library that is not there at all. --- Package.swift | 1 + Sources/Bazelize/Command.swift | 8 +- Sources/BazelizeKit/Kit.swift | 25 +- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 13 +- .../SwiftPM/SwiftPM+Generator.swift | 314 ++++++++++++++++++ .../SwiftPM/SwiftPM+Manifest.swift | 277 +++++++++++++++ .../SwiftPM/SwiftPM+Settings.swift | 88 +++++ .../SwiftPM/SwiftPM+Workspace.swift | 166 +++++++++ Sources/BazelizeKit/SwiftPM/SwiftPM.swift | 20 ++ 9 files changed, 909 insertions(+), 3 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM.swift diff --git a/Package.swift b/Package.swift index a17d6a8..57a662e 100644 --- a/Package.swift +++ b/Package.swift @@ -76,6 +76,7 @@ let package = Package( "Starlark", "PluginLoader", + .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "XcodeProj", package: "XcodeProj"), ]), .target( diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index 748d7cf..c909437 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -46,6 +46,9 @@ struct GenerateCommand: AsyncParsableCommand { @Option(name: [.long], help: "plugin list") var manifest = ".bazelize.yml" + @Option(name: [.long], help: "Who generates the Swift package rules: rspm or native") + var spm: SwiftPM.Mode = .rspm + @Flag var dump = false @@ -58,7 +61,8 @@ struct GenerateCommand: AsyncParsableCommand { let kit = try await Kit( path, config, - outputPath: outputPath) + outputPath: outputPath, + spm: spm) guard !clear else { kit.clear() @@ -73,6 +77,8 @@ struct GenerateCommand: AsyncParsableCommand { } } +extension SwiftPM.Mode: ExpressibleByArgument {} + // MARK: - XCode2Command struct XCode2Command: AsyncParsableCommand { diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 287f546..588cbb5 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -15,6 +15,7 @@ import Yams public final class Kit { let project: Project let outputRoot: Path + let spm: SwiftPM.Mode private lazy var roadmap = Bazel.Roadmap(output: outputRoot, project: project) lazy var version = Bazel.Version(outputRoot) @@ -45,9 +46,15 @@ public final class Kit { // MARK: Lifecycle - public init(_ projPath: Path, _ preferConfig: String?, outputPath: Path? = nil) async throws { + public init( + _ projPath: Path, + _ preferConfig: String?, + outputPath: Path? = nil, + spm: SwiftPM.Mode = .rspm) async throws + { project = try Project.load(path: projPath, preferConfig: preferConfig) outputRoot = outputPath ?? Path(project.workspacePath) + self.spm = spm plugins = [] try await pluginSPM.loadPackageNames(projPath: projPath) @@ -60,6 +67,7 @@ public final class Kit { // try await loadPlugins(mainfest) try generate() + try await generateSwiftPackages() } public final func dump() throws { @@ -89,6 +97,21 @@ extension Kit { } } +// MARK: - Swift packages +extension Kit { + /// Rules for the packages the project depends on, generated from their + /// manifests instead of by `rules_swift_package_manager`. + private final func generateSwiftPackages() async throws { + guard spm == .native else { return } + + let workspace = try await SwiftPM.loadWorkspace(output: outputRoot) + try SwiftPM.Generator(output: outputRoot, workspace: workspace).generate() + + let count = workspace.packages.count + Log.codeGenerate.info("Generate \(count, privacy: .public) Swift packages") + } +} + // MARK: - Generate extension Kit { private final func generate() throws { diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index b418d7a..95932c5 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -56,6 +56,9 @@ final class PluginSwiftPM: PluginBuiltin { /// A project without Swift packages has no `Package.swift` to point at, and /// the extension fails module resolution when the manifest is missing. guard hasPackages else { return } + /// Nothing to declare when the packages' rules are generated here: they are + /// plain targets in this workspace. + guard kit.spm == .rspm else { return } builder.bazel_dep( name: "rules_swift_package_manager", @@ -245,11 +248,19 @@ final class PluginSwiftPM: PluginBuiltin { override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } - return [package, packageResolved].compactMap { $0 } + patchFiles + facadeFiles + + let manifests = [package, packageResolved].compactMap { $0 } + /// In native mode the package directories hold the rules themselves, so + /// there is nothing to alias and no generator to patch. + guard kit.spm == .rspm else { return manifests } + + return manifests + patchFiles + facadeFiles } override var tip: String? { guard hasPackages else { return nil } + guard kit.spm == .rspm else { return nil } + return """ # rules_swift_package_manager After bazelize, run `swift package update` and `bazel mod tidy`. diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift new file mode 100644 index 0000000..7963ea6 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -0,0 +1,314 @@ +// +// SwiftPM+Generator.swift +// +// +// Bazel rules for the Swift packages a project depends on. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM { + /// Writes one `BUILD` per package under `Packages/`, plus the source tree it + /// points at. + /// + /// The layout matches what `Targets/` already does: a symlink tree of the + /// sources and a generated `BUILD` beside it. Nothing outside `Packages/` + /// changes — a target reaches a product through the facade either way. + struct Generator { + let output: Path + let workspace: Workspace + + func generate() throws { + for package in workspace.packages { + try generate(package) + } + } + + // MARK: Private + + private var packagesRoot: Path { + output + PluginSwiftPM.packagesDirectory + } + + private func generate(_ package: Package) throws { + let root = packagesRoot + package.directory + try root.mkpath() + try materializeSources(package, at: root) + + let builder = CodeBuilder() + let emitted = try supportedTargets(of: package) + + for target in package.manifest.targets where emitted.contains(target.name) { + build(target, in: package, builder: builder) + } + + for product in package.manifest.products { + build(product, emitted: emitted, package: package, builder: builder) + } + + try (root + "BUILD").write(builder.build()) + } + + /// The targets that can be generated, after dropping everything that depends + /// on one that cannot: a library missing a target it links is worse than a + /// library that is not there at all. + private func supportedTargets(of package: Package) throws -> Set { + let targets = package.manifest.targets.filter { $0.type != "test" } + var supported = Set() + + for target in targets { + guard let kind = try kind(of: target, in: package) else { continue } + switch kind { + case .swift: + supported.insert(target.name) + case .unsupported(let reason): + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + \(reason, privacy: .public) + """) + } + } + + let names = Set(targets.map(\.name)) + var changed = true + while changed { + changed = false + for target in targets where supported.contains(target.name) { + let missing = target.dependencies.compactMap { dependency -> String? in + switch dependency.kind { + case .target(let name), .byName(let name): + guard names.contains(name), !supported.contains(name) else { return nil } + return name + case .product: + return nil + } + } + guard let first = missing.first else { continue } + + supported.remove(target.name) + changed = true + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + depends on \(first, privacy: .public), which is not generated + """) + } + } + + return supported + } + + /// The sources stay where SwiftPM put them; the package directory only + /// carries a link to them, the way a target's `Sources/` does. + private func materializeSources(_ package: Package, at root: Path) throws { + let destination = root + Self.sourcesRoot + if destination.isSymlink || destination.exists { + try? destination.delete() + } + try destination.symlink(package.root) + } + + static let sourcesRoot = "Package" + + private enum TargetKind { + case swift + case unsupported(String) + } + + /// What the target is made of, decided by the files on disk: the manifest + /// only says `regular`. + private func kind(of target: PackageTarget, in package: Package) throws -> TargetKind? { + switch target.type { + case "test": + return nil + case "plugin": + /// Every plugin in the wild so far is a linter: it produces no + /// source, so a build without it is the same build. + return .unsupported("plugin targets are not generated") + case "binary": + return .unsupported("binary targets are not generated yet") + case "system": + return .unsupported("system library targets are not generated yet") + case "macro": + return .unsupported("macro targets are not generated yet") + default: + break + } + + guard let directory = sourceDirectory(of: target, in: package) else { + return .unsupported("no source directory") + } + + let files = (try? directory.recursiveChildren()) ?? [] + let extensions = Set(files.compactMap(\.extension)) + + if extensions.isDisjoint(with: Self.clangExtensions) { + return .swift + } + return .unsupported("C-family sources are not generated yet") + } + + private static let clangExtensions: Set = ["c", "cc", "cpp", "cxx", "m", "mm", "S"] + + /// SwiftPM's own layout rules: an explicit `path`, else one of the + /// conventional directories, else the package root for a single target. + private func sourceDirectory(of target: PackageTarget, in package: Package) -> Path? { + if let path = target.path { + let directory = (package.root + path).normalize() + return directory.exists ? directory : nil + } + + for candidate in ["Sources", "Source", "src", "srcs"] { + let directory = package.root + candidate + target.name + if directory.exists { return directory } + } + + let flat = package.root + target.name + return flat.exists ? flat : nil + } + + /// The target's directory, relative to the package's source link. + private func sourcePrefix(of target: PackageTarget, in package: Package) -> String? { + guard let directory = sourceDirectory(of: target, in: package) else { return nil } + + let root = package.root.normalize().string + let path = directory.normalize().string + guard path.hasPrefix(root) else { return nil } + + let relative = String(path.dropFirst(root.count)).trimmingCharacters(in: ["/"]) + return relative.isEmpty ? Self.sourcesRoot : "\(Self.sourcesRoot)/\(relative)" + } + + private func build(_ target: PackageTarget, in package: Package, builder: CodeBuilder) { + guard let prefix = sourcePrefix(of: target, in: package) else { return } + + builder.load(loadableRule: Rules.Swift.swift_library) + builder.call( + Rules.Swift.Call.swift_library( + name: target.name, + /// SwiftPM compiles every package target with the developer + /// search paths, which is how a test-support library finds + /// XCTest. + always_include_developer_search_paths: true, + copts: copts(of: target).nonEmpty, + module_name: Self.moduleName(target.name), + srcs: Starlark.glob( + sources(of: target, prefix: prefix), + exclude: target.exclude.map { excluded in + "\(prefix)/\(excluded)/**" + }), + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + defines: .build { + defines(of: target) + }, + linkopts: linkopts(of: target).nonEmpty, + /// A package target is built through the bundle rule that + /// transitions it to a platform; building it on its own would + /// compile an iOS-only package for the host. + tags: ["manual"], + visibility: .public)) + } + + /// An explicit `sources` list names files or directories; without one the + /// whole target directory is the target. + private func sources(of target: PackageTarget, prefix: String) -> [String] { + guard let sources = target.sources, !sources.isEmpty else { + return ["\(prefix)/**/*.swift"] + } + + return sources.map { source in + Path(source).extension == nil + ? "\(prefix)/\(source)/**/*.swift" + : "\(prefix)/\(source)" + } + } + + private func deps(of target: PackageTarget, in package: Package) -> [Starlark.Label] { + let localTargets = Set(package.manifest.targets.map(\.name)) + let localProducts = Dictionary( + package.manifest.products.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first }) + + let labels: [String] = target.dependencies.compactMap { dependency in + switch dependency.kind { + case .target(let name): + return localTargets.contains(name) ? ":\(name)" : nil + case .byName(let name): + if localTargets.contains(name) { return ":\(name)" } + if localProducts[name] != nil { return ":\(name)" } + return label(product: name, package: nil, from: package) + case .product(let name, let packageName): + return label(product: name, package: packageName, from: package) + } + } + + return Array(Set(labels)).sorted().map(Starlark.Label.named) + } + + /// A product of another package is reached through the facade, so the label + /// does not depend on how that package's rules are generated. + private func label(product: String, package name: String?, from package: Package) -> String? { + let identities = [name, product].compactMap { $0 } + + package.manifest.dependencies.map(\.identity) + + for identity in identities { + guard let directory = workspace.directoryByIdentity[identity.lowercased()] else { continue } + return "//\(PluginSwiftPM.packagesDirectory)/\(directory):\(product)" + } + + Log.codeGenerate.warning(""" + No package for product \(product, privacy: .public) \ + required by \(package.directory, privacy: .public) + """) + return nil + } + + private func build( + _ product: PackageProduct, + emitted: Set, + package: Package, + builder: CodeBuilder) + { + guard product.kind == .library else { return } + + let targets = product.targets.filter { emitted.contains($0) } + guard !targets.isEmpty else { return } + + /// A product of one target is that target under another name; several + /// targets are a group that exports all of them. + if targets.count == 1, let target = targets.first { + guard target != product.name else { return } + + builder.call( + Rules.Builtin.Call.alias( + name: product.name, + actual: .named(":\(target)"), + visibility: .public)) + return + } + + builder.load(loadableRule: Rules.Swift.swift_library_group) + builder.call( + Rules.Swift.Call.swift_library_group( + name: product.name, + deps: .build { + targets.sorted().map { target in + Starlark.Label.named(":\(target)") + } + }, + visibility: .public)) + } + + /// Swift module names are identifiers; a package name is not. + static func moduleName(_ name: String) -> String { + String(name.map { character in + character.isLetter || character.isNumber || character == "_" ? character : "_" + }) + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift new file mode 100644 index 0000000..5b52d6f --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift @@ -0,0 +1,277 @@ +// +// SwiftPM+Manifest.swift +// +// +// The subset of `swift package dump-package` the generator needs. +// + +import Foundation + +// MARK: - SwiftPM + +/// Generating Bazel rules for the Swift packages a project depends on. +public enum SwiftPM {} + +extension SwiftPM { + /// A package manifest, as `swift package dump-package` prints it. + /// + /// The dump is the manifest after SwiftPM evaluated it, so conditionals and + /// defaults are already applied; reading it beats re-implementing + /// `Package.swift`. + struct Manifest: Decodable { + let name: String + let platforms: [Platform] + let products: [PackageProduct] + let targets: [PackageTarget] + let dependencies: [Dependency] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + name = try container.decode(String.self, forKey: AnyKey("name")) + platforms = container.list(Platform.self, "platforms") + products = container.list(PackageProduct.self, "products") + targets = container.list(PackageTarget.self, "targets") + dependencies = container.list(Dependency.self, "dependencies") + } + } + + struct Platform: Decodable { + let platformName: String + let version: String? + } + + enum ProductKind { + case library + case executable + case plugin + } + + struct PackageProduct: Decodable { + let name: String + let targets: [String] + /// `{"library": ["automatic"]}`, `{"executable": null}`, `{"plugin": null}`. + let type: [String: AnyDecodable?] + + var kind: ProductKind { + if type.keys.contains("executable") { return .executable } + if type.keys.contains("plugin") { return .plugin } + return .library + } + } + + struct PackageTarget: Decodable { + let name: String + /// `regular`, `executable`, `test`, `system`, `binary`, `plugin`, `macro`. + let type: String + let path: String? + let sources: [String]? + let exclude: [String] + let publicHeadersPath: String? + let settings: [Setting] + let resources: [Resource] + let dependencies: [TargetDependency] + /// A binary target's remote archive. + let url: String? + let checksum: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + name = try container.decode(String.self, forKey: AnyKey("name")) + type = container.value(String.self, "type") ?? "regular" + path = container.value(String.self, "path") + sources = container.value([String].self, "sources") + exclude = container.list(String.self, "exclude") + publicHeadersPath = container.value(String.self, "publicHeadersPath") + settings = container.list(Setting.self, "settings") + resources = container.list(Resource.self, "resources") + dependencies = container.list(TargetDependency.self, "dependencies") + url = container.value(String.self, "url") + checksum = container.value(String.self, "checksum") + } + } + + /// `{"tool": "swift", "kind": {"define": {"_0": "FOO"}}}` + struct Setting: Decodable { + let tool: String + let kind: [String: SettingValues] + + /// `define`, `headerSearchPath`, `defaultIsolation`… + var name: String? { + kind.keys.first + } + + var values: [String] { + kind.values.first?.values ?? [] + } + } + + /// `{"_0": "FOO"}`, `{"_0": ["-Xfrontend", "-warn-long"]}` or `{}`. + struct SettingValues: Decodable { + let values: [String] + + init(from decoder: Decoder) throws { + guard let container = try? decoder.container(keyedBy: AnyKey.self) else { + values = [] + return + } + + var result: [String] = [] + for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) { + if let value = try? container.decode(String.self, forKey: key) { + result.append(value) + } else if let list = try? container.decode([String].self, forKey: key) { + result.append(contentsOf: list) + } + } + values = result + } + } + + /// `{"rule": {"copy": {}}, "path": "Resources"}` + struct Resource: Decodable { + let path: String + let rule: [String: AnyDecodable?] + + var isCopy: Bool { + rule.keys.contains("copy") + } + } + + enum TargetDependencyKind { + /// A target in the same package, or a product with the same name. + case byName(String) + /// A target in the same package. + case target(String) + /// `product: [productName, packageName, moduleAliases, condition]` + case product(name: String, package: String?) + } + + struct TargetDependency: Decodable { + let kind: TargetDependencyKind + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + + for key in container.allKeys { + let values = container.list(AnyDecodable.self, key.stringValue) + let strings = values.compactMap { $0.value as? String } + guard let name = strings.first else { continue } + + switch key.stringValue { + case "byName": + kind = .byName(name) + return + case "target": + kind = .target(name) + return + case "product": + kind = .product(name: name, package: strings.dropFirst().first) + return + default: + continue + } + } + + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Unknown target dependency")) + } + } + + /// `{"fileSystem": [{...}]}` or `{"sourceControl": [{...}]}` + struct Dependency: Decodable { + let identity: String + let name: String? + let path: String? + let url: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + + for key in container.allKeys { + guard let entry = container.list(DependencyEntry.self, key.stringValue).first else { continue } + + identity = entry.identity + name = entry.nameForTargetDependencyResolutionOnly + path = entry.path + url = entry.location?.url + return + } + + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Unknown package dependency")) + } + } + + struct DependencyEntry: Decodable { + let identity: String + let nameForTargetDependencyResolutionOnly: String? + let path: String? + let location: DependencyLocation? + } + + /// `{"remote": [{"urlString": "https://…"}]}` + struct DependencyLocation: Decodable { + let url: String? + + init(from decoder: Decoder) throws { + guard let container = try? decoder.container(keyedBy: AnyKey.self) else { + url = nil + return + } + + for key in container.allKeys { + if let remote = container.list(DependencyRemote.self, key.stringValue).first { + url = remote.urlString + return + } + } + url = nil + } + } + + struct DependencyRemote: Decodable { + let urlString: String + } + + /// The dump uses payload keys (`_0`) and wrapper keys (`byName`), so every + /// container is keyed dynamically. + struct AnyKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init(_ value: String) { stringValue = value } + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue _: Int) { nil } + } +} + +extension KeyedDecodingContainer where Key == SwiftPM.AnyKey { + /// Absent, null and malformed all mean "not there": a manifest dump spans every + /// tools version, and a key that does not apply is simply missing. + func value(_ type: T.Type, _ key: String) -> T? { + try? decodeIfPresent(type, forKey: SwiftPM.AnyKey(key)) + } + + func list(_ type: T.Type, _ key: String) -> [T] { + (try? decodeIfPresent([T].self, forKey: SwiftPM.AnyKey(key))) ?? [] + } +} + +/// Anything, decoded only to be ignored. +struct AnyDecodable: Decodable { + let value: Any? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self.value = value + } else if let value = try? container.decode(Int.self) { + self.value = value + } else if let value = try? container.decode(Bool.self) { + self.value = value + } else { + self.value = nil + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift new file mode 100644 index 0000000..4793039 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift @@ -0,0 +1,88 @@ +// +// SwiftPM+Settings.swift +// +// +// `SwiftSetting` and `LinkerSetting` as compiler and linker flags. +// + +import Foundation + +extension SwiftPM.Generator { + /// What the target compiles with, beyond the defaults. + /// + /// SwiftPM hands these to `swiftc` directly, so they are copts rather than + /// anything the rules model: a `defines` attribute would re-tokenize a value + /// and a feature is not a flag the rules know. + func copts(of target: SwiftPM.PackageTarget) -> [String] { + target.settings.flatMap { setting -> [String] in + guard setting.tool == "swift", let name = setting.name else { return [] } + + switch name { + case "swiftLanguageMode", "swiftLanguageVersion": + guard let version = setting.values.first else { return [] } + return ["-swift-version", version] + case "defaultIsolation": + guard let isolation = setting.values.first else { return [] } + return ["-default-isolation", isolation] + case "enableUpcomingFeature": + return setting.values.flatMap { feature in + ["-enable-upcoming-feature", feature] + } + case "enableExperimentalFeature": + return setting.values.flatMap { feature in + ["-enable-experimental-feature", feature] + } + case "strictMemorySafety": + return ["-strict-memory-safety"] + case "interoperabilityMode": + guard let mode = setting.values.first else { return [] } + return ["-cxx-interoperability-mode=\(mode)"] + case "unsafeFlags": + return setting.values + default: + return [] + } + } + } + + /// `SWIFT_PACKAGE` is what a package's own sources test for; SwiftPM defines it + /// for every target it builds. + func defines(of target: SwiftPM.PackageTarget) -> [String] { + let declared = target.settings.compactMap { setting -> [String]? in + guard setting.tool == "swift", setting.name == "define" else { return nil } + return setting.values + }.flatMap { $0 } + + return ["SWIFT_PACKAGE"] + declared + } + + /// A package can name a system library or framework it needs; nothing else in + /// the graph knows about it. + func linkopts(of target: SwiftPM.PackageTarget) -> [String] { + target.settings.flatMap { setting -> [String] in + guard setting.tool == "linker", let name = setting.name else { return [] } + + switch name { + case "linkedLibrary": + return setting.values.map { library in + "-l\(library)" + } + case "linkedFramework": + return setting.values.flatMap { framework in + ["-framework", framework] + } + case "unsafeFlags": + return setting.values + default: + return [] + } + } + } +} + +extension Array { + /// `nil` rather than an empty attribute. + var nonEmpty: [Element]? { + isEmpty ? nil : self + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift new file mode 100644 index 0000000..e0650af --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift @@ -0,0 +1,166 @@ +// +// SwiftPM+Workspace.swift +// +// +// Resolving the packages a project depends on and reading their manifests. +// + +import Foundation +@preconcurrency import PathKit +import Subprocess +import System +import Util + +extension SwiftPM { + /// A package as the generator sees it: where its sources are, what it declares, + /// and the directory its products are exposed under. + struct Package { + /// The directory under `Packages/`, named the way a human refers to the + /// package. + let directory: String + /// The checkout the sources come from. + let root: Path + let manifest: Manifest + /// `true` for a package in the project's own repository. + let isLocal: Bool + } + + /// Everything the generator needs about one project's package graph. + struct Workspace { + let packages: [Package] + + /// Which directory a package identity or manifest name resolves to, so a + /// product dependency can be turned into a label. + let directoryByIdentity: [String: String] + } +} + +extension SwiftPM { + /// Resolves the workspace `Package.swift` and reads every checkout's manifest. + /// + /// SwiftPM owns resolution: it already wrote `Package.resolved`, and its + /// checkouts are the sources the rules will point at. `dump-package` is read + /// per checkout because it is the manifest SwiftPM itself evaluated — cheap, + /// offline, and it spans every tools version in the graph. + static func loadWorkspace(output: Path) async throws -> Workspace { + try await resolve(output: output) + + let checkouts = output + ".build/checkouts" + var packages: [Package] = [] + var directoryByIdentity: [String: String] = [:] + + for root in try roots(output: output, checkouts: checkouts) { + guard let manifest = try await manifest(at: root.path) else { continue } + + let package = Package( + directory: root.directory, + root: root.path, + manifest: manifest, + isLocal: root.isLocal) + packages.append(package) + + for identity in [manifest.name, root.directory, root.path.lastComponent] { + directoryByIdentity[identity.lowercased()] = root.directory + } + } + + return .init(packages: packages, directoryByIdentity: directoryByIdentity) + } + + // MARK: Private + + private struct Root { + let directory: String + let path: Path + let isLocal: Bool + } + + /// `swift package resolve` fetches what `Package.resolved` pins; without it + /// there are no checkouts to read. + private static func resolve(output: Path) async throws { + Log.codeGenerate.info("swift package resolve at \(output.string, privacy: .public)") + + let result = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["package", "resolve"]), + workingDirectory: FilePath(output.string), + output: .discarded, + error: .currentStandardError) + + guard result.terminationStatus.isSuccess else { + throw SwiftPMError.resolveFailed(status: "\(result.terminationStatus)") + } + } + + /// Remote packages live in `.build/checkouts`; a local one is wherever the + /// manifest points, and is read in place. + private static func roots(output: Path, checkouts: Path) throws -> [Root] { + var roots: [Root] = [] + + if checkouts.exists { + for child in try checkouts.children() where child.isDirectory { + roots.append(.init(directory: child.lastComponent, path: child, isLocal: false)) + } + } + + for path in localPaths(output: output) { + let resolved = (output + path).normalize() + guard resolved.exists else { continue } + roots.append(.init(directory: resolved.lastComponent, path: resolved, isLocal: true)) + } + + return roots.sorted { $0.directory < $1.directory } + } + + /// The `path:` dependencies of the generated manifest. + private static func localPaths(output: Path) -> [String] { + guard let manifest: String = try? (output + "Package.swift").read() else { return [] } + + let pattern = #"\.package\(path:\s*"([^"]+)"\)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + return regex.matches(in: manifest, range: NSRange(manifest.startIndex..., in: manifest)) + .compactMap { match in + guard let range = Range(match.range(at: 1), in: manifest) else { return nil } + return String(manifest[range]) + } + } + + private static func manifest(at root: Path) async throws -> Manifest? { + let result = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["package", "dump-package", "--package-path", root.string]), + output: .data(limit: 32 * 1024 * 1024), + error: .discarded) + + guard result.terminationStatus.isSuccess else { + Log.codeGenerate.warning(""" + No manifest for \(root.lastComponent, privacy: .public): dump-package failed + """) + return nil + } + + do { + return try JSONDecoder().decode(Manifest.self, from: Data(result.standardOutput)) + } catch { + Log.codeGenerate.warning(""" + Cannot read the manifest of \(root.lastComponent, privacy: .public): \ + \(error.localizedDescription, privacy: .public) + """) + return nil + } + } +} + +// MARK: - SwiftPMError + +enum SwiftPMError: Error, CustomStringConvertible { + case resolveFailed(status: String) + + var description: String { + switch self { + case .resolveFailed(let status): + return "swift package resolve failed: \(status)" + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM.swift new file mode 100644 index 0000000..33ca6e2 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM.swift @@ -0,0 +1,20 @@ +// +// SwiftPM.swift +// +// +// Which generator produces the rules for Swift packages. +// + +extension SwiftPM { + /// Who generates the rules behind `//Packages/:`. + /// + /// Both modes produce the same labels — the facade is what a target depends + /// on — so a workspace can be regenerated either way without touching a single + /// target. + public enum Mode: String, CaseIterable, Sendable { + /// `rules_swift_package_manager` generates them in an external repository. + case rspm + /// bazelize generates them next to the sources, from the manifests. + case native + } +} From bed74fbc79b70d7ca7542c424eb1fb3a1c5b8ac0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 14:52:08 +0800 Subject: [PATCH 116/173] Describe generating the Swift package rules in docs --- docs/Roadmap.md | 30 +++- docs/Roadmap_ZH.md | 29 ++- docs/SPM.md | 338 +++++++++++++++++++++++++++++------ Roadmap.md => docs/SPM_ZH.md | 167 +++++++++-------- 4 files changed, 433 insertions(+), 131 deletions(-) rename Roadmap.md => docs/SPM_ZH.md (57%) diff --git a/docs/Roadmap.md b/docs/Roadmap.md index f984fcf..7ebf99c 100644 --- a/docs/Roadmap.md +++ b/docs/Roadmap.md @@ -22,6 +22,12 @@ $Output/ <- Bazel Root Sources/ Generated/ + Packages/ + $Package1/ + BUILD + Package/ + Generated/ + Prebuilt/ BUILD A.xcframework @@ -87,10 +93,32 @@ Sources/B/B.swift -> /B/B.swift Sources/C -> /C ``` +## Package Layout + +Each Swift package the project depends on has its own directory under +`Packages/`, whoever generates its rules. + +```text +Packages/ + $Package/ + BUILD + Package/ + Generated/ +``` + +- the directory is named after the package as a human reads it: the last path + component of the URL without `.git`, or the directory name of a local package +- `Package/` is one symlink to the package's sources, remote or local +- a product is a label in this directory, so `//Packages/$Package:$Product` is + what a target depends on regardless of how the rules are generated + +See [SwiftPM](SPM.md) for what the rules themselves look like. + ## Special Directories -- `Generated/` is target-local and reserved for files generated for that target +- `Generated/` is target-local or package-local and reserved for files generated for it - `Prebuilt/` is global at the root level and stores prebuilt binaries +- `Packages/` is global at the root level and stores the Swift packages' rules ## Deferred diff --git a/docs/Roadmap_ZH.md b/docs/Roadmap_ZH.md index 926dc05..e737a8c 100644 --- a/docs/Roadmap_ZH.md +++ b/docs/Roadmap_ZH.md @@ -22,6 +22,12 @@ $Output/ <- Bazel Root Sources/ Generated/ + Packages/ + $Package1/ + BUILD + Package/ + Generated/ + Prebuilt/ BUILD A.xcframework @@ -87,10 +93,31 @@ Sources/B/B.swift -> /B/B.swift Sources/C -> /C ``` +## Package Layout + +專案依賴的每個 Swift package 都會在 `Packages/` 底下有自己的目錄,不論它的規則 +是誰產生的。 + +```text +Packages/ + $Package/ + BUILD + Package/ + Generated/ +``` + +- 目錄名取人看得懂的 package 名:remote 用 URL 最後一段去掉 `.git`,local 用目錄名 +- `Package/` 是一條指向該 package 原始碼的 symlink,遠端或本地皆然 +- product 就是這個目錄裡的 label,所以不論規則怎麼產生,target 依賴的都是 + `//Packages/$Package:$Product` + +規則本身長什麼樣見 [SwiftPM](SPM_ZH.md)。 + ## Special Directories -- `Generated/` 是 target-local,保留給該 target 專屬的 generated files +- `Generated/` 是 target-local 或 package-local,保留給它專屬的 generated files - `Prebuilt/` 是 root-level global directory,用來放 prebuilt binaries +- `Packages/` 是 root-level global directory,用來放 Swift package 的規則 ## Deferred diff --git a/docs/SPM.md b/docs/SPM.md index 51aaaa3..8725500 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -1,67 +1,303 @@ -## workspace - -```bazel -load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") - -http_archive( - name = "cgrindel_rules_spm", - sha256 = "ba4310ba33cd1864a95e41d1ceceaa057e56ebbe311f74105774d526d68e2a0d", - strip_prefix = "rules_spm-0.10.0", - urls = [ - "http://github.com/cgrindel/rules_spm/archive/v0.10.0.tar.gz", - ], -) +# SwiftPM -load( - "@cgrindel_rules_spm//spm:deps.bzl", - "spm_rules_dependencies", -) +## Goal -spm_rules_dependencies() +Generate the Bazel rules for a project's Swift packages in bazelize, instead of +delegating them to `rules_swift_package_manager` (rspm). -load( - "@build_bazel_rules_swift//swift:repositories.bzl", - "swift_rules_dependencies", -) +Today bazelize only declares rspm in `MODULE.bazel` and writes a synthesized +`Package.swift`; rspm generates the package BUILD files inside an external +repository at fetch time. -swift_rules_dependencies() +This document describes the **output shape** after the switch, the mapping from +SwiftPM concepts to rules, and the staged plan. It is about artifacts and +responsibility boundaries, not implementation details. -load( - "@build_bazel_rules_swift//swift:extras.bzl", - "swift_rules_extra_dependencies", -) +## Why + +1. rspm's output needs 4 vendored patches for real projects + (`Patches/rspm-*.patch` + `single_version_override`), plus a version gate. +2. We are pinned to rspm 1.15.0: from 1.16 every SwiftPM target is transitioned + to the platform floor it declares itself, and analysis fails when a + dependency declares a higher floor. Xcode never does this. Platform + semantics are bazelize's own domain, so generating the rules here removes + the conflict. +3. Header, resource and plist handling for Xcode targets already lives in + bazelize; package targets behave consistently only if they share it. +4. The output becomes checked-in files: a problem is read in the file, not + traced through a repo rule. +5. One fewer step — no `bazel mod tidy` to maintain the `use_repo` list. + +The cost: SwiftPM semantics (traits, registry, binary targets, plugins, macros) +become our responsibility. + +## Current output (rspm, for contrast) -swift_rules_extra_dependencies() +```text +App/ +├── MODULE.bazel # bazel_dep(rules_swift_package_manager) +│ # + swift_deps.from_package + use_repo(...) +│ # + single_version_override(patches = …) +├── Patches/ # vendored rspm patches, version gated +│ ├── BUILD +│ └── rspm-*.patch +├── Package.swift # synthesized manifest, read by rspm +├── Package.resolved # seeded from Xcode's Package.resolved +├── config.bazelrc +├── BUILD +├── Prebuilt/ # project-owned .framework/.a/.dylib (symlinks) +└── Targets// + ├── BUILD + ├── Sources/ # symlink tree into the original sources + ├── Headers// # flattened header tree + ├── Generated/ # BazelizeDefines.h, entitlements, asset symbols + └── CopyFiles// # copy phase destinations ``` -## Workspace +The package BUILD files are not here; they are in +`external/rules_swift_package_manager++swift_deps+swiftpkg_/`. -```bazel -load("@cgrindel_rules_spm//spm:defs.bzl", "spm_pkg", "spm_repositories") +## New output (generated here) -spm_repositories( - name = "swift_pkgs", - dependencies = [ - spm_pkg( - "https://github.com/apple/swift-log.git", - exact_version = "1.4.2", - products = ["Logging"], - ), - ], -) +```text +App/ +├── MODULE.bazel # no rspm +├── Package.swift # kept: SwiftPM still resolves the graph +├── Package.resolved # kept: the only source of pins +├── config.bazelrc +├── BUILD +├── Prebuilt/ +├── Targets// # unchanged +└── Packages/ # ★ new + └── / + ├── BUILD # the rules for every target of that package + ├── Generated/ # resource bundle accessors, modulemaps, defines + └── Package # symlink to the package's sources +``` + +`Patches/` disappears entirely. + +### How a package's sources get in + +Every package — remote or local — is a directory in this workspace holding a +generated `BUILD` and one symlink to the sources SwiftPM already has: + +```text +Packages/SFSafeSymbols/ +├── BUILD +└── Package -> /.build/checkouts/SFSafeSymbols ``` -## import +so a target's sources are globbed as `Package/Sources//**/*.swift`. +A local package points at wherever its manifest is, read in place. -```bazel -load("@build_bazel_rules_swift//swift:swift.bzl", "swift_binary") +Properties of this choice: -swift_binary( - name = "simple", - srcs = ["main.swift"], - visibility = ["//swift:__subpackages__"], - deps = [ - "@swift_pkgs//swift-log:Logging", - ], +- Same shape as `Targets/`: a symlink tree plus a generated `BUILD` beside it. + One mechanism, not two. +- No external repositories, so no `use_repo` list and no `bazel mod tidy`. +- Resolution stays SwiftPM's job: bazelize runs `swift package resolve` and + reads each checkout's manifest with `swift package dump-package`, which is + offline and spans every tools version in the graph. + +The alternative — one `git_repository` per remote package, pinned to the +revision in `Package.resolved` — is hermetic but reintroduces external repos +and fetches sources Bazel already has on disk. + +### Label naming: the facade + +Every package product — remote or local — has one shape in `Targets/*/BUILD`: + +| Product of | Before | Now | +|---|---|---| +| a remote package | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `//Packages/SFSafeSymbols:SFSafeSymbols` | +| a local package | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | + +In rspm mode `Packages//BUILD` is a layer of aliases pointing at whatever +implements the product today: + +```python +alias( + name = "SFSafeSymbols", + actual = "@swiftpkg_sfsafesymbols//:SFSafeSymbols", + visibility = ["//visibility:public"], ) ``` + +The directory is named after the package as a human reads it (last path +component of the URL without `.git`, or the directory name for a local +package), so a name with a dot — `//Packages/GRMustache.swift:Mustache` — +works too. + +What this buys: **replacing the SwiftPM implementation only touches files under +`Packages/`**. No target's `deps` changes when the aliases become the rules +themselves, reverting means pointing the aliases back at rspm, and no test pins +rspm's repository naming. + +## SwiftPM concept → generated rule + +| SwiftPM | Generated | +|---|---| +| Swift target | `swift_library` | +| clang target (C/ObjC/C++) | `objc_library`, reusing bazelize's header/include logic | +| mixed target | `mixed_language_library` | +| system-library target | `cc_library` + a generated modulemap | +| binary target (xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | +| binary target (local archive) | unarchived first, then as above | +| library product, one target | `alias` | +| library product, several targets | `swift_library_group` | +| `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | +| auto-discovered resources (xib/xcassets/metal/xcstrings) | as above; `.metal` enters the resource group with that target's headers | +| `defines` | `defines` (an unsafe value goes through `Generated/Defines.h`, same policy as an Xcode target) | +| `headerSearchPath` | `includes` | +| `linkedLibrary` / `linkedFramework` | `linkopts` | +| `swiftLanguageMode` | `-swift-version` | +| `enableUpcomingFeature` / `enableExperimentalFeature` | `-enable-upcoming-feature` / `-enable-experimental-feature` | +| `defaultIsolation` | `-default-isolation ` | +| `interoperabilityMode` | `-cxx-interoperability-mode=` | +| `strictMemorySafety` | `-strict-memory-safety` | +| `unsafeFlags` | `copts` | +| build tool plugin (SwiftLint etc.) | stage 3; skipped with a warning | +| macro / compiler plugin | stage 3; `swift_compiler_plugin` | +| traits (SE-0450) | expanded into `-D` and conditional deps per enabled trait | + +Two SwiftPM behaviours are matched on every generated `swift_library`: +`alwayslink`, because SwiftPM always links a package library, and +`always_include_developer_search_paths`, which is how a test-support library +such as `RxTest` finds XCTest. Each library is also tagged `manual`: a package +target is built through the bundle rule that transitions it to a platform, so a +wildcard pattern must not compile an iOS-only package for the host. + +A package's platform floor is deliberately ignored — honouring it per package +is exactly the rspm behaviour that pins us to 1.15.0. + +## Stage 0 results (measured) + +Corpus: the **119 packages / 208 non-test targets** the 12 apps expand to, read +from the `dump.json` and `desc.json` rspm generates in its external repos (the +output of `swift package dump-package` and `describe`). + +### Target kinds + +| module type | count | +|---|---| +| SwiftTarget | 170 | +| ClangTarget | 50 | +| BinaryTarget | 2 | +| SystemLibraryTarget | 2 | +| PluginTarget | 1 | + +No macro targets, and no mixed-language targets (SwiftPM does not allow them). + +### Build settings (targets / packages using them) + +| setting | targets | packages | +|---|---|---| +| `swift.enableUpcomingFeature` | 116 | 5 | +| `c.headerSearchPath` | 44 | 28 | +| `swift.strictMemorySafety` | 23 | 4 | +| `swift.enableExperimentalFeature` | 21 | 14 | +| `swift.define` | 14 | 4 | +| `swift.swiftLanguageMode` | 13 | 13 | +| `swift.defaultIsolation` | 10 | 10 | +| `c.define` | 6 | 3 | +| `linker.linkedLibrary` | 1 | 1 | +| `linker.linkedFramework` | 1 | 1 | +| `swift.unsafeFlags` | 1 | 1 | + +### Other shapes + +- **resources**: 32 packages (37 `.copy`, 4 `.process`) → resource bundles and + a `Bundle.module` accessor are required. +- **manifest shape**: 30 targets list `sources` explicitly, 32 use `exclude`, + 33 set `publicHeadersPath` → file collection for a clang target cannot rely + on convention alone. +- **tools version** ranges from 4.2 to 6.3 (most common: 5.3, 30 packages). +- **plugin usage**: 9 packages, **all SwiftLint** (`SwiftLintPlugin` 5, + `SwiftLintPlugins` 4) — lint only, they generate no source. +- **plugin target**: exactly one, swift-argument-parser's `GenerateManual`, + consumed by nobody in the corpus. +- **binary target**: 2 (Sparkle's remote xcframework, CodeEditLanguages' local + `.zip`). + +### What that means + +Taking "skip a lint-only build tool plugin with a warning" and "do not generate +a plugin target nobody consumes" as rules, **all 119 packages fall into stages +1–2**: + +| Scope of the stage | Packages covered | +|---|---| +| pure Swift libraries, no resources | 58 | +| + clang / resources / binary / system | 61 (119 cumulative) | +| macros, source-generating plugins | 0 (none in the corpus) | + +The minimum stage each app needs (expanded from each workspace's +`Package.resolved`): + +| app | pins | needs | +|---|---|---| +| Rectangle | 2 | stage 2 | +| SwiftBar | 5 | stage 2 | +| MonitorControl | 6 | stage 2 | +| iina | 4 | stage 2 | +| IceCubesApp | 20 | stage 2 | +| VirtualBuddy | 6 | stage 2 (only needs argument-parser's plugin target skipped) | +| UTM | 15 | stage 2 (same) | +| PlayCover | 8 | stage 2 (same) | +| CotEditor | 29 | stage 2 (+ SwiftLint plugin skipped) | +| CodeEdit | 34 | stage 2 (+ SwiftLint plugin skipped) | + +So **macros and real plugins can be deferred as a whole**: stage 2 covers the +entire corpus. + +## Stage 1 results (measured) + +`--spm native` generates rules for pure-Swift package targets. A target whose +kind is not generated yet is skipped with a warning, and so is every target +that depends on it: a library missing a target it links is worse than a library +that is not there at all. + +Native mode across the 7 green macOS apps, `bazel build //...`: + +| app | result | blocking target kind | +|---|---|---| +| stats | builds | — | +| MacPass | builds | — | +| MonitorControl | needs stage 2 | Sparkle, binary target | +| SwiftBar | needs stage 2 | Sparkle, binary target | +| Rectangle | needs stage 2 | MASShortcut, clang target | +| iina | needs stage 2 | GRMustache.swift's `GRMustacheKeyAccess`, clang target | +| VirtualBuddy | needs stage 2 | BuddyKit, clang target | + +Every failure is a missing target kind, not a wrong rule: the products that +reference a skipped target are the only unresolved labels. + +## Stages and exit criteria + +The exit criterion is the same at every stage: **the 12 apps at least hold +their ground** (the 7 green ones stay green, the blocked ones keep the same +reason), plus the 114 unit tests and the iOS fixture. + +| Stage | Scope | Goal | +|---|---|---| +| 0 ✅ | measure the corpus | see above | +| 0.5 ✅ | the `//Packages` facade (aliases into rspm) | all apps; label shape settled | +| 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind `--spm native`, default still rspm | 58 packages build on their own | +| 2 | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude`), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | all 12 apps at least hold their ground | +| 3 | macros / source-generating build tool plugins | when something outside the corpus needs it | +| 4 | flip the default, drop the rspm dependency, `Patches/` and the version gate | everything | + +Through stages 1–3 rspm and the native generator are **never mixed**: a +workspace takes one path or the other, chosen by the flag. Mixing them would +produce two dependency graphs. + +## Open questions + +1. Does `Package.swift` still need to be part of the output? Only + `swift package resolve` reads it, so it could be generated only when pins + are updated. +2. Which stage supports registry packages (`.package(id:)`)? Nothing in the + corpus uses one. +3. Should the rspm patches still go upstream during stages 1–4? They are small + and useful to others, so probably yes. diff --git a/Roadmap.md b/docs/SPM_ZH.md similarity index 57% rename from Roadmap.md rename to docs/SPM_ZH.md index 1623ebb..eeb7a58 100644 --- a/Roadmap.md +++ b/docs/SPM_ZH.md @@ -1,18 +1,20 @@ -# Roadmap: 自己產生 SwiftPM 的 BUILD +# SwiftPM -目前 SwiftPM 依賴是交給 `rules_swift_package_manager`(以下 rspm)處理的: -bazelize 只在 `MODULE.bazel` 裡宣告它、寫一份 `Package.swift`,剩下的 BUILD -由 rspm 在 fetch 階段產生在 external repo 裡。 +## 目標 -這份文件描述「改成自己產生」之後,**輸出結構**長什麼樣,以及分階段的做法。 -它只談產物形狀與責任邊界,不談實作細節。 +由 bazelize 自己產生專案裡 Swift package 的 Bazel 規則,不再交給 +`rules_swift_package_manager`(以下 rspm)。 ---- +目前 bazelize 只在 `MODULE.bazel` 裡宣告 rspm、寫一份合成的 `Package.swift`, +package 的 BUILD 由 rspm 在 fetch 階段產生在 external repo 裡。 + +這份文件描述改成自己產生之後的**輸出結構**、SwiftPM 概念到規則的對應,以及 +分階段的做法。它只談產物形狀與責任邊界,不談實作細節。 ## 為什麼要換 -1. rspm 產出的 BUILD 有幾處對真實專案不夠用,我們現在用 4 個 vendored patch - 補(`Patches/rspm-*.patch` + `single_version_override`),還得帶版本守門。 +1. rspm 產出的 BUILD 有幾處對真實專案不夠用,現在用 4 個 vendored patch 補 + (`Patches/rspm-*.patch` + `single_version_override`),還得帶版本守門。 2. 我們被釘在 rspm 1.15.0:≥1.16 會把每個 SwiftPM target 轉場到它自己宣告的 platform floor,然後在依賴宣告更高版本時 analysis 失敗——Xcode 從不這樣做。 平台語義本來就是 bazelize 的主場,自己產生就不會打架。 @@ -24,11 +26,9 @@ bazelize 只在 `MODULE.bazel` 裡宣告它、寫一份 `Package.swift`,剩下 代價:SwiftPM 的語義(traits、registry、binary target、plugin、macro)從此是 我們的責任。 ---- - ## 現在的輸出(rspm 版,作為對照) -``` +```text App/ ├── MODULE.bazel # bazel_dep(rules_swift_package_manager) │ # + swift_deps.from_package + use_repo(...) @@ -49,15 +49,14 @@ App/ └── CopyFiles// # copy phase 目的地 ``` -package 的 BUILD 不在這裡,而在 `external/rules_swift_package_manager++swift_deps+swiftpkg_/`。 - ---- +package 的 BUILD 不在這裡,而在 +`external/rules_swift_package_manager++swift_deps+swiftpkg_/`。 ## 新的輸出(自己產生) -``` +```text App/ -├── MODULE.bazel # 不再有 rspm;改成每個遠端 package 一個 repo rule +├── MODULE.bazel # 不再有 rspm ├── Package.swift # 保留:仍用 SwiftPM 解析依賴圖 ├── Package.resolved # 保留:pin 的唯一來源 ├── config.bazelrc @@ -65,54 +64,41 @@ App/ ├── Prebuilt/ ├── Targets// # 完全不變 └── Packages/ # ★ 新增 - ├── BUILD # exports_files:給 repo rule 的 build_file 用 └── / - ├── BUILD.bazel # 該 package 全部 target 的規則(我們產生) - ├── Generated/ - │ ├── ResourceBundleAccessor.swift - │ ├── .modulemap - │ └── Defines.h - └── Sources/ # 僅本地 package:指向 checkout 的 symlink 樹 + ├── BUILD # 該 package 全部 target 的規則(我們產生) + ├── Generated/ # resource bundle accessor、modulemap、defines + └── Package # 指向該 package 原始碼的 symlink ``` `Patches/` 整組消失。 -### 遠端 package 怎麼進來 +### package 的原始碼怎麼進來 -`MODULE.bazel` 為每個遠端 package 產生一個 repo rule,revision 直接取自 -`Package.resolved`,BUILD 用我們簽入的那份: +每個 package——遠端或本地——都是這個 workspace 裡的一個目錄,裡面放我們產生的 +`BUILD`,和一條指向 SwiftPM 既有原始碼的 symlink: -```python -git_repository( - name = "swiftpkg_sfsafesymbols", - remote = "https://github.com/SFSafeSymbols/SFSafeSymbols", - commit = "…", # Package.resolved 的 revision - build_file = "//Packages/SFSafeSymbols:BUILD.bazel", -) +```text +Packages/SFSafeSymbols/ +├── BUILD +└── Package -> /.build/checkouts/SFSafeSymbols ``` -這麼做的性質: - -- **hermetic**:由 Bazel 抓、pin 到 revision,不依賴 `.build/checkouts`。 -- **可讀**:BUILD 在我們的 repo 裡,不是產生在 external repo 裡。 -- **仍要 `swift package resolve`**:但只在「依賴變動時」跑一次,用來更新 - `Package.resolved` 與讓 bazelize 讀到 manifest;build 本身不需要它。 +所以 target 的原始碼就是 `Package/Sources//**/*.swift`。本地 package +指向它 manifest 所在的位置,就地讀取。 -### 本地 package 怎麼進來 +這個選擇的性質: -本地 package(`.package(path: "../Packages/Account")`)和 Xcode target 同一個 -workspace,走和 `Targets/` 相同的 symlink 樹,不需要 repo rule: - -``` -Packages/Account/ -├── BUILD.bazel -├── Sources/ → symlink 到 ../../../Packages/Account/Sources -└── Generated/ -``` +- 和 `Targets/` 同一個形狀:symlink 樹加上旁邊產生的 `BUILD`,只有一套機制。 +- 沒有 external repository,所以沒有 `use_repo` 清單,也不需要 `bazel mod tidy`。 +- 解析仍然是 SwiftPM 的事:bazelize 跑 `swift package resolve`,再用 + `swift package dump-package` 讀每個 checkout 的 manifest——離線、而且橫跨 + 依賴圖裡所有 tools version。 -label 形如 `//Packages/Account:Account`。 +另一個選項是每個遠端 package 產生一個 `git_repository`,用 `Package.resolved` +的 revision 釘住:那是 hermetic 的,但又把 external repo 帶回來,還會重抓一份 +Bazel 手上已經有的原始碼。 -### Label 命名:facade(已實作) +### Label 命名:facade 所有 package product——遠端或本地——在 `Targets/*/BUILD` 裡都是同一個形狀: @@ -121,7 +107,7 @@ label 形如 `//Packages/Account:Account`。 | 遠端 package 的 product | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `//Packages/SFSafeSymbols:SFSafeSymbols` | | 本地 package 的 product | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | -`Packages//BUILD` 是一層 alias,指向目前實作它的東西: +rspm 模式下 `Packages//BUILD` 是一層 alias,指向目前實作它的東西: ```python alias( @@ -134,11 +120,9 @@ alias( 目錄名取人看得懂的 package 名(remote 用 URL 最後一段去掉 `.git`,local 用 目錄名),所以 `//Packages/GRMustache.swift:Mustache` 這種帶點的名字也成立。 -意義:**換掉 SPM 實作只動 `Packages/` 底下的檔案**。階段 4 把 alias 換成規則 -本體時,沒有任何 target 的 `deps` 需要改;中途要回退,把 alias 指回 rspm 即可。 -測試也不再釘 rspm 的 repo 命名規則。 - ---- +意義:**換掉 SwiftPM 實作只動 `Packages/` 底下的檔案**。把 alias 換成規則本體時, +沒有任何 target 的 `deps` 需要改;要回退,把 alias 指回 rspm 即可。測試也不再 +釘 rspm 的 repo 命名規則。 ## SwiftPM 概念 → 產出的規則 @@ -150,26 +134,37 @@ alias( | system-library target | `cc_library` + 我們產生的 modulemap | | binary target(xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | | binary target(本地 archive) | 先解壓,再同上 | +| library product,單一 target | `alias` | +| library product,多個 target | `swift_library_group` | | `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | | auto-discovered resources(xib/xcassets/metal/xcstrings) | 同上,`.metal` 連同該 target 的 header 一起進 resource group | -| `defines` | `-D`(值不安全時走 `Generated/Defines.h`,與 Xcode target 同策略) | +| `defines` | `defines`(值不安全時走 `Generated/Defines.h`,與 Xcode target 同策略) | | `headerSearchPath` | `includes` | -| `linkedLibrary` / `linkedFramework` | `linkopts` / `sdk_frameworks` | +| `linkedLibrary` / `linkedFramework` | `linkopts` | | `swiftLanguageMode` | `-swift-version` | -| `enableUpcomingFeature` / `enableExperimentalFeature` | rules_swift 的 `features` | +| `enableUpcomingFeature` / `enableExperimentalFeature` | `-enable-upcoming-feature` / `-enable-experimental-feature` | | `defaultIsolation` | `-default-isolation ` | +| `interoperabilityMode` | `-cxx-interoperability-mode=` | +| `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | | build tool plugin(SwiftLint 等) | 階段 3;先跳過並警告 | | macro / compiler plugin | 階段 3;`swift_compiler_plugin` | | traits(SE-0450) | 依 enabled traits 展開成 `-D` 與條件依賴 | ---- +每個產生的 `swift_library` 都對齊兩個 SwiftPM 行為:`alwayslink`,因為 SwiftPM +一律整份連結 package library;還有 `always_include_developer_search_paths`, +`RxTest` 這種測試輔助 library 就是靠它找到 XCTest。每個 library 另外標 +`manual`:package target 是透過會轉場到某個平台的 bundle 規則建起來的,wildcard +pattern 不該把 iOS-only 的 package 拿去編 host。 + +package 自己宣告的 platform floor 是**故意忽略**的——逐 package 遵守它,正是把 +我們釘在 rspm 1.15.0 的那個行為。 ## 階段 0 的結果(已量測) -語料:12 個 app 目前展開出的 **119 個 package/208 個非測試 target**(讀 rspm 產生 -在 external repo 裡的 `dump.json` 與 `desc.json`;`swift package dump-package` -與 `describe` 的輸出)。 +語料:12 個 app 目前展開出的 **119 個 package/208 個非測試 target**(讀 rspm +產生在 external repo 裡的 `dump.json` 與 `desc.json`,也就是 +`swift package dump-package` 與 `describe` 的輸出)。 ### target 種類 @@ -206,8 +201,8 @@ alias( - **manifest 形狀**:明列 `sources` 30 個 target、`exclude` 32、 `publicHeadersPath` 33 → clang target 的檔案收集不能只靠慣例。 - **tools-version** 從 4.2 到 6.3 都有(最多的是 5.3,30 個)。 -- **plugin 使用**:9 個 package,**全部是 SwiftLint** - (`SwiftLintPlugin` 5 個、`SwiftLintPlugins` 4 個)——只做 lint,不產生原始碼。 +- **plugin 使用**:9 個 package,**全部是 SwiftLint**(`SwiftLintPlugin` 5 個、 + `SwiftLintPlugins` 4 個)——只做 lint,不產生原始碼。 - **plugin target**:只有 1 個,swift-argument-parser 的 `GenerateManual`, 語料裡沒有人消費它。 - **binary target**:2 個(Sparkle 的遠端 xcframework、CodeEditLanguages 的本地 @@ -215,8 +210,8 @@ alias( ### 這代表什麼 -把「lint-only 的 build tool plugin 略過(印警告)」和「不產生也不輸出沒人消費的 -plugin target/product」當成規則,語料裡**119 個 package 全部落在階段 1–2**: +把「lint-only 的 build tool plugin 略過(印警告)」和「不產生沒人消費的 plugin +target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: | 階段支援的範圍 | 覆蓋 package | |---|---| @@ -241,6 +236,27 @@ plugin target/product」當成規則,語料裡**119 個 package 全部落在 也就是說:**macro 與真 plugin 可以整段延後**,階段 2 做完就能覆蓋全部語料。 +## 階段 1 的結果(已量測) + +`--spm native` 會為純 Swift 的 package target 產生規則。還不支援的種類會略過 +並印警告,依賴它的 target 也一起略過:一個少了它要連結的 target 的 library, +比根本不存在更糟。 + +7 個原本綠燈的 macOS app 在 native 模式下跑 `bazel build //...`: + +| app | 結果 | 卡住的 target 種類 | +|---|---|---| +| stats | 建得起來 | — | +| MacPass | 建得起來 | — | +| MonitorControl | 需要階段 2 | Sparkle,binary target | +| SwiftBar | 需要階段 2 | Sparkle,binary target | +| Rectangle | 需要階段 2 | MASShortcut,clang target | +| iina | 需要階段 2 | GRMustache.swift 的 `GRMustacheKeyAccess`,clang target | +| VirtualBuddy | 需要階段 2 | BuddyKit,clang target | + +每個失敗都是「少了一種 target 種類」,不是規則產錯:唯一解不到的 label 就是 +那些指向被略過 target 的 product。 + ## 分階段與通過條件 每一階段的通過條件都一樣:**12 個 app 至少維持現狀**(7 個綠的仍綠、blocked 的 @@ -250,7 +266,7 @@ plugin target/product」當成規則,語料裡**119 個 package 全部落在 |---|---|---| | 0 ✅ | 量測語料 | 見上 | | 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | -| 1 | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`unsafeFlags`;lint-only plugin 略過並警告;plugin target 不產生。flag 切換,預設仍 rspm | 58 個 package 能單獨建起來 | +| 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由 `--spm native` 切換,預設仍 rspm | 58 個 package 能單獨建起來 | | 2 | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 全部 12 個 app 至少維持現狀 | | 3 | macro/會產生原始碼的 build tool plugin | 語料外的需求出現時再做 | | 4 | 預設切換,移除 rspm 依賴、`Patches/` 與版本守門 | 全部 | @@ -258,14 +274,9 @@ plugin target/product」當成規則,語料裡**119 個 package 全部落在 階段 1–3 期間 rspm 與自製產生器**不混用**:同一個 workspace 只走其中一條,由 flag 決定;混用會產生兩張依賴圖。 ---- - ## 待決事項 -1. 遠端 package 用 `git_repository`(pin revision)還是 `http_archive` - (pin tarball + sha256)?後者快、可快取,但 `Package.resolved` 只給 revision, - 要自己組 tarball URL 並算 checksum。 -2. registry package(`.package(id:)`)階段幾支援?目前語料沒有。 -3. `Package.swift` 是否還需要出現在產物裡?只有 `swift package resolve` 需要它, +1. `Package.swift` 是否還需要出現在產物裡?只有 `swift package resolve` 需要它, 可以改成只在更新 pin 時才產生。 -4. 階段 1–4 期間,上游 rspm PR 還要不要送?(我建議要,patch 很小,對別人也有用) +2. registry package(`.package(id:)`)階段幾支援?目前語料沒有。 +3. 階段 1–4 期間,上游 rspm PR 還要不要送?patch 很小、對別人也有用,我建議要。 From 71194c7ee23d3b235e01746a4bec050c2ae23441 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 15:24:03 +0800 Subject: [PATCH 117/173] Generate rules for the remaining kinds of package target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only pure-Swift package targets had rules, which left five of the seven macOS apps without the C-family, resource-carrying and binary targets their packages are made of. All three arrive together because they share one dispatch: what a target is made of is decided by the files SwiftPM hands it, not by the manifest, which only ever says `regular`. A C-family target becomes an objc_library with a swift_interop_hint. The hint is what names the module — without it the module is named after the label and the target cannot be imported by its own name — and it carries the package's own module map when there is one. Headers are collected from the whole target directory even when `sources` lists files one by one, because an explicit list only stops SwiftPM from compiling the rest. Resources become an apple_resource_bundle named the way SwiftPM names it, plus the accessor a package's own code reaches it through: `Bundle.module` in Swift, a force-included `SWIFTPM_MODULE_BUNDLE` in C. Beyond the declared resources, SwiftPM's own file rules apply, including the one that makes any file inside a `.lproj` directory a localized resource. A binary target is imported as the xcframework SwiftPM already fetched, static or dynamic according to what the binary itself is. Package defines are flags rather than the `defines` attribute: that attribute propagates to every dependent, and a project's own target must not compile as if it were a package — Xcode's generated asset symbols, for one, switch on SWIFT_PACKAGE. --- Sources/BazelRules/Rules+Apple.swift | 4 + Sources/BazelRules/Rules+Objc.swift | 4 + Sources/BazelRules/Rules+Swift.swift | 45 ++++ .../BazelizeKit/SwiftPM/SwiftPM+Binary.swift | 114 ++++++++ .../BazelizeKit/SwiftPM/SwiftPM+Clang.swift | 173 ++++++++++++ .../SwiftPM/SwiftPM+Generator.swift | 177 +++++++++--- .../SwiftPM/SwiftPM+Manifest.swift | 4 + .../SwiftPM/SwiftPM+Resources.swift | 251 ++++++++++++++++++ .../SwiftPM/SwiftPM+Settings.swift | 13 +- .../SwiftPM/SwiftPM+Workspace.swift | 13 +- 10 files changed, 757 insertions(+), 41 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index af51fb8..9a636a5 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -1176,6 +1176,8 @@ extension Rules.Apple.Resources { /// Builds an `apple_resource_bundle` target. public static func apple_resource_bundle( name: String, + bundle_name: String? = nil, + infoplists: Starlark.Value? = nil, resources: Starlark.Value? = nil, structured_resources: Starlark.Value? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) @@ -1183,6 +1185,8 @@ extension Rules.Apple.Resources { { Rules.Apple.Resources.apple_resource_bundle.call { "name" => name + if let bundle_name { "bundle_name" => bundle_name } + if let infoplists { "infoplists" => infoplists } if let resources { "resources" => resources } if let structured_resources { "structured_resources" => structured_resources } if let visibility { visibility } diff --git a/Sources/BazelRules/Rules+Objc.swift b/Sources/BazelRules/Rules+Objc.swift index 115ee7d..80ec443 100644 --- a/Sources/BazelRules/Rules+Objc.swift +++ b/Sources/BazelRules/Rules+Objc.swift @@ -43,6 +43,7 @@ extension Rules.Objc { /// Reference: [Bazel `objc_library`](https://bazel.build/reference/be/objective-c#objc_library) public static func objc_library( name: String, + aspect_hints: Starlark.Value? = nil, srcs: Starlark.Value? = nil, hdrs: Starlark.Value? = nil, deps: Starlark.Value? = nil, @@ -60,6 +61,7 @@ extension Rules.Objc { sdk_dylibs: [String]? = nil, sdk_frameworks: [String]? = nil, sdk_includes: [String]? = nil, + tags: [String]? = nil, textual_hdrs: Starlark.Value? = nil, testonly: Bool? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil, @@ -68,6 +70,7 @@ extension Rules.Objc { { Rules.Objc.objc_library.call { "name" => name + if let aspect_hints { "aspect_hints" => aspect_hints } if let srcs { "srcs" => srcs } if let hdrs { "hdrs" => hdrs } if let deps { "deps" => deps } @@ -85,6 +88,7 @@ extension Rules.Objc { if let sdk_dylibs { "sdk_dylibs" => sdk_dylibs } if let sdk_frameworks { "sdk_frameworks" => sdk_frameworks } if let sdk_includes { "sdk_includes" => sdk_includes } + if let tags { "tags" => tags } if let textual_hdrs { "textual_hdrs" => textual_hdrs } if let testonly { "testonly" => testonly } if let visibility { visibility } diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index f76ff14..034ee39 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -29,6 +29,8 @@ extension Rules { case swift_c_module /// `swift_overlay(name, deps, module_name, overlay_deps, srcs)`. case swift_overlay + /// `swift_interop_hint(name, exclude_hdrs, module_map, module_name, suppressed)`. + case swift_interop_hint /// `swift_library_group(name, deps, exports)`. case swift_library_group @@ -61,6 +63,8 @@ extension Rules { "@build_bazel_rules_swift//swift:swift_c_module.bzl" case .swift_overlay: "@build_bazel_rules_swift//swift:swift_overlay.bzl" + case .swift_interop_hint: + "@build_bazel_rules_swift//swift:swift_interop_hint.bzl" case .swift_library_group: "@build_bazel_rules_swift//swift:swift_library_group.bzl" case .swift_compiler_plugin, .universal_swift_compiler_plugin: @@ -131,6 +135,7 @@ extension Rules.Swift { always_include_developer_search_paths: Bool? = nil, copts: [String]? = nil, module_name: String? = nil, + package_name: String? = nil, srcs: Starlark.Value, deps: Starlark.Value? = nil, data: Starlark.Value? = nil, @@ -158,6 +163,9 @@ extension Rules.Swift { if let module_name { "module_name" => module_name } + if let package_name { + "package_name" => package_name + } "srcs" => srcs if let deps { @@ -482,6 +490,43 @@ extension Rules.Swift { /// Dependencies re-exported by the group. /// - `visibility: Starlark.Statement.Argument.Visibility?` /// Repo-local convenience for emitting a `visibility` attribute. + /// Builds a `swift_interop_hint` target. + /// + /// Reference: [rules_swift `swift_interop_hint`](https://github.com/bazelbuild/rules_swift/blob/main/doc/rules.md#swift_interop_hint) + /// + /// Signature: + /// `swift_interop_hint(name, exclude_hdrs, module_map, module_name, suppressed)`. + /// + /// Parameters: + /// - `name: String` + /// The Bazel target name. + /// - `module_map: Starlark.Label?` + /// A module map written by hand, used instead of a generated one. + /// - `module_name: String?` + /// The module name a Swift target imports. + /// - `exclude_hdrs: Starlark.Value?` + /// Headers kept out of the generated module map. + /// - `suppressed: Bool?` + /// Hides the C target from Swift entirely. + public static func swift_interop_hint( + name: String, + module_map: Starlark.Label? = nil, + module_name: String? = nil, + exclude_hdrs: Starlark.Value? = nil, + suppressed: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Swift.swift_interop_hint.call { + "name" => name + if let module_map { "module_map" => module_map } + if let module_name { "module_name" => module_name } + if let exclude_hdrs { "exclude_hdrs" => exclude_hdrs } + if let suppressed { "suppressed" => suppressed } + if let visibility { visibility } + } + } + public static func swift_library_group( name: String, deps: Starlark.Value? = nil, diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift new file mode 100644 index 0000000..bae1fed --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift @@ -0,0 +1,114 @@ +// +// SwiftPM+Binary.swift +// +// +// Rules for a package target that ships a built binary. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A binary target is an `.xcframework` SwiftPM already fetched, imported the + /// way a project-owned one is. + /// + /// Whether it links statically or dynamically is not in the manifest, so the + /// binary itself is read: an archive is static, a Mach-O dylib is not. + func buildBinary( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + root: Path, + builder: CodeBuilder) throws -> Bool + { + guard let xcframework = try artifact(of: target, in: package) else { + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + no artifact for the binary target + """) + return false + } + + /// The link keeps the `.xcframework` name: the import rule reads the + /// bundle name out of the path. + let directory = root + Self.artifactsRoot + target.name + let link = directory + xcframework.lastComponent + try directory.mkpath() + if link.isSymlink || link.exists { + try? link.delete() + } + try link.symlink(xcframework) + + let imports = Starlark.glob([ + "\(Self.artifactsRoot)/\(target.name)/**", + ]) + + if isStatic(xcframework) { + builder.load(.apple_static_xcframework_import) + builder.call( + Rules.Apple.General.Call.apple_static_xcframework_import( + name: target.name, + xcframework_imports: imports, + visibility: .public)) + } else { + builder.load(.apple_dynamic_xcframework_import) + builder.call( + Rules.Apple.General.Call.apple_dynamic_xcframework_import( + name: target.name, + xcframework_imports: imports, + visibility: .public)) + } + + return true + } + + static let artifactsRoot = "Artifacts" + + /// Where the artifact ended up: a remote one was downloaded and unpacked into + /// the workspace's artifact directory, a local one is a path in the package. + private func artifact(of target: SwiftPM.PackageTarget, in package: SwiftPM.Package) throws -> Path? { + var roots: [Path] = [workspace.artifacts + package.identity + target.name] + + if let path = target.path { + roots.append(package.root + path) + } + + for root in roots { + if root.extension == "xcframework", root.exists { return root } + guard root.isDirectory else { continue } + + let children = (try? root.children()) ?? [] + if let xcframework = children.first(where: { $0.extension == "xcframework" }) { + return xcframework + } + } + + return nil + } + + /// The framework inside the slice is either a static archive — `!` — or a + /// Mach-O dylib. Reading the first bytes beats guessing from the name. + private func isStatic(_ xcframework: Path) -> Bool { + let slices = ((try? xcframework.children()) ?? []).filter(\.isDirectory) + + for slice in slices { + let entries = (try? slice.children()) ?? [] + + if entries.contains(where: { $0.extension == "a" }) { return true } + + guard let framework = entries.first(where: { $0.extension == "framework" }) else { + continue + } + let binary = framework + framework.lastComponentWithoutExtension + guard let handle = FileHandle(forReadingAtPath: binary.string) else { continue } + defer { try? handle.close() } + + let magic = try? handle.read(upToCount: 8) + return magic?.starts(with: Array("!".utf8)) ?? false + } + + return false + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift new file mode 100644 index 0000000..8428fa2 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift @@ -0,0 +1,173 @@ +// +// SwiftPM+Clang.swift +// +// +// Rules for a package target written in C, Objective-C or C++. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A C-family target becomes an `objc_library`: the same rule an Xcode target + /// with C sources uses, so headers, includes and defines behave identically on + /// both sides of the graph. + func buildClang( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + root: Path, + resources: ResourceBundle?, + builder: CodeBuilder) + { + guard let directory = sourceDirectory(of: target, in: package) else { return } + + let module = Self.moduleName(target.name) + let extensions = extensions(of: target, in: package) + let headers = publicHeaders(of: target, in: directory) + let compiled = Self.compileExtensions.filter { extensions.contains($0) } + let present = Set(allFiles(of: target, in: package).compactMap(\.extension)) + let headerExtensions = Self.headerExtensions.filter { present.contains($0) } + + /// The hint is what names the module: without it the module is named after + /// the label, and a Swift `import` of the target's own name fails. A module + /// map the package wrote itself replaces the generated one, because it is + /// the interface the package intends. + let hint = "\(target.name)_interop" + let headerPrefix = headers.map { Self.path(prefix, $0) } + let moduleMap = headerPrefix + .map { "\($0)/module.modulemap" } + .flatMap { path -> String? in + (root + path).exists ? path : nil + } + + builder.load(loadableRule: Rules.Swift.swift_interop_hint) + builder.call( + Rules.Swift.Call.swift_interop_hint( + name: hint, + module_map: moduleMap.map { .named($0) }, + module_name: module)) + + builder.load(loadableRule: Rules.Objc.objc_library) + builder.call( + Rules.Objc.Call.objc_library( + name: target.name, + aspect_hints: .build { [Starlark.Label.named(":\(hint)")] }, + srcs: Starlark.glob( + sources(of: target, prefix: prefix, extensions: compiled) + /// Private headers are compilation inputs wherever they + /// sit, so they are collected from the whole directory even + /// when the sources are listed one by one. + + (headerPrefix == prefix + ? [] + : headerExtensions.map { "\(prefix)/**/*.\($0)" }) + + (resources?.accessors ?? []), + exclude: excluded(target, prefix: prefix) + + (headerPrefix.map { $0 == prefix ? [] : ["\($0)/**"] } ?? [])), + hdrs: headerExtensions.isEmpty ? nil : headerPrefix.map { path in + Starlark.glob(headerExtensions.map { "\(path)/**/*.\($0)" }) + }, + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + data: resources.map { bundle in + .build { [Starlark.Label.named(bundle.label)] } + }, + alwayslink: true, + copts: clangCopts( + of: target, + in: package, + module: module, + resources: resources).nonEmpty, + enable_modules: true, + includes: includes(of: target, prefix: prefix, headers: headers).nonEmpty, + linkopts: linkopts(of: target).nonEmpty, + tags: ["manual"], + visibility: .public)) + } + + /// `publicHeadersPath`, defaulting to the `include` directory SwiftPM looks + /// for. It can also be `.`, meaning the target's own directory. + private func publicHeaders(of target: SwiftPM.PackageTarget, in directory: Path) -> String? { + let path = target.publicHeadersPath ?? "include" + return (directory + path).isDirectory ? path : nil + } + + /// A path a glob accepts: no `.` segment survives normalization. + static func path(_ prefix: String, _ path: String) -> String { + Path("\(prefix)/\(path)").normalize().string + } + + /// What a header lookup can reach: the public headers, the target directory + /// itself — a target's own sources include each other by relative path — and + /// whatever `headerSearchPath` adds. + private func includes( + of target: SwiftPM.PackageTarget, + prefix: String, + headers: String?) -> [String] + { + var paths = [prefix] + if let headers { + paths.append(Self.path(prefix, headers)) + } + + for setting in target.settings + where setting.tool == "c" && setting.name == "headerSearchPath" + { + paths.append(contentsOf: setting.values.map { Self.path(prefix, $0) }) + } + + return NSOrderedSet(array: paths).compactMap { $0 as? String } + } + + /// The module name has to be stated: without it clang names the module after + /// the module map's directory, and a Swift `import` of the target fails. + private func clangCopts( + of target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + module: String, + resources: ResourceBundle?) -> [String] + { + var copts = ["-fmodule-name=\(module)"] + clangDefines(of: target) + + /// SwiftPM force-includes the accessor, so a source reaches its bundle + /// without importing anything. + if let header = resources?.header { + copts.append("-include$(location \(header))") + } + + if let standard = package.manifest.cLanguageStandard { + copts.append("-std=\(standard)") + } + if let standard = package.manifest.cxxLanguageStandard { + copts.append("-std=\(standard)") + } + + for setting in target.settings where setting.tool == "c" || setting.tool == "cxx" { + guard setting.name == "unsafeFlags" else { continue } + copts.append(contentsOf: setting.values) + } + + return copts + } +} + +extension SwiftPM.Generator { + /// `c.define` and `cxx.define`, plus the `SWIFT_PACKAGE` every package target + /// compiles with. + /// + /// Flags, not the `defines` attribute, for the same reason as a Swift target: + /// the attribute would propagate into everything downstream. + func clangDefines(of target: SwiftPM.PackageTarget) -> [String] { + let declared = target.settings.flatMap { setting -> [String] in + guard setting.name == "define" else { return [] } + guard setting.tool == "c" || setting.tool == "cxx" else { return [] } + return setting.values + } + + return (["SWIFT_PACKAGE"] + declared).map { "-D\($0)" } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index 7963ea6..a027b6e 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -40,14 +40,51 @@ extension SwiftPM { try materializeSources(package, at: root) let builder = CodeBuilder() - let emitted = try supportedTargets(of: package) + var emitted = try supportedTargets(of: package) - for target in package.manifest.targets where emitted.contains(target.name) { - build(target, in: package, builder: builder) + for target in package.manifest.targets { + guard let kind = emitted[target.name] else { continue } + + if case .binary = kind { + if try !buildBinary(target, in: package, root: root, builder: builder) { + emitted[target.name] = nil + } + continue + } + + guard let prefix = sourcePrefix(of: target, in: package) else { continue } + + let resources = try buildResources( + target, + in: package, + prefix: prefix, + root: root, + kind: kind, + builder: builder) + + switch kind { + case .swift: + build( + target, + in: package, + prefix: prefix, + resources: resources, + builder: builder) + case .clang: + buildClang( + target, + in: package, + prefix: prefix, + root: root, + resources: resources, + builder: builder) + case .binary, .unsupported: + continue + } } for product in package.manifest.products { - build(product, emitted: emitted, package: package, builder: builder) + build(product, emitted: Set(emitted.keys), package: package, builder: builder) } try (root + "BUILD").write(builder.build()) @@ -56,15 +93,15 @@ extension SwiftPM { /// The targets that can be generated, after dropping everything that depends /// on one that cannot: a library missing a target it links is worse than a /// library that is not there at all. - private func supportedTargets(of package: Package) throws -> Set { + private func supportedTargets(of package: Package) throws -> [String: TargetKind] { let targets = package.manifest.targets.filter { $0.type != "test" } - var supported = Set() + var supported: [String: TargetKind] = [:] for target in targets { guard let kind = try kind(of: target, in: package) else { continue } switch kind { - case .swift: - supported.insert(target.name) + case .swift, .clang, .binary: + supported[target.name] = kind case .unsupported(let reason): Log.codeGenerate.warning(""" Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ @@ -77,11 +114,11 @@ extension SwiftPM { var changed = true while changed { changed = false - for target in targets where supported.contains(target.name) { + for target in targets where supported[target.name] != nil { let missing = target.dependencies.compactMap { dependency -> String? in switch dependency.kind { case .target(let name), .byName(let name): - guard names.contains(name), !supported.contains(name) else { return nil } + guard names.contains(name), supported[name] == nil else { return nil } return name case .product: return nil @@ -89,7 +126,7 @@ extension SwiftPM { } guard let first = missing.first else { continue } - supported.remove(target.name) + supported[target.name] = nil changed = true Log.codeGenerate.warning(""" Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ @@ -113,8 +150,10 @@ extension SwiftPM { static let sourcesRoot = "Package" - private enum TargetKind { + enum TargetKind { case swift + case clang + case binary case unsupported(String) } @@ -129,7 +168,7 @@ extension SwiftPM { /// source, so a build without it is the same build. return .unsupported("plugin targets are not generated") case "binary": - return .unsupported("binary targets are not generated yet") + return .binary case "system": return .unsupported("system library targets are not generated yet") case "macro": @@ -142,20 +181,67 @@ extension SwiftPM { return .unsupported("no source directory") } - let files = (try? directory.recursiveChildren()) ?? [] - let extensions = Set(files.compactMap(\.extension)) + let extensions = extensions(of: target, in: package) + guard !extensions.isEmpty else { + return .unsupported("no sources") + } + + /// A target with any Swift in it is a Swift target: SwiftPM does not + /// allow one target to mix languages, so the C-family files that are + /// still on disk belong to another target or are excluded. + return extensions.contains("swift") ? .swift : .clang + } + + /// The extensions of the files that actually belong to the target, which is + /// what decides whether a `regular` target is Swift or C-family. + func extensions(of target: PackageTarget, in package: Package) -> Set { + Set(sourceFiles(of: target, in: package).compactMap(\.extension)) + } - if extensions.isDisjoint(with: Self.clangExtensions) { - return .swift + /// The files SwiftPM compiles for the target: what an explicit `sources` + /// list names, or the whole target directory, minus `exclude`. + func sourceFiles(of target: PackageTarget, in package: Package) -> [Path] { + guard let directory = sourceDirectory(of: target, in: package) else { return [] } + let roots = (target.sources?.nonEmpty?.map { directory + $0 }) ?? [directory] + return files(under: roots, excluding: target.exclude, in: directory) + } + + /// Everything in the target directory, `exclude` aside. + /// + /// An explicit `sources` list only stops SwiftPM from compiling the rest; + /// a header next to those sources is still the target's header, which is + /// why it is collected from the whole directory. + func allFiles(of target: PackageTarget, in package: Package) -> [Path] { + guard let directory = sourceDirectory(of: target, in: package) else { return [] } + return files(under: [directory], excluding: target.exclude, in: directory) + } + + private func files(under roots: [Path], excluding exclude: [String], in directory: Path) -> [Path] { + let excluded = exclude.map { (directory + $0).normalize().string } + + var files: [Path] = [] + for root in roots { + if root.isDirectory { + files.append(contentsOf: ((try? root.recursiveChildren()) ?? [])) + } else if root.exists { + files.append(root) + } + } + + return files.filter { file in + let path = file.normalize().string + return !excluded.contains { path == $0 || path.hasPrefix("\($0)/") } } - return .unsupported("C-family sources are not generated yet") } - private static let clangExtensions: Set = ["c", "cc", "cpp", "cxx", "m", "mm", "S"] + /// Extensions a C-family compiler is handed. + static let compileExtensions = ["c", "cc", "cpp", "cxx", "m", "mm", "S", "s"] + /// Extensions that are only ever included by another file. + static let headerExtensions = ["h", "hh", "hpp", "hxx", "inc"] /// SwiftPM's own layout rules: an explicit `path`, else one of the /// conventional directories, else the package root for a single target. - private func sourceDirectory(of target: PackageTarget, in package: Package) -> Path? { + func sourceDirectory(of target: PackageTarget, in package: Package) -> Path? { if let path = target.path { let directory = (package.root + path).normalize() return directory.exists ? directory : nil @@ -171,7 +257,7 @@ extension SwiftPM { } /// The target's directory, relative to the package's source link. - private func sourcePrefix(of target: PackageTarget, in package: Package) -> String? { + func sourcePrefix(of target: PackageTarget, in package: Package) -> String? { guard let directory = sourceDirectory(of: target, in: package) else { return nil } let root = package.root.normalize().string @@ -182,9 +268,13 @@ extension SwiftPM { return relative.isEmpty ? Self.sourcesRoot : "\(Self.sourcesRoot)/\(relative)" } - private func build(_ target: PackageTarget, in package: Package, builder: CodeBuilder) { - guard let prefix = sourcePrefix(of: target, in: package) else { return } - + private func build( + _ target: PackageTarget, + in package: Package, + prefix: String, + resources: ResourceBundle?, + builder: CodeBuilder) + { builder.load(loadableRule: Rules.Swift.swift_library) builder.call( Rules.Swift.Call.swift_library( @@ -195,16 +285,18 @@ extension SwiftPM { always_include_developer_search_paths: true, copts: copts(of: target).nonEmpty, module_name: Self.moduleName(target.name), + /// Which targets `package` visibility reaches: every target of + /// the same package, which is what the name identifies. + package_name: package.manifest.name, srcs: Starlark.glob( - sources(of: target, prefix: prefix), - exclude: target.exclude.map { excluded in - "\(prefix)/\(excluded)/**" - }), + sources(of: target, prefix: prefix, extensions: ["swift"]) + + (resources?.accessors ?? []), + exclude: excluded(target, prefix: prefix)), deps: deps(of: target, in: package).nonEmpty.map { labels in .build { labels } }, - defines: .build { - defines(of: target) + data: resources.map { bundle in + .build { [Starlark.Label.named(bundle.label)] } }, linkopts: linkopts(of: target).nonEmpty, /// A package target is built through the bundle rule that @@ -216,19 +308,30 @@ extension SwiftPM { /// An explicit `sources` list names files or directories; without one the /// whole target directory is the target. - private func sources(of target: PackageTarget, prefix: String) -> [String] { + func sources(of target: PackageTarget, prefix: String, extensions: [String]) -> [String] { guard let sources = target.sources, !sources.isEmpty else { - return ["\(prefix)/**/*.swift"] + return extensions.map { "\(prefix)/**/*.\($0)" } } - return sources.map { source in - Path(source).extension == nil - ? "\(prefix)/\(source)/**/*.swift" - : "\(prefix)/\(source)" + return sources.flatMap { source -> [String] in + guard let fileExtension = Path(source).extension else { + return extensions.map { "\(prefix)/\(source)/**/*.\($0)" } + } + return extensions.contains(fileExtension) ? ["\(prefix)/\(source)"] : [] + } + } + + /// `exclude` names a file or a directory; a directory excludes everything + /// under it. + func excluded(_ target: PackageTarget, prefix: String) -> [String] { + target.exclude.flatMap { excluded -> [String] in + Path(excluded).extension == nil + ? ["\(prefix)/\(excluded)/**"] + : ["\(prefix)/\(excluded)"] } } - private func deps(of target: PackageTarget, in package: Package) -> [Starlark.Label] { + func deps(of target: PackageTarget, in package: Package) -> [Starlark.Label] { let localTargets = Set(package.manifest.targets.map(\.name)) let localProducts = Dictionary( package.manifest.products.map { ($0.name, $0) }, diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift index 5b52d6f..ba45237 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift @@ -24,6 +24,8 @@ extension SwiftPM { let products: [PackageProduct] let targets: [PackageTarget] let dependencies: [Dependency] + let cLanguageStandard: String? + let cxxLanguageStandard: String? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: AnyKey.self) @@ -32,6 +34,8 @@ extension SwiftPM { products = container.list(PackageProduct.self, "products") targets = container.list(PackageTarget.self, "targets") dependencies = container.list(Dependency.self, "dependencies") + cLanguageStandard = container.value(String.self, "cLanguageStandard") + cxxLanguageStandard = container.value(String.self, "cxxLanguageStandard") } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift new file mode 100644 index 0000000..35635f7 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -0,0 +1,251 @@ +// +// SwiftPM+Resources.swift +// +// +// A package target's resources, as a bundle plus the accessor that finds it. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// What a target's resources add to its own rule. + struct ResourceBundle { + /// The rule the library carries as `data`. + let label: String + /// Generated sources compiled into the library: the accessor a package's + /// own code calls to reach its bundle. + let accessors: [String] + /// The header a C-family target force-includes, so `SWIFTPM_MODULE_BUNDLE` + /// resolves without the sources importing anything. + let header: String? + } + + /// The resources of one target, or `nil` when it has none. + /// + /// SwiftPM puts a target's resources in a bundle named `_` + /// and compiles an accessor that finds it at runtime; a package reaches its + /// own resources only through that pair, so both are generated here. + func buildResources( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + root: Path, + kind: TargetKind, + builder: CodeBuilder) throws -> ResourceBundle? + { + guard let directory = sourceDirectory(of: target, in: package) else { return nil } + + let files = sourceFiles(of: target, in: package) + let declared = target.resources + let discovered = Self.discoveredResources(files: files, prefix: prefix) + guard !declared.isEmpty || !discovered.isEmpty else { return nil } + + let bundle = "\(package.manifest.name)_\(target.name)" + let name = "\(target.name)Resources" + let generated = root + "Generated" + try generated.mkpath() + + let plist = "Generated/\(target.name)ResourceBundle-Info.plist" + try (root + plist).write(Self.infoPlist(bundle: bundle)) + + /// `.copy` keeps the item's own name and inner structure, which is what a + /// structured resource is; `.process` lets the bundler place each file. + var resources: [String] = [] + var structured: [String] = [] + for resource in declared { + let pattern = Self.pattern(of: resource.path, in: directory, prefix: prefix) + if resource.isCopy { + structured.append(pattern) + } else { + resources.append(pattern) + } + } + resources.append(contentsOf: discovered) + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_bundle) + builder.call( + Rules.Apple.Resources.Call.apple_resource_bundle( + name: name, + bundle_name: bundle, + infoplists: .build { [Starlark.Label.named(plist)] }, + resources: resources.nonEmpty.map { Starlark.glob($0) }, + structured_resources: structured.nonEmpty.map { Starlark.glob($0) })) + + switch kind { + case .swift: + let accessor = "Generated/\(target.name)ResourceBundleAccessor.swift" + try (root + accessor).write(Self.swiftAccessor(bundle: bundle)) + return ResourceBundle(label: ":\(name)", accessors: [accessor], header: nil) + case .clang: + let module = Self.moduleName(target.name) + let header = "Generated/\(target.name)ResourceBundleAccessor.h" + let implementation = "Generated/\(target.name)ResourceBundleAccessor.m" + try (root + header).write(Self.objcAccessorHeader(module: module)) + try (root + implementation).write( + Self.objcAccessor(module: module, bundle: bundle)) + return ResourceBundle( + label: ":\(name)", + accessors: [header, implementation], + header: header) + case .binary, .unsupported: + return nil + } + } + + /// The resource types SwiftPM treats as resources without being told, so a + /// package that ships a xib and declares nothing still gets a bundle. + private static func discoveredResources(files: [Path], prefix: String) -> [String] { + let extensions = Set(files.compactMap(\.extension)) + var patterns = discoveredExtensions + .filter { extensions.contains($0) } + .map { "\(prefix)/**/*.\($0)" } + + /// A file inside a `.lproj` directory is a localized resource whatever its + /// own type is — that is how a package ships `.strings` without declaring + /// anything. + if files.contains(where: { $0.parent().extension == "lproj" }) { + patterns.append("\(prefix)/**/*.lproj/**") + } + + return patterns + } + + /// The file types SwiftPM turns into resources on its own, from its own file + /// rules. + private static let discoveredExtensions = [ + "nib", + "xib", + "storyboard", + "xcassets", + "xcstrings", + "xcdatamodel", + "xcdatamodeld", + "xcmappingmodel", + "metal", + ] + + /// A resource path is a file or a directory; a directory contributes + /// everything under it. + private static func pattern(of path: String, in directory: Path, prefix: String) -> String { + (directory + path).isDirectory + ? "\(prefix)/\(path)/**" + : "\(prefix)/\(path)" + } + + /// The bundle SwiftPM produces carries an `Info.plist`; without one the bundle + /// is not loadable. + private static func infoPlist(bundle: String) -> String { + """ + + + + + CFBundleIdentifier + org.swift.\(bundle) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + \(bundle) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + + + """ + } + + /// `Bundle.module`, the name a package's Swift code uses. + /// + /// The bundle sits next to the binary that linked the package, and which + /// binary that is depends on whether the package went into an app, a + /// framework or a tool — so every candidate is tried. + private static func swiftAccessor(bundle: String) -> String { + """ + import Foundation + + private final class BundleFinder {} + + extension Foundation.Bundle { + static let module: Bundle = { + let candidates = [ + Bundle.main.resourceURL, + Bundle(for: BundleFinder.self).resourceURL, + Bundle.main.bundleURL, + ] + + for candidate in candidates { + let url = candidate?.appendingPathComponent("\(bundle).bundle") + if let bundle = url.flatMap(Bundle.init(url:)) { + return bundle + } + } + + fatalError("unable to find bundle named \(bundle)") + }() + } + + """ + } + + /// The C-family half of the same accessor. SwiftPM force-includes this header + /// into every source of the target, which is how `SWIFTPM_MODULE_BUNDLE` + /// appears without an import. + private static func objcAccessorHeader(module: String) -> String { + """ + #ifdef __OBJC__ + #import + + #if __cplusplus + extern "C" { + #endif + + NSBundle *\(module)_SWIFTPM_MODULE_BUNDLE(void); + + #define SWIFTPM_MODULE_BUNDLE \(module)_SWIFTPM_MODULE_BUNDLE() + + #if __cplusplus + } + #endif + #endif + + """ + } + + private static func objcAccessor(module: String, bundle: String) -> String { + """ + #import + + @interface \(module)_BundleFinder : NSObject + @end + + @implementation \(module)_BundleFinder + @end + + NSBundle *\(module)_SWIFTPM_MODULE_BUNDLE(void) { + NSArray *candidates = @[ + [[NSBundle mainBundle] bundleURL], + [[NSBundle bundleForClass:[\(module)_BundleFinder class]] bundleURL], + ]; + + for (NSURL *base in candidates) { + NSURL *url = [base URLByAppendingPathComponent:@"\(bundle).bundle"]; + NSBundle *found = [NSBundle bundleWithURL:url]; + if (found != nil) { + return found; + } + } + + return nil; + } + + """ + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift index 4793039..4fbe97f 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift @@ -14,7 +14,7 @@ extension SwiftPM.Generator { /// anything the rules model: a `defines` attribute would re-tokenize a value /// and a feature is not a flag the rules know. func copts(of target: SwiftPM.PackageTarget) -> [String] { - target.settings.flatMap { setting -> [String] in + swiftDefines(of: target) + target.settings.flatMap { setting -> [String] in guard setting.tool == "swift", let name = setting.name else { return [] } switch name { @@ -47,13 +47,20 @@ extension SwiftPM.Generator { /// `SWIFT_PACKAGE` is what a package's own sources test for; SwiftPM defines it /// for every target it builds. - func defines(of target: SwiftPM.PackageTarget) -> [String] { + /// + /// These are flags, not the `defines` attribute: that attribute propagates to + /// everything that depends on the library, and a project's own target must not + /// compile as if it were a package — Xcode's generated asset symbols, for one, + /// switch on `SWIFT_PACKAGE`. + func swiftDefines(of target: SwiftPM.PackageTarget) -> [String] { let declared = target.settings.compactMap { setting -> [String]? in guard setting.tool == "swift", setting.name == "define" else { return nil } return setting.values }.flatMap { $0 } - return ["SWIFT_PACKAGE"] + declared + return (["SWIFT_PACKAGE"] + declared).flatMap { define in + ["-D\(define)", "-Xcc", "-D\(define)"] + } } /// A package can name a system library or framework it needs; nothing else in diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift index e0650af..3ed1271 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift @@ -23,12 +23,20 @@ extension SwiftPM { let manifest: Manifest /// `true` for a package in the project's own repository. let isLocal: Bool + + /// The name SwiftPM files the package's artifacts under. + var identity: String { + directory.lowercased() + } } /// Everything the generator needs about one project's package graph. struct Workspace { let packages: [Package] + /// Where SwiftPM unpacked the binary targets it fetched. + let artifacts: Path + /// Which directory a package identity or manifest name resolves to, so a /// product dependency can be turned into a label. let directoryByIdentity: [String: String] @@ -64,7 +72,10 @@ extension SwiftPM { } } - return .init(packages: packages, directoryByIdentity: directoryByIdentity) + return .init( + packages: packages, + artifacts: output + ".build/artifacts", + directoryByIdentity: directoryByIdentity) } // MARK: Private From f063aee9aa706e7053e696f4a7c15e1b4d07190f Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 20:50:23 +0800 Subject: [PATCH 118/173] Keep SwiftPM's working directory out of the Bazel workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A checkout can carry BUILD files of its own — swift-syntax and Yams both do — and a wildcard pattern then tries to load them as packages of this workspace, which fails on labels that only mean something in the package's own build. --- Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 95932c5..0eb91c6 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -249,7 +249,7 @@ final class PluginSwiftPM: PluginBuiltin { override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } - let manifests = [package, packageResolved].compactMap { $0 } + let manifests = [package, packageResolved].compactMap { $0 } + [ignore] /// In native mode the package directories hold the rules themselves, so /// there is nothing to alias and no generator to patch. guard kit.spm == .rspm else { return manifests } @@ -257,6 +257,12 @@ final class PluginSwiftPM: PluginBuiltin { return manifests + patchFiles + facadeFiles } + /// SwiftPM's working directory is not part of the Bazel workspace: a checkout + /// can carry `BUILD` files of its own, and Bazel would try to load them. + private var ignore: PluginBuiltin.Custom { + .init(path: ".bazelignore", content: ".build\n") + } + override var tip: String? { guard hasPackages else { return nil } guard kit.spm == .rspm else { return nil } From b801b4d4ba2ae48a2c1077197eea4497df2b3bdc Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 20:50:30 +0800 Subject: [PATCH 119/173] Default the device families of an iOS bundle TARGETED_DEVICE_FAMILY is optional in a project file: Xcode then builds for every family the platform has. An iOS bundle rule requires the attribute, so a target that leaves the setting out produced a rule Bazel rejects. --- Sources/BazelizeKit/Codegen/CodeGen+Extension.swift | 2 +- .../BazelizeKit/Codegen/Codegen+Application.swift | 2 +- Sources/BazelizeKit/Codegen/Codegen+Framework.swift | 2 +- Sources/BazelizeKit/Codegen/Codegen+Platform.swift | 13 +++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift index b690870..250b429 100644 --- a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -92,7 +92,7 @@ extension Target { frameworks }, entitlements: entitlementsLabel(project: kit.project), - families: prefer(\.platform.deviceFamily)?.map(\.code), + families: deviceFamilies, infoplists: .build { plistFile(kit) plist_auto diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index fe7645e..0113813 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -86,7 +86,7 @@ extension Target { entitlements: entitlementsLabel(project: project), extensions: embeddedExtensions(project: project), frameworks: embeddedFrameworks(project: project), - families: prefer(\.platform.deviceFamily)?.map(\.code), + families: deviceFamilies, infoplists: .build { plistFile(kit) plist_auto diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index fb55dfa..0393c64 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -19,7 +19,7 @@ extension Target { deps: .build { ":\(name)_library" }, - families: prefer(\.platform.deviceFamily)?.map(\.code), + families: deviceFamilies, infoplists: .build { plistFile(kit) plist_auto diff --git a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift index 9956dd1..00ab188 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift @@ -37,4 +37,17 @@ extension Target { return prefer(\.platform.sdk) } + + /// The device families a bundle rule is built for. + /// + /// `TARGETED_DEVICE_FAMILY` is optional in a project file: Xcode then builds + /// for every family the platform has, and an iOS bundle rule requires the + /// attribute, so the default has to be stated. + var deviceFamilies: [String]? { + if let declared = prefer(\.platform.deviceFamily), !declared.isEmpty { + return declared.map(\.code) + } + + return platformSDK == .iOS ? [XCode.DeviceFamily.iphone.code, XCode.DeviceFamily.ipad.code] : nil + } } From 63a14256272421f40e7e01f3c2d4277938265ba9 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 20:51:13 +0800 Subject: [PATCH 120/173] Build an Xcode target's library through its bundle rule A target's library is compiled with the platform its bundle rule transitions to. Picked up by a wildcard pattern it is compiled for the host instead, which is not what an iOS target's sources are written against: `bazel build //...` on an iOS project failed in UIKit imports rather than building the app. --- Sources/BazelRules/Rules+Builtin.swift | 2 ++ Sources/BazelRules/Rules+Swift.swift | 2 ++ Sources/BazelizeKit/Codegen/Codegen+Target.swift | 7 +++++++ Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift | 2 ++ .../BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift | 2 ++ .../Codegen/Language/Codegen+SwiftLibrary.swift | 2 ++ 6 files changed, 17 insertions(+) diff --git a/Sources/BazelRules/Rules+Builtin.swift b/Sources/BazelRules/Rules+Builtin.swift index 10dd000..86d401a 100644 --- a/Sources/BazelRules/Rules+Builtin.swift +++ b/Sources/BazelRules/Rules+Builtin.swift @@ -79,12 +79,14 @@ extension Rules.Builtin.Call { public static func alias( name: String, actual: Starlark.Label, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { .init("alias") { "name" => name "actual" => actual + if let tags { "tags" => tags } if let visibility { visibility.argument } diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index 034ee39..e187710 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -679,6 +679,7 @@ extension Rules.Swift { swift_plugins: Starlark.Value? = nil, swift_srcs: Starlark.Value? = nil, swiftc_inputs: Starlark.Value? = nil, + tags: [String]? = nil, textual_hdrs: Starlark.Value? = nil, umbrella_header: Starlark.Label? = nil, weak_sdk_frameworks: [String]? = nil, @@ -716,6 +717,7 @@ extension Rules.Swift { if let swift_plugins { "swift_plugins" => swift_plugins } if let swift_srcs { "swift_srcs" => swift_srcs } if let swiftc_inputs { "swiftc_inputs" => swiftc_inputs } + if let tags { "tags" => tags } if let textual_hdrs { "textual_hdrs" => textual_hdrs } if let umbrella_header { "umbrella_header" => umbrella_header } if let weak_sdk_frameworks { "weak_sdk_frameworks" => weak_sdk_frameworks } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 9000bc4..857bb6c 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -1,6 +1,13 @@ import Util extension Target { + /// A target's library is compiled through the bundle rule that transitions it + /// to the target's platform. On its own it would be compiled for the host, + /// which is not what an iOS target's sources are written against, so no + /// wildcard pattern may pick one up. + var manual: [String] { + ["manual"] + } /// A target with no sources of its own has no library to link, so no rule can /// produce its product: UTM wraps an externally built binary in a bundle that /// way. Nothing references a rule that is not emitted either. diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 34ec81a..d5df663 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -91,6 +91,7 @@ extension Target { intentSources assetSymbolSources }, + tags: manual, weak_sdk_frameworks: weakFrameworksSDK, deps: .build { linkedFrameworksLibrary(project: project) @@ -104,6 +105,7 @@ extension Target { Rules.Builtin.Call.alias( name: "\(name)_library", actual: .named("\(name)_mixed"), + tags: manual, visibility: .public)) } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index c57fe5b..5885c5e 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -52,6 +52,7 @@ extension Target { module_name: codegenModuleName, sdk_dylibs: dylibsSDK, sdk_frameworks: sdkFrameworks(project: project), + tags: manual, testonly: isTest, visibility: .private, weak_sdk_frameworks: weakFrameworksSDK)) @@ -61,6 +62,7 @@ extension Target { Rules.Builtin.Call.alias( name: "\(name)_library", actual: .named("\(name)_objc"), + tags: manual, visibility: .public)) } } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index 404f6d6..f7c4687 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -51,6 +51,7 @@ extension Target { definesHeader prefixHeader }, + tags: manual, testonly: isTest, visibility: .private)) @@ -58,6 +59,7 @@ extension Target { Rules.Builtin.Call.alias( name: "\(name)_library", actual: .named("\(name)_swift"), + tags: manual, visibility: .public)) } From d2749b777f664b3e0b9ab8f95afb853d47cee85a Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 20:51:30 +0800 Subject: [PATCH 121/173] Generate package rules the whole corpus builds with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus turned up the shapes a real package has, and every one of them is decided by what SwiftPM would do with the same files. System-library targets are generated: a cc_library over headers the machine already has, with the libraries and frameworks its module map's `link` directives name. Without them a package that wraps SQLite took the packages depending on it down with it. C-family targets get a module map — the one the package ships, or one written over its public headers, as SwiftPM writes it — and every dependency's map is passed to the compiler. A Swift consumer is handed a module by the rules; a C-family one `@import`ing a sibling target is not. Sources are linked per target rather than per checkout, so a package's own BUILD files stay out of this workspace. Every generated rule is tagged manual, for the same reason a target's library is: a package is built through the bundle rule that gives it a platform. The rest is faithfulness to SwiftPM's file rules and to Bazel's: - a glob pattern is only emitted when the target has such a file, because Bazel fails a glob that matches nothing - `.docc` and `.xcprivacy` are ignored, and the sample code in a documentation catalogue does not compile as a source - a header search path keeps its headers even when `exclude` drops the directory: they are inputs reached by `-I` - a target directory that is a symlink is walked, which is how a package builds a variant of another target's sources - a product that carries a target's name while grouping several targets keeps the name a consumer writes; the target's rule is suffixed - whether an imported xcframework is static is read from the binary, including the fat file wrapping one archive per architecture --- Sources/BazelRules/Rules+Apple.swift | 6 + Sources/BazelRules/Rules+Builtin.swift | 9 + Sources/BazelRules/Rules+Cc.swift | 33 ++ Sources/BazelRules/Rules+Swift.swift | 2 + .../BazelizeKit/SwiftPM/SwiftPM+Binary.swift | 86 +++- .../BazelizeKit/SwiftPM/SwiftPM+Clang.swift | 121 +++++- .../SwiftPM/SwiftPM+Generator.swift | 391 ++++++++++++++++-- .../SwiftPM/SwiftPM+Resources.swift | 62 ++- .../SwiftPM/SwiftPM+SystemLibrary.swift | 83 ++++ 9 files changed, 687 insertions(+), 106 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+SystemLibrary.swift diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index 9a636a5..f39c5df 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -1031,12 +1031,14 @@ extension Rules.Apple.General { public static func apple_dynamic_xcframework_import( name: String, xcframework_imports: Starlark.Value, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Apple.General.apple_dynamic_xcframework_import.call { "name" => name "xcframework_imports" => xcframework_imports + if let tags { "tags" => tags } if let visibility { visibility } } } @@ -1045,12 +1047,14 @@ extension Rules.Apple.General { public static func apple_static_xcframework_import( name: String, xcframework_imports: Starlark.Value, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Apple.General.apple_static_xcframework_import.call { "name" => name "xcframework_imports" => xcframework_imports + if let tags { "tags" => tags } if let visibility { visibility } } } @@ -1180,6 +1184,7 @@ extension Rules.Apple.Resources { infoplists: Starlark.Value? = nil, resources: Starlark.Value? = nil, structured_resources: Starlark.Value? = nil, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { @@ -1189,6 +1194,7 @@ extension Rules.Apple.Resources { if let infoplists { "infoplists" => infoplists } if let resources { "resources" => resources } if let structured_resources { "structured_resources" => structured_resources } + if let tags { "tags" => tags } if let visibility { visibility } } } diff --git a/Sources/BazelRules/Rules+Builtin.swift b/Sources/BazelRules/Rules+Builtin.swift index 86d401a..815eab4 100644 --- a/Sources/BazelRules/Rules+Builtin.swift +++ b/Sources/BazelRules/Rules+Builtin.swift @@ -76,6 +76,15 @@ extension Rules.Builtin.Call { } } + /// Makes files of a package usable by another one. + /// + /// Reference: [Bazel `exports_files`](https://bazel.build/reference/be/functions#exports_files) + public static func exports_files(_ paths: [String]) -> Starlark.Statement.Call { + .init("exports_files") { + .positional(.array(paths.map { .string($0) })) + } + } + public static func alias( name: String, actual: Starlark.Label, diff --git a/Sources/BazelRules/Rules+Cc.swift b/Sources/BazelRules/Rules+Cc.swift index 4c82e59..861ee47 100644 --- a/Sources/BazelRules/Rules+Cc.swift +++ b/Sources/BazelRules/Rules+Cc.swift @@ -6,6 +6,7 @@ import Starlark extension Rules { public enum Cc: String, LoadableRule { case cc_import + case cc_library public var module: String { "@rules_cc//cc:defs.bzl" @@ -37,5 +38,37 @@ extension Rules.Cc { if let visibility { visibility } } } + + /// Builds a `cc_library` target. + /// + /// Reference: [Bazel `cc_library`](https://bazel.build/reference/be/c-cpp#cc_library) + public static func cc_library( + name: String, + aspect_hints: Starlark.Value? = nil, + srcs: Starlark.Value? = nil, + hdrs: Starlark.Value? = nil, + deps: Starlark.Value? = nil, + copts: [String]? = nil, + includes: [String]? = nil, + linkopts: [String]? = nil, + tags: [String]? = nil, + textual_hdrs: Starlark.Value? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Cc.cc_library.call { + "name" => name + if let aspect_hints { "aspect_hints" => aspect_hints } + if let srcs { "srcs" => srcs } + if let hdrs { "hdrs" => hdrs } + if let deps { "deps" => deps } + if let copts { "copts" => copts } + if let includes { "includes" => includes } + if let linkopts { "linkopts" => linkopts } + if let tags { "tags" => tags } + if let textual_hdrs { "textual_hdrs" => textual_hdrs } + if let visibility { visibility } + } + } } } diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index e187710..b0d8b74 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -531,6 +531,7 @@ extension Rules.Swift { name: String, deps: Starlark.Value? = nil, exports: Starlark.Value? = nil, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { @@ -538,6 +539,7 @@ extension Rules.Swift { "name" => name if let deps { "deps" => deps } if let exports { "exports" => exports } + if let tags { "tags" => tags } if let visibility { visibility } } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift index bae1fed..a6be5ba 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift @@ -49,15 +49,17 @@ extension SwiftPM.Generator { builder.load(.apple_static_xcframework_import) builder.call( Rules.Apple.General.Call.apple_static_xcframework_import( - name: target.name, + name: ruleName(of: target.name, in: package), xcframework_imports: imports, + tags: Self.manual, visibility: .public)) } else { builder.load(.apple_dynamic_xcframework_import) builder.call( Rules.Apple.General.Call.apple_dynamic_xcframework_import( - name: target.name, + name: ruleName(of: target.name, in: package), xcframework_imports: imports, + tags: Self.manual, visibility: .public)) } @@ -88,8 +90,9 @@ extension SwiftPM.Generator { return nil } - /// The framework inside the slice is either a static archive — `!` — or a - /// Mach-O dylib. Reading the first bytes beats guessing from the name. + /// Whether the framework in the slice links statically, read from the binary + /// itself: the manifest does not say, and a static archive handed to the + /// dynamic import rule fails in the bitcode stripper. private func isStatic(_ xcframework: Path) -> Bool { let slices = ((try? xcframework.children()) ?? []).filter(\.isDirectory) @@ -102,13 +105,80 @@ extension SwiftPM.Generator { continue } let binary = framework + framework.lastComponentWithoutExtension - guard let handle = FileHandle(forReadingAtPath: binary.string) else { continue } - defer { try? handle.close() } + guard let data = try? Data(contentsOf: binary.url) else { continue } - let magic = try? handle.read(upToCount: 8) - return magic?.starts(with: Array("!".utf8)) ?? false + return Self.isStatic(binary: data, offset: 0) } return false } + + /// A binary is an archive, a fat file wrapping one per architecture, or a + /// Mach-O image whose type says whether it is a dylib. + private static func isStatic(binary: Data, offset: Int) -> Bool { + guard let magic = binary.marker(at: offset) else { return false } + + switch magic { + case Self.fat32, Self.fat64: + /// A fat header is followed by one entry per architecture, each naming + /// the offset of its image; every slice of one file is of the same + /// kind, so the first answers. The 64-bit entry has a 64-bit offset, + /// whose low word is the one that can address the file. + let field = magic == Self.fat64 ? offset + 20 : offset + 16 + guard let slice = binary.word(at: field, littleEndian: false) else { return false } + return isStatic(binary: binary, offset: Int(slice)) + + case Self.machOBigEndian32, Self.machOBigEndian64: + return binary.fileType(at: offset, littleEndian: false) != Self.dylib + + case Self.machOLittleEndian32, Self.machOLittleEndian64: + return binary.fileType(at: offset, littleEndian: true) != Self.dylib + + default: + /// Neither Mach-O nor fat: a static archive, which is what a static + /// framework's binary is. + return binary.starts(with: Array("!".utf8), at: offset) + } + } + + private static let fat32: UInt32 = 0xCAFE_BABE + private static let fat64: UInt32 = 0xCAFE_BABF + private static let machOBigEndian32: UInt32 = 0xFEED_FACE + private static let machOBigEndian64: UInt32 = 0xFEED_FACF + private static let machOLittleEndian32: UInt32 = 0xCEFA_EDFE + private static let machOLittleEndian64: UInt32 = 0xCFFA_EDFE + /// `MH_DYLIB`. + private static let dylib: UInt32 = 6 +} + +extension Data { + /// The four bytes at an offset in file order, which is what a magic number is. + fileprivate func marker(at offset: Int) -> UInt32? { + word(at: offset, littleEndian: false) + } + + /// `filetype`, the third word of a Mach-O header. + fileprivate func fileType(at offset: Int, littleEndian: Bool) -> UInt32? { + word(at: offset + 12, littleEndian: littleEndian) + } + + /// A 32-bit field, in the image's own byte order. + fileprivate func word(at offset: Int, littleEndian: Bool) -> UInt32? { + guard offset >= 0, count >= offset + 4 else { return nil } + + let start = index(startIndex, offsetBy: offset) + let bytes = Array(self[start ..< index(start, offsetBy: 4)]) + /// Folding from the most significant byte: the first byte in a big-endian + /// field, the last in a little-endian one. + return (littleEndian ? Array(bytes.reversed()) : bytes) + .reduce(UInt32(0)) { result, byte in + result << 8 | UInt32(byte) + } + } + + fileprivate func starts(with prefix: [UInt8], at offset: Int) -> Bool { + guard count >= offset + prefix.count else { return false } + let start = index(startIndex, offsetBy: offset) + return Array(self[start ..< index(start, offsetBy: prefix.count)]) == prefix + } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift index 8428fa2..936451c 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift @@ -26,23 +26,26 @@ extension SwiftPM.Generator { guard let directory = sourceDirectory(of: target, in: package) else { return } let module = Self.moduleName(target.name) + let imported = moduleMaps(of: target, in: package) let extensions = extensions(of: target, in: package) let headers = publicHeaders(of: target, in: directory) let compiled = Self.compileExtensions.filter { extensions.contains($0) } - let present = Set(allFiles(of: target, in: package).compactMap(\.extension)) - let headerExtensions = Self.headerExtensions.filter { present.contains($0) } + /// Headers are matched against everything on disk: `exclude` can drop a + /// directory that a header search path still points into. + let files = relativeFiles(of: target, in: package, prefix: prefix, excluding: false) /// The hint is what names the module: without it the module is named after /// the label, and a Swift `import` of the target's own name fails. A module /// map the package wrote itself replaces the generated one, because it is /// the interface the package intends. - let hint = "\(target.name)_interop" + let name = ruleName(of: target.name, in: package) + let hint = "\(name)_interop" let headerPrefix = headers.map { Self.path(prefix, $0) } - let moduleMap = headerPrefix - .map { "\($0)/module.modulemap" } - .flatMap { path -> String? in - (root + path).exists ? path : nil - } + let moduleMap = try? write( + moduleMapOf: target, + in: package, + headers: headerPrefix, + root: root) builder.load(loadableRule: Rules.Swift.swift_interop_hint) builder.call( @@ -54,22 +57,27 @@ extension SwiftPM.Generator { builder.load(loadableRule: Rules.Objc.objc_library) builder.call( Rules.Objc.Call.objc_library( - name: target.name, + name: name, aspect_hints: .build { [Starlark.Label.named(":\(hint)")] }, srcs: Starlark.glob( - sources(of: target, prefix: prefix, extensions: compiled) - /// Private headers are compilation inputs wherever they - /// sit, so they are collected from the whole directory even - /// when the sources are listed one by one. - + (headerPrefix == prefix - ? [] - : headerExtensions.map { "\(prefix)/**/*.\($0)" }) + matching( + sources(of: target, prefix: prefix, extensions: compiled) + /// Private headers are compilation inputs wherever they + /// sit, so they are collected from the whole directory + /// even when the sources are listed one by one. + + (headerPrefix == prefix + ? [] + : Self.headerExtensions.map { "\(prefix)/**/*.\($0)" }), + files) + (resources?.accessors ?? []), - exclude: excluded(target, prefix: prefix) + exclude: excludedClang(target, prefix: prefix) + (headerPrefix.map { $0 == prefix ? [] : ["\($0)/**"] } ?? [])), - hdrs: headerExtensions.isEmpty ? nil : headerPrefix.map { path in - Starlark.glob(headerExtensions.map { "\(path)/**/*.\($0)" }) - }, + hdrs: headerPrefix + .map { path in + matching(Self.headerExtensions.map { "\(path)/**/*.\($0)" }, files) + }? + .nonEmpty + .map { Starlark.glob($0) }, deps: deps(of: target, in: package).nonEmpty.map { labels in .build { labels } }, @@ -85,13 +93,77 @@ extension SwiftPM.Generator { enable_modules: true, includes: includes(of: target, prefix: prefix, headers: headers).nonEmpty, linkopts: linkopts(of: target).nonEmpty, - tags: ["manual"], + /// The module a dependent's `@import` names: the package target's + /// own name, not the one Bazel derives from the label. + module_name: module, + tags: Self.manual, + /// A dependency's module map is what makes its `@import` resolve; + /// a C-family consumer, unlike a Swift one, gets none from the + /// rules. + textual_hdrs: imported.nonEmpty.map { maps in + .build { maps.map { Starlark.Label.named($0.label) } } + }, visibility: .public)) } + /// The target's own module map: the one the package ships, or one written + /// here over its public headers. + /// + /// SwiftPM writes one for a clang target that ships none, and the map is + /// what both a Swift `import` and a C-family `@import` of this target + /// resolve through. + private func write( + moduleMapOf target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + headers: String?, + root: Path) throws -> String? + { + guard let map = module(of: target.name, in: package) else { return nil } + + let relative = map.label.split(separator: ":").last.map(String.init) ?? "" + guard relative.hasPrefix("Generated/") else { return relative } + guard let headers else { return nil } + + try (root + "Generated").mkpath() + try (root + relative).write(""" + module \(Self.moduleName(target.name)) { + umbrella "../\(headers)" + export * + } + + """) + + return relative + } + + /// What `exclude` removes from a C-family target. + /// + /// A directory that is also a header search path keeps its headers: they are + /// compilation inputs reached by `-I`, and excluding them leaves the compiler + /// looking for a file the sandbox does not have. Only what would be compiled + /// from there is dropped. + private func excludedClang(_ target: SwiftPM.PackageTarget, prefix: String) -> [String] { + let searched = Set(target.settings.flatMap { setting -> [String] in + guard setting.tool == "c" || setting.tool == "cxx" else { return [] } + guard setting.name == "headerSearchPath" else { return [] } + return setting.values.map { Path($0).normalize().string } + }) + + return target.exclude.flatMap { excluded -> [String] in + let path = Path(excluded).normalize().string + + if searched.contains(path) { + return Self.compileExtensions.map { "\(prefix)/\(path)/**/*.\($0)" } + } + return Path(excluded).extension == nil + ? ["\(prefix)/\(excluded)/**"] + : ["\(prefix)/\(excluded)"] + } + Self.ignoredExtensions.map { "\(prefix)/**/*.\($0)/**" } + } + /// `publicHeadersPath`, defaulting to the `include` directory SwiftPM looks /// for. It can also be `.`, meaning the target's own directory. - private func publicHeaders(of target: SwiftPM.PackageTarget, in directory: Path) -> String? { + func publicHeaders(of target: SwiftPM.PackageTarget, in directory: Path) -> String? { let path = target.publicHeadersPath ?? "include" return (directory + path).isDirectory ? path : nil } @@ -133,6 +205,11 @@ extension SwiftPM.Generator { { var copts = ["-fmodule-name=\(module)"] + clangDefines(of: target) + /// The module map of each dependency, so its `@import` resolves. + for map in moduleMaps(of: target, in: package) { + copts.append("-fmodule-map-file=\(map.path)") + } + /// SwiftPM force-includes the accessor, so a source reaches its bundle /// without importing anything. if let header = resources?.header { diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index a027b6e..f7fcba0 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -18,16 +18,153 @@ extension SwiftPM { /// The layout matches what `Targets/` already does: a symlink tree of the /// sources and a generated `BUILD` beside it. Nothing outside `Packages/` /// changes — a target reaches a product through the facade either way. - struct Generator { + final class Generator { let output: Path let workspace: Workspace + /// The clang modules of the whole graph, so a C-family target can be handed + /// the module maps of what it imports. + private var modules: [String: Module] = [:] + private var kinds: [String: [String: TargetKind]] = [:] + + init(output: Path, workspace: Workspace) { + self.output = output + self.workspace = workspace + } + + /// A clang module a dependent can `@import`. + struct Module { + /// The module map as a compile action sees it. + let path: String + /// The label that makes the map an input of that action. + let label: String + } + func generate() throws { + /// The modules come first: a C-family target needs the module maps of + /// its dependencies, which may be in a package generated later. + for package in workspace.packages { + let supported = try supportedTargets(of: package) + kinds[package.directory] = supported + register(modulesOf: package, supported: supported) + } + for package in workspace.packages { try generate(package) } } + /// Where each C-family target's module map is, or will be written. + private func register(modulesOf package: Package, supported: [String: TargetKind]) { + for target in package.manifest.targets { + guard let kind = supported[target.name] else { continue } + guard let directory = sourceDirectory(of: target, in: package) else { continue } + + let prefix = "\(Self.sourcesRoot)/\(target.name)" + let relative: String? + + switch kind { + case .clang: + let headers = publicHeaders(of: target, in: directory) + let shipped = headers.map { "\(prefix)/\($0)/module.modulemap" } + + if let shipped, (directory + (headers ?? "") + "module.modulemap").exists { + relative = shipped + } else { + /// Written by the generator, the way SwiftPM writes one for + /// a clang target that ships none. + relative = headers == nil ? nil : "Generated/\(target.name).modulemap" + } + case .system: + relative = "\(prefix)/module.modulemap" + case .swift, .binary, .unsupported: + relative = nil + } + + guard let relative else { continue } + let directoryLabel = "//\(PluginSwiftPM.packagesDirectory)/\(package.directory)" + modules["\(package.directory)/\(target.name)"] = Module( + path: "\(PluginSwiftPM.packagesDirectory)/\(package.directory)/\(relative)", + label: "\(directoryLabel):\(relative)") + } + } + + /// The module map of a target, if it has one. + func module(of target: String, in package: Package) -> Module? { + modules["\(package.directory)/\(target)"] + } + + /// The module maps a C-family target compiles against: its dependencies', + /// and theirs, because a module map can import another module. + func moduleMaps(of target: PackageTarget, in package: Package) -> [Module] { + var found: [String: Module] = [:] + var seen: Set = ["\(package.directory)/\(target.name)"] + var queue: [(Package, PackageTarget)] = [(package, target)] + + while let (owner, current) = queue.popLast() { + for (nextPackage, next) in dependencies(of: current, in: owner) { + let key = "\(nextPackage.directory)/\(next.name)" + guard seen.insert(key).inserted else { continue } + + if let module = modules[key] { found[key] = module } + queue.append((nextPackage, next)) + } + } + + return found.keys.sorted().compactMap { found[$0] } + } + + /// The targets a target depends on, in the packages that own them. + private func dependencies( + of target: PackageTarget, + in package: Package) -> [(Package, PackageTarget)] + { + let targetsByName = Dictionary( + package.manifest.targets.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first }) + + return target.dependencies.flatMap { dependency -> [(Package, PackageTarget)] in + switch dependency.kind { + case .target(let name): + return targetsByName[name].map { [(package, $0)] } ?? [] + case .byName(let name): + if let local = targetsByName[name] { return [(package, local)] } + if let product = package.manifest.products.first(where: { $0.name == name }) { + return targets(of: product, in: package) + } + return targets(ofProduct: name, package: nil, from: package) + case .product(let name, let packageName): + return targets(ofProduct: name, package: packageName, from: package) + } + } + } + + private func targets( + ofProduct product: String, + package name: String?, + from package: Package) -> [(Package, PackageTarget)] + { + guard let owner = self.package(ofProduct: product, package: name, from: package) else { + return [] + } + guard let declared = owner.manifest.products.first(where: { $0.name == product }) else { + return [] + } + + return targets(of: declared, in: owner) + } + + private func targets( + of product: PackageProduct, + in package: Package) -> [(Package, PackageTarget)] + { + product.targets.compactMap { name in + package.manifest.targets + .first { $0.name == name } + .map { (package, $0) } + } + } + // MARK: Private private var packagesRoot: Path { @@ -37,10 +174,9 @@ extension SwiftPM { private func generate(_ package: Package) throws { let root = packagesRoot + package.directory try root.mkpath() - try materializeSources(package, at: root) let builder = CodeBuilder() - var emitted = try supportedTargets(of: package) + var emitted = kinds[package.directory] ?? [:] for target in package.manifest.targets { guard let kind = emitted[target.name] else { continue } @@ -52,7 +188,20 @@ extension SwiftPM { continue } - guard let prefix = sourcePrefix(of: target, in: package) else { continue } + guard let prefix = try materialize(target, in: package, at: root) else { continue } + + if case .system = kind { + if !buildSystemLibrary( + target, + in: package, + prefix: prefix, + root: root, + builder: builder) + { + emitted[target.name] = nil + } + continue + } let resources = try buildResources( target, @@ -78,7 +227,7 @@ extension SwiftPM { root: root, resources: resources, builder: builder) - case .binary, .unsupported: + case .binary, .system, .unsupported: continue } } @@ -87,6 +236,16 @@ extension SwiftPM { build(product, emitted: Set(emitted.keys), package: package, builder: builder) } + /// A C-family target in another package compiles against these maps, + /// so they have to be readable from there. + let maps = emitted.keys + .compactMap { module(of: $0, in: package) } + .map(\.label) + .compactMap { $0.split(separator: ":").last.map(String.init) } + if let maps = maps.nonEmpty { + builder.call(Rules.Builtin.Call.exports_files(maps.sorted())) + } + try (root + "BUILD").write(builder.build()) } @@ -100,7 +259,7 @@ extension SwiftPM { for target in targets { guard let kind = try kind(of: target, in: package) else { continue } switch kind { - case .swift, .clang, .binary: + case .swift, .clang, .binary, .system: supported[target.name] = kind case .unsupported(let reason): Log.codeGenerate.warning(""" @@ -138,22 +297,39 @@ extension SwiftPM { return supported } - /// The sources stay where SwiftPM put them; the package directory only - /// carries a link to them, the way a target's `Sources/` does. - private func materializeSources(_ package: Package, at root: Path) throws { - let destination = root + Self.sourcesRoot - if destination.isSymlink || destination.exists { - try? destination.delete() + /// The sources stay where SwiftPM put them; the package directory carries + /// one link per target, the way a target's `Sources/` does. + /// + /// A link per target rather than one for the whole checkout is what keeps + /// the rest of the checkout out of the build: a package can ship `BUILD` + /// files of its own — swift-syntax and Yams both do — and Bazel would load + /// them as packages of this workspace. + private func materialize(_ target: PackageTarget, in package: Package, at root: Path) throws -> String? { + guard let directory = sourceDirectory(of: target, in: package) else { return nil } + + let prefix = "\(Self.sourcesRoot)/\(target.name)" + let link = root + prefix + try link.parent().mkpath() + if link.isSymlink || link.exists { + try? link.delete() } - try destination.symlink(package.root) + try link.symlink(directory) + + return prefix } - static let sourcesRoot = "Package" + static let sourcesRoot = "Sources" + + /// A package rule is built through the bundle rule that transitions it to a + /// platform; on its own an iOS-only package would be compiled for the host, + /// so no wildcard pattern may pick one up. + static let manual = ["manual"] enum TargetKind { case swift case clang case binary + case system case unsupported(String) } @@ -170,7 +346,7 @@ extension SwiftPM { case "binary": return .binary case "system": - return .unsupported("system library targets are not generated yet") + return .system case "macro": return .unsupported("macro targets are not generated yet") default: @@ -206,6 +382,119 @@ extension SwiftPM { return files(under: roots, excluding: target.exclude, in: directory) } + /// The rule that stands for a target. + /// + /// A product may carry the name of a target while grouping several of them. + /// SwiftPM allows that; two rules cannot share one name, so the product + /// keeps the name a consumer writes and the target's own rule is suffixed. + func ruleName(of target: String, in package: Package) -> String { + let grouped = package.manifest.products + .filter { $0.kind == .library && $0.targets.count > 1 } + .map(\.name) + + return grouped.contains(target) ? "\(target)_target" : target + } + + /// Every file under a directory. + /// + /// `FileManager.subpathsOfDirectory` returns nothing when the directory + /// itself is a symlink, and a package can point one target at another's + /// sources that way to build a variant of it. Paths stay under the + /// directory as named, because that is what a glob pattern is built from. + static func walk(_ directory: Path) -> [Path] { + var visited: Set = [] + return walk(directory, visited: &visited) + } + + private static func walk(_ directory: Path, visited: inout Set) -> [Path] { + /// A package's test fixtures can link a directory back to an ancestor, + /// which would otherwise be walked forever. + let resolved = directory.url.resolvingSymlinksInPath().path + guard visited.insert(resolved).inserted else { return [] } + + let children = (try? directory.children()) ?? [] + return children.flatMap { child -> [Path] in + child.isDirectory ? walk(child, visited: &visited) : [child] + } + } + + /// The target's files as paths under its source link, which is what a glob + /// pattern is matched against. + func relativeFiles( + of target: PackageTarget, + in package: Package, + prefix: String, + excluding exclude: Bool = true) -> [String] + { + guard let directory = sourceDirectory(of: target, in: package) else { return [] } + let root = directory.normalize().string + let files = exclude + ? allFiles(of: target, in: package) + : Self.walk(directory) + + return files.compactMap { file in + let path = file.normalize().string + guard path.hasPrefix(root) else { return nil } + return prefix + String(path.dropFirst(root.count)) + } + } + + /// The patterns that match at least one of the target's files. + /// + /// Bazel fails a glob that matches nothing, so a pattern for a file type + /// the target does not have would break the package rather than produce an + /// empty list. + func matching(_ patterns: [String], _ files: [String]) -> [String] { + patterns.filter { pattern in + files.contains { Self.matches(pattern, $0) } + } + } + + /// Bazel's own glob semantics, on path segments: `**` stands for any run + /// of segments, `*` for any part of one. + static func matches(_ pattern: String, _ file: String) -> Bool { + matches( + pattern: pattern.split(separator: "/").map(String.init), + file: file.split(separator: "/").map(String.init)) + } + + private static func matches(pattern: [String], file: [String]) -> Bool { + guard let segment = pattern.first else { return file.isEmpty } + + if segment == "**" { + let rest = Array(pattern.dropFirst()) + if matches(pattern: rest, file: file) { return true } + guard !file.isEmpty else { return false } + return matches(pattern: pattern, file: Array(file.dropFirst())) + } + + guard let name = file.first, matches(segment: segment, name: name) else { + return false + } + return matches(pattern: Array(pattern.dropFirst()), file: Array(file.dropFirst())) + } + + private static func matches(segment: String, name: String) -> Bool { + let parts = segment.split(separator: "*", omittingEmptySubsequences: false).map(String.init) + guard parts.count > 1 else { return segment == name } + + var rest = Substring(name) + for (index, part) in parts.enumerated() where !part.isEmpty { + if index == 0 { + guard rest.hasPrefix(part) else { return false } + rest = rest.dropFirst(part.count) + } else if index == parts.count - 1 { + guard rest.hasSuffix(part) else { return false } + rest = rest.dropLast(part.count) + } else { + guard let range = rest.range(of: part) else { return false } + rest = rest[range.upperBound...] + } + } + + return true + } + /// Everything in the target directory, `exclude` aside. /// /// An explicit `sources` list only stops SwiftPM from compiling the rest; @@ -222,7 +511,7 @@ extension SwiftPM { var files: [Path] = [] for root in roots { if root.isDirectory { - files.append(contentsOf: ((try? root.recursiveChildren()) ?? [])) + files.append(contentsOf: Self.walk(root)) } else if root.exists { files.append(root) } @@ -256,18 +545,6 @@ extension SwiftPM { return flat.exists ? flat : nil } - /// The target's directory, relative to the package's source link. - func sourcePrefix(of target: PackageTarget, in package: Package) -> String? { - guard let directory = sourceDirectory(of: target, in: package) else { return nil } - - let root = package.root.normalize().string - let path = directory.normalize().string - guard path.hasPrefix(root) else { return nil } - - let relative = String(path.dropFirst(root.count)).trimmingCharacters(in: ["/"]) - return relative.isEmpty ? Self.sourcesRoot : "\(Self.sourcesRoot)/\(relative)" - } - private func build( _ target: PackageTarget, in package: Package, @@ -278,7 +555,7 @@ extension SwiftPM { builder.load(loadableRule: Rules.Swift.swift_library) builder.call( Rules.Swift.Call.swift_library( - name: target.name, + name: ruleName(of: target.name, in: package), /// SwiftPM compiles every package target with the developer /// search paths, which is how a test-support library finds /// XCTest. @@ -289,7 +566,9 @@ extension SwiftPM { /// the same package, which is what the name identifies. package_name: package.manifest.name, srcs: Starlark.glob( - sources(of: target, prefix: prefix, extensions: ["swift"]) + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)) + (resources?.accessors ?? []), exclude: excluded(target, prefix: prefix)), deps: deps(of: target, in: package).nonEmpty.map { labels in @@ -299,10 +578,7 @@ extension SwiftPM { .build { [Starlark.Label.named(bundle.label)] } }, linkopts: linkopts(of: target).nonEmpty, - /// A package target is built through the bundle rule that - /// transitions it to a platform; building it on its own would - /// compile an iOS-only package for the host. - tags: ["manual"], + tags: Self.manual, visibility: .public)) } @@ -323,14 +599,21 @@ extension SwiftPM { /// `exclude` names a file or a directory; a directory excludes everything /// under it. + /// + /// Documentation catalogues are excluded on top of that: SwiftPM ignores a + /// `.docc` directory, and the sample code inside one does not compile — + /// it is written against `PackageDescription`. func excluded(_ target: PackageTarget, prefix: String) -> [String] { target.exclude.flatMap { excluded -> [String] in Path(excluded).extension == nil ? ["\(prefix)/\(excluded)/**"] : ["\(prefix)/\(excluded)"] - } + } + Self.ignoredExtensions.map { "\(prefix)/**/*.\($0)/**" } } + /// Directory types SwiftPM's file rules ignore. + static let ignoredExtensions = ["docc", "xcprivacy"] + func deps(of target: PackageTarget, in package: Package) -> [Starlark.Label] { let localTargets = Set(package.manifest.targets.map(\.name)) let localProducts = Dictionary( @@ -340,9 +623,13 @@ extension SwiftPM { let labels: [String] = target.dependencies.compactMap { dependency in switch dependency.kind { case .target(let name): - return localTargets.contains(name) ? ":\(name)" : nil + return localTargets.contains(name) + ? ":\(ruleName(of: name, in: package))" + : nil case .byName(let name): - if localTargets.contains(name) { return ":\(name)" } + if localTargets.contains(name) { + return ":\(ruleName(of: name, in: package))" + } if localProducts[name] != nil { return ":\(name)" } return label(product: name, package: nil, from: package) case .product(let name, let packageName): @@ -356,18 +643,32 @@ extension SwiftPM { /// A product of another package is reached through the facade, so the label /// does not depend on how that package's rules are generated. private func label(product: String, package name: String?, from package: Package) -> String? { + guard let owner = self.package(ofProduct: product, package: name, from: package) else { + Log.codeGenerate.warning(""" + No package for product \(product, privacy: .public) \ + required by \(package.directory, privacy: .public) + """) + return nil + } + + return "//\(PluginSwiftPM.packagesDirectory)/\(owner.directory):\(product)" + } + + /// Which package declares a product: the one the dependency names, or the + /// one whose identity matches. + private func package( + ofProduct product: String, + package name: String?, + from package: Package) -> Package? + { let identities = [name, product].compactMap { $0 } + package.manifest.dependencies.map(\.identity) for identity in identities { guard let directory = workspace.directoryByIdentity[identity.lowercased()] else { continue } - return "//\(PluginSwiftPM.packagesDirectory)/\(directory):\(product)" + return workspace.packages.first { $0.directory == directory } } - Log.codeGenerate.warning(""" - No package for product \(product, privacy: .public) \ - required by \(package.directory, privacy: .public) - """) return nil } @@ -390,7 +691,8 @@ extension SwiftPM { builder.call( Rules.Builtin.Call.alias( name: product.name, - actual: .named(":\(target)"), + actual: .named(":\(ruleName(of: target, in: package))"), + tags: Self.manual, visibility: .public)) return } @@ -401,9 +703,10 @@ extension SwiftPM { name: product.name, deps: .build { targets.sorted().map { target in - Starlark.Label.named(":\(target)") + Starlark.Label.named(":\(ruleName(of: target, in: package))") } }, + tags: Self.manual, visibility: .public)) } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index 35635f7..15951d2 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -39,24 +39,13 @@ extension SwiftPM.Generator { { guard let directory = sourceDirectory(of: target, in: package) else { return nil } - let files = sourceFiles(of: target, in: package) - let declared = target.resources - let discovered = Self.discoveredResources(files: files, prefix: prefix) - guard !declared.isEmpty || !discovered.isEmpty else { return nil } - - let bundle = "\(package.manifest.name)_\(target.name)" - let name = "\(target.name)Resources" - let generated = root + "Generated" - try generated.mkpath() - - let plist = "Generated/\(target.name)ResourceBundle-Info.plist" - try (root + plist).write(Self.infoPlist(bundle: bundle)) + let files = relativeFiles(of: target, in: package, prefix: prefix) /// `.copy` keeps the item's own name and inner structure, which is what a /// structured resource is; `.process` lets the bundler place each file. var resources: [String] = [] var structured: [String] = [] - for resource in declared { + for resource in target.resources { let pattern = Self.pattern(of: resource.path, in: directory, prefix: prefix) if resource.isCopy { structured.append(pattern) @@ -64,7 +53,20 @@ extension SwiftPM.Generator { resources.append(pattern) } } - resources.append(contentsOf: discovered) + resources = matching(resources, files) + + matching(Self.discoveredResources(prefix: prefix), files) + structured = matching(structured, files) + + /// A declared resource that is not on disk leaves nothing to bundle, and a + /// bundle rule without resources is an empty bundle. + guard !resources.isEmpty || !structured.isEmpty else { return nil } + + let bundle = "\(package.manifest.name)_\(target.name)" + let name = "\(ruleName(of: target.name, in: package))Resources" + try (root + "Generated").mkpath() + + let plist = "Generated/\(target.name)ResourceBundle-Info.plist" + try (root + plist).write(Self.infoPlist(bundle: bundle)) builder.load(loadableRule: Rules.Apple.Resources.apple_resource_bundle) builder.call( @@ -73,7 +75,8 @@ extension SwiftPM.Generator { bundle_name: bundle, infoplists: .build { [Starlark.Label.named(plist)] }, resources: resources.nonEmpty.map { Starlark.glob($0) }, - structured_resources: structured.nonEmpty.map { Starlark.glob($0) })) + structured_resources: structured.nonEmpty.map { Starlark.glob($0) }, + tags: Self.manual)) switch kind { case .swift: @@ -91,27 +94,19 @@ extension SwiftPM.Generator { label: ":\(name)", accessors: [header, implementation], header: header) - case .binary, .unsupported: + case .binary, .system, .unsupported: return nil } } /// The resource types SwiftPM treats as resources without being told, so a /// package that ships a xib and declares nothing still gets a bundle. - private static func discoveredResources(files: [Path], prefix: String) -> [String] { - let extensions = Set(files.compactMap(\.extension)) - var patterns = discoveredExtensions - .filter { extensions.contains($0) } - .map { "\(prefix)/**/*.\($0)" } - - /// A file inside a `.lproj` directory is a localized resource whatever its - /// own type is — that is how a package ships `.strings` without declaring - /// anything. - if files.contains(where: { $0.parent().extension == "lproj" }) { - patterns.append("\(prefix)/**/*.lproj/**") - } - - return patterns + private static func discoveredResources(prefix: String) -> [String] { + discoveredExtensions.map { "\(prefix)/**/*.\($0)" } + /// A catalog or a model is a directory, so what a glob can name is the + /// files inside it — as is a `.lproj` directory, which makes every file + /// in it a localized resource whatever its own type is. + + (discoveredDirectoryExtensions + ["lproj"]).map { "\(prefix)/**/*.\($0)/**" } } /// The file types SwiftPM turns into resources on its own, from its own file @@ -120,12 +115,15 @@ extension SwiftPM.Generator { "nib", "xib", "storyboard", - "xcassets", "xcstrings", + "metal", + ] + + private static let discoveredDirectoryExtensions = [ + "xcassets", "xcdatamodel", "xcdatamodeld", "xcmappingmodel", - "metal", ] /// A resource path is a file or a directory; a directory contributes diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+SystemLibrary.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+SystemLibrary.swift new file mode 100644 index 0000000..62e148f --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+SystemLibrary.swift @@ -0,0 +1,83 @@ +// +// SwiftPM+SystemLibrary.swift +// +// +// Rules for a package target that wraps a library the system already has. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A system-library target is a module map over headers that are already on + /// the machine, so the rule compiles nothing and only says what to link. + /// + /// The module map is the whole interface: it names the headers and, through + /// its `link` directives, the libraries and frameworks the module needs. + func buildSystemLibrary( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + root: Path, + builder: CodeBuilder) -> Bool + { + let moduleMap = "\(prefix)/module.modulemap" + guard (root + moduleMap).exists else { + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + a system library target without a module map + """) + return false + } + + let name = ruleName(of: target.name, in: package) + let hint = "\(name)_interop" + let files = relativeFiles(of: target, in: package, prefix: prefix) + + builder.load(loadableRule: Rules.Swift.swift_interop_hint) + builder.call( + Rules.Swift.Call.swift_interop_hint( + name: hint, + module_map: .named(moduleMap), + module_name: Self.moduleName(target.name))) + + builder.load(loadableRule: Rules.Cc.cc_library) + builder.call( + Rules.Cc.Call.cc_library( + name: name, + aspect_hints: .build { [Starlark.Label.named(":\(hint)")] }, + hdrs: matching(Self.headerExtensions.map { "\(prefix)/**/*.\($0)" }, files) + .nonEmpty + .map { Starlark.glob($0) }, + includes: [prefix], + linkopts: Self.linkopts(moduleMap: root + moduleMap).nonEmpty, + tags: Self.manual, + visibility: .public)) + + return true + } + + /// `link "z"` and `link framework "Cocoa"` in a module map are what the module + /// needs at link time; nothing else in the manifest says so. + static func linkopts(moduleMap: Path) -> [String] { + guard let content: String = try? moduleMap.read() else { return [] } + + var linkopts: [String] = [] + for line in content.split(separator: "\n") { + let statement = line.trimmingCharacters(in: .whitespaces) + guard statement.hasPrefix("link ") else { continue } + + guard let name = statement.split(separator: "\"").dropFirst().first else { continue } + if statement.hasPrefix("link framework") { + linkopts.append(contentsOf: ["-framework", String(name)]) + } else { + linkopts.append("-l\(name)") + } + } + + return linkopts + } +} From 614857eca903b7f9d51d86e23b06786e744b7af7 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 20:53:39 +0800 Subject: [PATCH 122/173] Record what stage 2 generates and what it costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole corpus's package kinds are generated now, so the document says what the output looks like — one source link per target, generated module maps, artifacts — and which of the twelve apps build. The four that do not fail outside the generated rules, except UTM: honouring a package's platform floor is deferred, and that is what an iOS 14 project consuming iOS 16 packages runs into. --- docs/SPM.md | 87 ++++++++++++++++++++++++++++++++++++++++---------- docs/SPM_ZH.md | 74 +++++++++++++++++++++++++++++++++--------- 2 files changed, 129 insertions(+), 32 deletions(-) diff --git a/docs/SPM.md b/docs/SPM.md index 8725500..ffb7f04 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -71,8 +71,9 @@ App/ └── Packages/ # ★ new └── / ├── BUILD # the rules for every target of that package - ├── Generated/ # resource bundle accessors, modulemaps, defines - └── Package # symlink to the package's sources + ├── Generated/ # resource bundle accessors, module maps, plists + ├── Sources/ # symlink to that target's sources + └── Artifacts//.xcframework # binary targets ``` `Patches/` disappears entirely. @@ -80,16 +81,18 @@ App/ ### How a package's sources get in Every package — remote or local — is a directory in this workspace holding a -generated `BUILD` and one symlink to the sources SwiftPM already has: +generated `BUILD` and one symlink per target into the sources SwiftPM already +has: ```text Packages/SFSafeSymbols/ ├── BUILD -└── Package -> /.build/checkouts/SFSafeSymbols +└── Sources/ + └── SFSafeSymbols -> /.build/checkouts/SFSafeSymbols/Sources/SFSafeSymbols ``` -so a target's sources are globbed as `Package/Sources//**/*.swift`. -A local package points at wherever its manifest is, read in place. +so a target's sources are globbed as `Sources//**/*.swift`. A local +package points at wherever its manifest is, read in place. Properties of this choice: @@ -99,6 +102,8 @@ Properties of this choice: - Resolution stays SwiftPM's job: bazelize runs `swift package resolve` and reads each checkout's manifest with `swift package dump-package`, which is offline and spans every tools version in the graph. +- A link per target, rather than one for the whole checkout, keeps the rest of + the checkout out of the build — a package may carry `BUILD` files of its own. The alternative — one `git_repository` per remote package, pinned to the revision in `Package.resolved` — is hermetic but reintroduces external repos @@ -139,17 +144,16 @@ rspm's repository naming. | SwiftPM | Generated | |---|---| | Swift target | `swift_library` | -| clang target (C/ObjC/C++) | `objc_library`, reusing bazelize's header/include logic | -| mixed target | `mixed_language_library` | -| system-library target | `cc_library` + a generated modulemap | +| clang target (C/ObjC/C++) | `objc_library` + `swift_interop_hint`, and a module map when the package ships none | +| system-library target | `cc_library` + `swift_interop_hint` over the module map the package ships | | binary target (xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | | binary target (local archive) | unarchived first, then as above | | library product, one target | `alias` | | library product, several targets | `swift_library_group` | | `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | | auto-discovered resources (xib/xcassets/metal/xcstrings) | as above; `.metal` enters the resource group with that target's headers | -| `defines` | `defines` (an unsafe value goes through `Generated/Defines.h`, same policy as an Xcode target) | -| `headerSearchPath` | `includes` | +| `defines` | `-D` flags, not the `defines` attribute, which would propagate to every dependent | +| `headerSearchPath` | `includes`, and the headers there stay inputs even when `exclude` drops the directory | | `linkedLibrary` / `linkedFramework` | `linkopts` | | `swiftLanguageMode` | `-swift-version` | | `enableUpcomingFeature` / `enableExperimentalFeature` | `-enable-upcoming-feature` / `-enable-experimental-feature` | @@ -164,12 +168,26 @@ rspm's repository naming. Two SwiftPM behaviours are matched on every generated `swift_library`: `alwayslink`, because SwiftPM always links a package library, and `always_include_developer_search_paths`, which is how a test-support library -such as `RxTest` finds XCTest. Each library is also tagged `manual`: a package -target is built through the bundle rule that transitions it to a platform, so a -wildcard pattern must not compile an iOS-only package for the host. +such as `RxTest` finds XCTest. Every generated rule is also tagged `manual`: a +package target is built through the bundle rule that transitions it to a +platform, so a wildcard pattern must not compile an iOS-only package for the +host. + +A module map is what names a C-family module. Without one the module is named +after the label and the target cannot be imported by the name its own sources +use; a module map the package ships is preferred, because it is the interface +the package intends. The map of every dependency is passed to the compiler as +well: a Swift consumer is handed a module by the rules, a C-family one +`@import`ing a sibling target is not. + +A package's sources are linked one target at a time, so the rest of a checkout +stays out of the build, and `.bazelignore` keeps SwiftPM's working directory +out of it too. Both exist for the same reason: a package can carry `BUILD` +files of its own, and Bazel would load them as packages of this workspace. A package's platform floor is deliberately ignored — honouring it per package -is exactly the rspm behaviour that pins us to 1.15.0. +is exactly the rspm behaviour that pins us to 1.15.0. What that costs is in +stage 2's results below. ## Stage 0 results (measured) @@ -273,6 +291,41 @@ Native mode across the 7 green macOS apps, `bazel build //...`: Every failure is a missing target kind, not a wrong rule: the products that reference a skipped target are the only unresolved labels. +## Stage 2 results (measured) + +Every kind of target a package in the corpus is made of is generated: C-family, +resource-carrying, binary and system-library targets, next to the Swift ones. + +Native mode, `bazel build //...` followed by launching the app: + +| app | result | +|---|---| +| MonitorControl, SwiftBar, stats, Rectangle, MacPass, iina, VirtualBuddy | build and run | +| CodeEdit | every package builds; the app's own sources are rejected by Swift 6.4 | +| CotEditor | every package builds; the app's own sources are rejected by Swift 6.4 | +| IceCubesApp | every package builds; the app's own sources collide with the iOS 27 SDK (`SwiftUI.Document`) | +| UTM | see the platform floor below | +| PlayCover | `swift package resolve` fails on the package's own manifest | + +The four that do not build fail in code that is not generated here: three in +their own sources against a newer compiler and SDK, one in a package manifest +upstream. + +### Known limitation: platform floors + +A package declares the platform versions it supports, and SwiftPM compiles each +of its targets at the higher of that floor and the consumer's. Bazelize compiles +every package target at the project's deployment target, which is what pins the +rspm dependency at 1.15.0 — later versions transition each target to its own +floor and then fail analysis when a dependency declares a higher one. + +A package that requires more than the project does therefore fails to compile, +with availability errors naming the newer API. UTM is that case: an iOS 14 +project consuming packages that declare iOS 16 and iOS 18. + +Honouring the floor per target needs a transition that raises the deployment +target without splitting the graph, which is stage 3 work. + ## Stages and exit criteria The exit criterion is the same at every stage: **the 12 apps at least hold @@ -284,8 +337,8 @@ reason), plus the 114 unit tests and the iOS fixture. | 0 ✅ | measure the corpus | see above | | 0.5 ✅ | the `//Packages` facade (aliases into rspm) | all apps; label shape settled | | 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind `--spm native`, default still rspm | 58 packages build on their own | -| 2 | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude`), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | all 12 apps at least hold their ground | -| 3 | macros / source-generating build tool plugins | when something outside the corpus needs it | +| 2 ✅ | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude` / module maps), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | the 7 green apps build and run; every package of the other five builds | +| 3 | macros, source-generating build tool plugins, per-target platform floors | when something outside the corpus needs it | | 4 | flip the default, drop the rspm dependency, `Patches/` and the version gate | everything | Through stages 1–3 rspm and the native generator are **never mixed**: a diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index eeb7a58..c947a3b 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -66,8 +66,9 @@ App/ └── Packages/ # ★ 新增 └── / ├── BUILD # 該 package 全部 target 的規則(我們產生) - ├── Generated/ # resource bundle accessor、modulemap、defines - └── Package # 指向該 package 原始碼的 symlink + ├── Generated/ # resource bundle accessor、module map、plist + ├── Sources/ # 指向該 target 原始碼的 symlink + └── Artifacts//.xcframework # binary target ``` `Patches/` 整組消失。 @@ -75,16 +76,17 @@ App/ ### package 的原始碼怎麼進來 每個 package——遠端或本地——都是這個 workspace 裡的一個目錄,裡面放我們產生的 -`BUILD`,和一條指向 SwiftPM 既有原始碼的 symlink: +`BUILD`,以及每個 target 一條指向 SwiftPM 既有原始碼的 symlink: ```text Packages/SFSafeSymbols/ ├── BUILD -└── Package -> /.build/checkouts/SFSafeSymbols +└── Sources/ + └── SFSafeSymbols -> /.build/checkouts/SFSafeSymbols/Sources/SFSafeSymbols ``` -所以 target 的原始碼就是 `Package/Sources//**/*.swift`。本地 package -指向它 manifest 所在的位置,就地讀取。 +所以 target 的原始碼就是 `Sources//**/*.swift`。本地 package 指向它 +manifest 所在的位置,就地讀取。 這個選擇的性質: @@ -93,6 +95,8 @@ Packages/SFSafeSymbols/ - 解析仍然是 SwiftPM 的事:bazelize 跑 `swift package resolve`,再用 `swift package dump-package` 讀每個 checkout 的 manifest——離線、而且橫跨 依賴圖裡所有 tools version。 +- 一個 target 一條 symlink(而不是整包 checkout 一條),checkout 其餘部分就不會 + 進到 build 裡——package 可能自己帶 `BUILD` 檔。 另一個選項是每個遠端 package 產生一個 `git_repository`,用 `Package.resolved` 的 revision 釘住:那是 hermetic 的,但又把 external repo 帶回來,還會重抓一份 @@ -129,17 +133,16 @@ alias( | SwiftPM | 產出 | |---|---| | Swift target | `swift_library` | -| clang target(C/ObjC/C++) | `objc_library`,header/include 沿用 bazelize 現有邏輯 | -| 混合 target | `mixed_language_library` | -| system-library target | `cc_library` + 我們產生的 modulemap | +| clang target(C/ObjC/C++) | `objc_library` + `swift_interop_hint`,package 沒帶 module map 時我們產生一份 | +| system-library target | `cc_library` + `swift_interop_hint`,用 package 自己帶的 module map | | binary target(xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | | binary target(本地 archive) | 先解壓,再同上 | | library product,單一 target | `alias` | | library product,多個 target | `swift_library_group` | | `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | | auto-discovered resources(xib/xcassets/metal/xcstrings) | 同上,`.metal` 連同該 target 的 header 一起進 resource group | -| `defines` | `defines`(值不安全時走 `Generated/Defines.h`,與 Xcode target 同策略) | -| `headerSearchPath` | `includes` | +| `defines` | `-D` flag,不用 `defines` 屬性——那會往每個下游傳 | +| `headerSearchPath` | `includes`,而且該目錄被 `exclude` 丟掉時 header 仍然留作輸入 | | `linkedLibrary` / `linkedFramework` | `linkopts` | | `swiftLanguageMode` | `-swift-version` | | `enableUpcomingFeature` / `enableExperimentalFeature` | `-enable-upcoming-feature` / `-enable-experimental-feature` | @@ -153,12 +156,21 @@ alias( 每個產生的 `swift_library` 都對齊兩個 SwiftPM 行為:`alwayslink`,因為 SwiftPM 一律整份連結 package library;還有 `always_include_developer_search_paths`, -`RxTest` 這種測試輔助 library 就是靠它找到 XCTest。每個 library 另外標 +`RxTest` 這種測試輔助 library 就是靠它找到 XCTest。每個產生的規則另外都標 `manual`:package target 是透過會轉場到某個平台的 bundle 規則建起來的,wildcard pattern 不該把 iOS-only 的 package 拿去編 host。 +module map 決定 C 系模組叫什麼。沒有它,模組名會由 label 推導出來,原始碼就沒辦法 +用自己寫的名字 import;package 自己帶的 map 優先,因為那是它想提供的介面。每個依賴 +的 map 也會一起交給 compiler:Swift 端的模組是規則給的,C 系端 `@import` 兄弟 +target 則沒人給。 + +package 的原始碼是一個 target 一條 symlink,checkout 其餘部分不會進 build; +`.bazelignore` 也把 SwiftPM 的工作目錄排除在外。兩件事同一個理由:package 可能 +自己帶 `BUILD` 檔,Bazel 會把它當成這個 workspace 的 package 去載。 + package 自己宣告的 platform floor 是**故意忽略**的——逐 package 遵守它,正是把 -我們釘在 rspm 1.15.0 的那個行為。 +我們釘在 rspm 1.15.0 的那個行為。代價寫在下面階段 2 的結果裡。 ## 階段 0 的結果(已量測) @@ -257,6 +269,38 @@ target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: 每個失敗都是「少了一種 target 種類」,不是規則產錯:唯一解不到的 label 就是 那些指向被略過 target 的 product。 +## 階段 2 的結果(已量測) + +語料裡 package 會用到的每一種 target 都會產生了:C 系、帶 resource、binary、 +system library,加上原本的 Swift。 + +native 模式下跑 `bazel build //...`,再啟動 app: + +| app | 結果 | +|---|---| +| MonitorControl、SwiftBar、stats、Rectangle、MacPass、iina、VirtualBuddy | 建得起來也跑得起來 | +| CodeEdit | package 全部建得起來;app 自己的原始碼被 Swift 6.4 擋下 | +| CotEditor | package 全部建得起來;app 自己的原始碼被 Swift 6.4 擋下 | +| IceCubesApp | package 全部建得起來;app 自己的原始碼和 iOS 27 SDK 撞名(`SwiftUI.Document`) | +| UTM | 見下面的 platform floor | +| PlayCover | `swift package resolve` 在 package 自己的 manifest 上就失敗 | + +沒建起來的四個,失敗點都不在我們產生的東西裡:三個是自己的原始碼碰上更新的 +compiler 與 SDK,一個是上游 manifest。 + +### 已知限制:platform floor + +package 會宣告自己支援的平台版本,SwiftPM 編它的 target 時取「自己的 floor 和 +使用端的 floor 之中較高的那個」。bazelize 一律用專案的 deployment target 編所有 +package target,而這正是 rspm 依賴被釘在 1.15.0 的原因——之後的版本會把每個 +target 轉場到它自己的 floor,然後在依賴宣告更高版本時 analysis 失敗。 + +所以 package 要求比專案高時就會編不過,錯誤是那些新 API 的 availability。UTM 就是 +這個情形:iOS 14 的專案,用到宣告 iOS 16 與 iOS 18 的 package。 + +要逐 target 遵守 floor,需要一個「拉高 deployment target 又不把依賴圖切開」的 +轉場,那是階段 3 的事。 + ## 分階段與通過條件 每一階段的通過條件都一樣:**12 個 app 至少維持現狀**(7 個綠的仍綠、blocked 的 @@ -267,8 +311,8 @@ target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: | 0 ✅ | 量測語料 | 見上 | | 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | | 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由 `--spm native` 切換,預設仍 rspm | 58 個 package 能單獨建起來 | -| 2 | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 全部 12 個 app 至少維持現狀 | -| 3 | macro/會產生原始碼的 build tool plugin | 語料外的需求出現時再做 | +| 2 ✅ | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`/module map)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 7 個綠燈 app 建得起來也跑得起來;另外五個的 package 全部建得起來 | +| 3 | macro、會產生原始碼的 build tool plugin、逐 target 的 platform floor | 語料外的需求出現時再做 | | 4 | 預設切換,移除 rspm 依賴、`Patches/` 與版本守門 | 全部 | 階段 1–3 期間 rspm 與自製產生器**不混用**:同一個 workspace 只走其中一條,由 From f3a14c78e8066ed6393eba95f0ca39af4dc77d76 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 21:16:09 +0800 Subject: [PATCH 123/173] Drop rules_swift_package_manager The generated rules cover every kind of target the corpus's packages are made of, so the workspace no longer declares a generator for them: no bazel_dep, no module extension, no use_repo list to keep in step with `bazel mod tidy`, and no vendored patches with a version gate around them. `//Packages/` is the rules themselves rather than aliases into an external repository, which is what the facade was for. What the plugin still writes is what SwiftPM reads: `Package.swift` and the pins seeded from Xcode's `Package.resolved`. A local package's path in that manifest is resolved with `realpath` now, because resolution runs from wherever the output is and `resolvingSymlinksInPath()` drops the `/private` prefix a temporary directory resolves to. --- Sources/Bazelize/Command.swift | 8 +- .../BazelDep/BazelDep+SwiftPM.swift | 84 ------ Sources/BazelizeKit/Kit.swift | 11 +- .../Plugin/Plugin+SwiftPM+Facade.swift | 61 +---- .../Plugin/Plugin+SwiftPM+Patch.swift | 247 ------------------ .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 153 ++--------- Sources/BazelizeKit/SwiftPM/SwiftPM.swift | 20 -- .../Xcode2/Model/Project/XCode+Project.swift | 10 +- .../XCode2Tests/RoadmapTreeBuilderTests.swift | 47 +++- 9 files changed, 72 insertions(+), 569 deletions(-) delete mode 100644 Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift delete mode 100644 Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift delete mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM.swift diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index c909437..748d7cf 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -46,9 +46,6 @@ struct GenerateCommand: AsyncParsableCommand { @Option(name: [.long], help: "plugin list") var manifest = ".bazelize.yml" - @Option(name: [.long], help: "Who generates the Swift package rules: rspm or native") - var spm: SwiftPM.Mode = .rspm - @Flag var dump = false @@ -61,8 +58,7 @@ struct GenerateCommand: AsyncParsableCommand { let kit = try await Kit( path, config, - outputPath: outputPath, - spm: spm) + outputPath: outputPath) guard !clear else { kit.clear() @@ -77,8 +73,6 @@ struct GenerateCommand: AsyncParsableCommand { } } -extension SwiftPM.Mode: ExpressibleByArgument {} - // MARK: - XCode2Command struct XCode2Command: AsyncParsableCommand { diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift b/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift deleted file mode 100644 index 6465c59..0000000 --- a/Sources/BazelizeKit/BazelDep/BazelDep+SwiftPM.swift +++ /dev/null @@ -1,84 +0,0 @@ -extension BazelDep { - /// https://github.com/cgrindel/rules_swift_package_manager - enum SwiftPM: String { - static let latest: SwiftPM = .v1_23_0 - - case v1_23_0 = "1.23.0" - case v1_22_0 = "1.22.0" - case v1_21_0 = "1.21.0" - case v1_20_0 = "1.20.0" - case v1_19_0 = "1.19.0" - case v1_18_1 = "1.18.1" - case v1_17_1 = "1.17.1" - case v1_17_0 = "1.17.0" - case v1_16_1 = "1.16.1" - case v1_15_0 = "1.15.0" - case v1_14_0 = "1.14.0" - case v1_13_0 = "1.13.0" - case v1_12_0 = "1.12.0" - case v1_11_3 = "1.11.3" - case v1_11_1 = "1.11.1" - case v1_11_0 = "1.11.0" - case v1_10_0 = "1.10.0" - case v1_9_0 = "1.9.0" - case v1_8_0 = "1.8.0" - case v1_7_0 = "1.7.0" - case v1_6_0 = "1.6.0" - case v1_5_0 = "1.5.0" - case v1_4_0 = "1.4.0" - case v1_3_0 = "1.3.0" - case v1_2_0 = "1.2.0" - case v1_1_0 = "1.1.0" - case v1_0_0 = "1.0.0" - case v0_47_2 = "0.47.2" - case v0_45_0 = "0.45.0" - case v0_44_0 = "0.44.0" - case v0_43_0 = "0.43.0" - case v0_42_0 = "0.42.0" - case v0_41_0 = "0.41.0" - case v0_40_1 = "0.40.1" - case v0_39_0 = "0.39.0" - case v0_38_2 = "0.38.2" - case v0_37_0 = "0.37.0" - case v0_36_0 = "0.36.0" - case v0_35_1 = "0.35.1" - case v0_34_1 = "0.34.1" - case v0_34_0 = "0.34.0" - case v0_33_0 = "0.33.0" - case v0_32_0 = "0.32.0" - case v0_31_1 = "0.31.1" - case v0_30_0 = "0.30.0" - case v0_29_2 = "0.29.2" - case v0_29_1 = "0.29.1" - case v0_28_0 = "0.28.0" - case v0_26_2 = "0.26.2" - case v0_25_0 = "0.25.0" - case v0_24_0 = "0.24.0" - case v0_23_0 = "0.23.0" - case v0_22_0 = "0.22.0" - case v0_21_0 = "0.21.0" - case v0_20_0 = "0.20.0" - case v0_19_0 = "0.19.0" - case v0_18_2 = "0.18.2" - case v0_17_0 = "0.17.0" - case v0_16_0 = "0.16.0" - case v0_15_0 = "0.15.0" - case v0_14_0 = "0.14.0" - case v0_13_1 = "0.13.1" - case v0_13_0 = "0.13.0" - case v0_12_1 = "0.12.1" - case v0_12_0 = "0.12.0" - case v0_11_1 = "0.11.1" - case v0_11_0 = "0.11.0" - case v0_10_0 = "0.10.0" - case v0_9_0 = "0.9.0" - case v0_8_0 = "0.8.0" - case v0_7_1 = "0.7.1" - case v0_7_0 = "0.7.0" - case v0_6_0 = "0.6.0" - case v0_5_0 = "0.5.0" - case v0_4_4 = "0.4.4" - case v0_4_3 = "0.4.3" - case v0_4_2 = "0.4.2" - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 588cbb5..20c5f38 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -15,7 +15,6 @@ import Yams public final class Kit { let project: Project let outputRoot: Path - let spm: SwiftPM.Mode private lazy var roadmap = Bazel.Roadmap(output: outputRoot, project: project) lazy var version = Bazel.Version(outputRoot) @@ -46,15 +45,9 @@ public final class Kit { // MARK: Lifecycle - public init( - _ projPath: Path, - _ preferConfig: String?, - outputPath: Path? = nil, - spm: SwiftPM.Mode = .rspm) async throws - { + public init(_ projPath: Path, _ preferConfig: String?, outputPath: Path? = nil) async throws { project = try Project.load(path: projPath, preferConfig: preferConfig) outputRoot = outputPath ?? Path(project.workspacePath) - self.spm = spm plugins = [] try await pluginSPM.loadPackageNames(projPath: projPath) @@ -102,8 +95,6 @@ extension Kit { /// Rules for the packages the project depends on, generated from their /// manifests instead of by `rules_swift_package_manager`. private final func generateSwiftPackages() async throws { - guard spm == .native else { return } - let workspace = try await SwiftPM.loadWorkspace(output: outputRoot) try SwiftPM.Generator(output: outputRoot, workspace: workspace).generate() diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift index d81dbe1..1f74900 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift @@ -10,73 +10,18 @@ import PathKit import Util extension PluginSwiftPM { - /// Every package product a target links reaches it through `//Packages`, not - /// through the repository whatever tool generated it happens to use. - /// - /// The generated rules behind a product are an implementation detail: today - /// rules_swift_package_manager produces them in an external repository, and the - /// facade is an `alias` pointing there. Replacing that with rules bazelize - /// generates itself is then a change to `Packages/` alone — no target's `deps` - /// mention a package repository, so none of them move. + /// Every package product a target links reaches it through `//Packages`, the + /// one directory the package rules are generated into. static let packagesDirectory = "Packages" - /// A product a target links: which package it belongs to, and the rule that - /// implements it today. + /// A product a target links, and the package it belongs to. struct FacadeProduct { let package: String let product: String - let actual: String } /// `//Packages/SFSafeSymbols:SFSafeSymbols` func facadeLabel(package: String, product: String) -> String { "//\(Self.packagesDirectory)/\(package):\(product)" } - - /// One `BUILD` per package, aliasing each product a target actually links. - var facadeFiles: [PluginBuiltin.Custom] { - let grouped = Dictionary(grouping: facadeProducts) { product in - product.package - } - - return grouped.keys.sorted().compactMap { package -> PluginBuiltin.Custom? in - guard let products = grouped[package] else { return nil } - - var seen = Set() - let aliases = products - .sorted { $0.product < $1.product } - .filter { seen.insert($0.product).inserted } - .map { product in - """ - alias( - name = "\(product.product)", - actual = "\(product.actual)", - visibility = ["//visibility:public"], - ) - """ - } - - return .init( - path: "\(Self.packagesDirectory)/\(package)/BUILD", - content: ([Self.facadeHeader] + aliases).joined(separator: "\n") + "\n") - } - } - - // MARK: Private - - private static let facadeHeader = """ - # Generated using Bazelize - # - # The rules behind a package product live elsewhere; a target depends on the - # product, never on where it is generated. - - """ - - /// The products every target links, with the package they belong to and the - /// rule that currently implements them. - private var facadeProducts: [FacadeProduct] { - kit.project.targets - .flatMap(\.dependencies.packageProducts) - .compactMap(facadeProduct) - } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift deleted file mode 100644 index 61e22c1..0000000 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Patch.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// Plugin+SwiftPM+Patch.swift -// -// -// Patches for rules_swift_package_manager, applied to the generated workspace. -// -import Util - -extension PluginSwiftPM { - /// Files rules_swift_package_manager generates for a package are incomplete in - /// ways that keep real projects from building, and no amount of code generation - /// on this side can make up for them. They are patched in the generated - /// workspace so it stands on its own; all of it belongs upstream. - static let patchDirectory = "Patches" - - /// The release the patches were written against. - /// - /// A patch is a diff, so it only applies to the file it was taken from: for any - /// other release the workspace is generated without it, and whatever the newer - /// release does — fixed upstream or still broken — is what the build sees. - static let patchedVersion: BazelDep.SwiftPM = .v1_15_0 - - private static let allPatches: [(name: String, content: String)] = [ - (name: "rspm-clang-target-headers.patch", content: clangTargetHeadersPatch), - (name: "rspm-metal-headers.patch", content: metalHeadersPatch), - (name: "rspm-default-isolation-settings.patch", content: defaultIsolationSettingsPatch), - (name: "rspm-default-isolation-copts.patch", content: defaultIsolationCoptsPatch), - (name: "rspm-local-archive-artifact.patch", content: localArchiveArtifactPatch) - ] - - var patches: [(name: String, content: String)] { - guard dep == Self.patchedVersion else { - let pinned = dep.rawValue - let patched = Self.patchedVersion.rawValue - Log.codeGenerate.warning(""" - rules_swift_package_manager \(pinned, privacy: .public) is not \ - \(patched, privacy: .public): generating without the patches written for it - """) - return [] - } - return Self.allPatches - } - - /// The `patches` attribute of the module override. - var patchLabels: String { - patches.map { patch in - " \"//\(Self.patchDirectory):\(patch.name)\"," - }.joined(separator: "\n") - } - - /// The patch files themselves, plus the package that exports them. - var patchFiles: [PluginBuiltin.Custom] { - guard !patches.isEmpty else { return [] } - - let patchDirectory = Self.patchDirectory - let exports = patches.map { patch in - " \"\(patch.name)\"," - }.joined(separator: "\n") - - let build = PluginBuiltin.Custom( - path: "\(patchDirectory)/BUILD", - content: """ - exports_files([ - \(exports) - ]) - """) - - return [build] + patches.map { patch in - PluginBuiltin.Custom(path: "\(patchDirectory)/\(patch.name)", content: patch.content) - } - } - - /// A clang target that lists its sources explicitly loses every header that is - /// not a declared source or a public header, so a source including one by - /// relative path cannot compile in a sandbox. - /// - /// Example: tree-sitter-typescript, where `tsx/src/scanner.c` includes - /// `../../common/scanner.h`. - private static let clangTargetHeadersPatch = #""" ---- a/swiftpkg/internal/pkginfos.bzl -+++ b/swiftpkg/internal/pkginfos.bzl -@@ -1502,6 +1502,23 @@ - exclude_paths = abs_exclude_paths, - )) - -+ # A manifest that lists its sources explicitly still lets clang read any -+ # header under the target path: a source can include one by relative path -+ # without a header search path pointing at it. SPM compiles such a target -+ # straight out of the checkout, so nothing has to be declared; Bazel only -+ # stages declared files, so the headers are collected here. -+ # Example: tree-sitter-typescript, where `tsx/src/scanner.c` includes -+ # `../../common/scanner.h`. -+ if source_paths != None: -+ for f in repository_files.list_files_under( -+ repository_ctx, -+ abs_target_path, -+ exclude_paths = abs_exclude_paths, -+ ): -+ _, hdr_ext = paths.split_extension(f) -+ if hdr_ext in _HEADER_EXTS: -+ all_srcs.append(f) -+ - # SPM's exclude list only excludes files from being compiled as sources, - # but headers in excluded directories are still available for inclusion. - # We need to find all header files in excluded directories and add them -"""# - - /// A `.metal` resource is compiled, and the shader includes a header of its own - /// target, which the generated resource bundle does not carry. rules_apple - /// already treats a header in the same resource group as a metal include. - /// - /// Example: UTM's CocoaSpice, where `CSShaders.metal` includes - /// `include/CSShaderTypes.h`. - private static let metalHeadersPatch = #""" ---- a/swiftpkg/internal/swiftpkg_build_files.bzl -+++ b/swiftpkg/internal/swiftpkg_build_files.bzl -@@ -930,6 +930,20 @@ - for r in sorted_resources - if not r.endswith(".bundle") - ] -+ -+ # A `.metal` resource is compiled, not copied, and a shader routinely -+ # includes a header that is part of the target. rules_apple passes any -+ # header in the same resource group to `metal` as an input and does not -+ # bundle it, so the target's headers go in alongside the shaders. -+ if lists.contains([r.endswith(".metal") for r in resources], True): -+ clang_src_info = getattr(target, "clang_src_info", None) -+ if clang_src_info != None: -+ hdrs = [ -+ hdr -+ for hdr in clang_src_info.hdrs + clang_src_info.textual_hdrs -+ if hdr.endswith(".h") and not lists.contains(resources, hdr) -+ ] -+ resources = resources + sorted(hdrs) - precompiled_bundles_and_labels = [ - (r, "{}_{}".format(bundle_label_name, _sanitized_bundle_file_name(r.split("/")[-1]))) - for r in sorted_resources -"""# - - /// `SwiftSetting.defaultIsolation` (SE-0466) is parsed and then dropped as an - /// unrecognized setting, so a package written against `MainActor` by default - /// does not compile. Bazel applies one patch per file, so the setting and the - /// flag it maps to come as a pair. - /// - /// Example: IceCubesApp, whose local packages all declare it. - private static let defaultIsolationSettingsPatch = #""" ---- a/swiftpkg/internal/pkginfos.bzl -+++ b/swiftpkg/internal/pkginfos.bzl -@@ -1862,6 +1879,7 @@ - language_modes = [] - experimental_features = [] - upcoming_features = [] -+ default_isolations = [] - for bs in build_settings: - if bs.kind == build_setting_kinds.define: - defines.append(bs) -@@ -1873,6 +1891,8 @@ - experimental_features.append(bs) - elif bs.kind == build_setting_kinds.upcoming_features: - upcoming_features.append(bs) -+ elif bs.kind == build_setting_kinds.default_isolation: -+ default_isolations.append(bs) - else: - # We do not recognize the setting. - pass -@@ -1880,7 +1900,8 @@ - len(unsafe_flags) == 0 and \ - len(language_modes) == 0 and \ - len(experimental_features) == 0 and \ -- len(upcoming_features) == 0: -+ len(upcoming_features) == 0 and \ -+ len(default_isolations) == 0: - return None - return struct( - defines = defines, -@@ -1888,6 +1909,7 @@ - language_modes = language_modes, - experimental_features = experimental_features, - upcoming_features = upcoming_features, -+ default_isolations = default_isolations, - ) - - def _new_linker_settings(build_settings): -@@ -2083,6 +2105,7 @@ - ) - - build_setting_kinds = struct( -+ default_isolation = "defaultIsolation", - define = "define", - header_search_path = "headerSearchPath", - linked_framework = "linkedFramework", -"""# - - /// The other half: `swiftc`'s `-default-isolation`. - private static let defaultIsolationCoptsPatch = #""" ---- a/swiftpkg/internal/swiftpkg_build_files.bzl -+++ b/swiftpkg/internal/swiftpkg_build_files.bzl -@@ -176,6 +176,20 @@ - condition = experimental_feature.condition, - ) - features.append(new_experimental_feature) -+ for bs in target.swift_settings.default_isolations: -+ for default_isolation in lists.flatten(bzl_selects.new_from_build_setting(bs)): -+ # SE-0466: the manifest setting maps to the compiler flag that -+ # controls the module's default actor isolation. -+ copts.append(bzl_selects.new( -+ value = "-default-isolation", -+ kind = default_isolation.kind, -+ condition = default_isolation.condition, -+ )) -+ copts.append(bzl_selects.new( -+ value = default_isolation.value, -+ kind = default_isolation.kind, -+ condition = default_isolation.condition, -+ )) - for bs in target.swift_settings.upcoming_features: - for upcoming_feature in lists.flatten(bzl_selects.new_from_build_setting(bs)): - new_upcoming_feature = bzl_selects.new( -"""# - - /// A binary target whose `path` points at an archive in the checkout is ignored: - /// the artifact scan looks for a directory, finds nothing and generates no - /// target, while the package's own products still depend on it. SPM unzips such - /// an archive itself. - /// - /// Example: CodeEditLanguages, which ships - /// `CodeLanguagesContainer.xcframework.zip`. - private static let localArchiveArtifactPatch = #""" ---- a/swiftpkg/internal/repo_rules.bzl -+++ b/swiftpkg/internal/repo_rules.bzl -@@ -151,6 +151,14 @@ - repository_ctx.file(path, content = content, executable = False) - - def _artifact_infos_from_path(repository_ctx, path): -+ # A binary target can point at an archive in the checkout, which SPM unzips -+ # itself; nothing in it is visible until it is extracted. -+ if path.endswith(".zip") and not repository_files.is_directory(repository_ctx, path): -+ output = path + ".extracted" -+ if not repository_files.path_exists(repository_ctx, output): -+ repository_ctx.extract(archive = path, output = output) -+ path = output -+ - if path.endswith(".xcframework"): - xcframework_dirs = [path] - else: -"""# -} diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index 0eb91c6..dfd7b7d 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -10,40 +10,18 @@ import PathKit // MARK: - PluginSPM -/// http://github.com/cgrindel/rules_swift_package_manager +/// The manifests SwiftPM resolves the project's packages from. +/// +/// The rules behind those packages are generated by `SwiftPM.Generator`; this +/// plugin only writes what SwiftPM itself reads, and tells each target which +/// product labels to depend on. final class PluginSwiftPM: PluginBuiltin { - /// Not `.latest`: 1.16.0+ transitions every SwiftPM target to its own declared - /// platform floor and then fails analysis when a package imports a dependency - /// with a higher floor. Xcode never enforces that, so real projects (e.g. - /// SimplyCoreAudio declaring macOS 10.12 while depending on swift-atomics - /// declaring 10.13) stop analyzing on versions past 1.15.0. - let dep: BazelDep.SwiftPM = .v1_15_0 let remotes: [RemotePackage] let locals: [LocalPackage] - private var packages: [String] = [] private var projectPath: Path? func loadPackageNames(projPath: Path) async throws { projectPath = projPath - - let packageSwift = package - let workspace = projPath.parent() - let path = workspace + packageSwift.path - let hadExistingManifest = path.exists - let originalContent = hadExistingManifest ? (try? path.read()) : nil - - try path.write(packageSwift.content) - defer { - if hadExistingManifest { - if let originalContent { - try? path.write(originalContent) - } - } else { - try? path.delete() - } - } - - packages = packageRepositories } override init(_ kit: Kit) { @@ -52,67 +30,18 @@ final class PluginSwiftPM: PluginBuiltin { super.init(kit) } - override func module(_ builder: CodeBuilder) { - /// A project without Swift packages has no `Package.swift` to point at, and - /// the extension fails module resolution when the manifest is missing. - guard hasPackages else { return } - /// Nothing to declare when the packages' rules are generated here: they are - /// plain targets in this workspace. - guard kit.spm == .rspm else { return } - - builder.bazel_dep( - name: "rules_swift_package_manager", - version: dep.rawValue) - if !patches.isEmpty { - builder.custom(""" - single_version_override( - module_name = "rules_swift_package_manager", - patch_strip = 1, - patches = [ - \(patchLabels) - ], - ) - """) - } - builder.custom(""" - swift_deps = use_extension( - "@rules_swift_package_manager//:extensions.bzl", - "swift_deps", - ) - swift_deps.from_package( - declare_swift_deps_info = True, - resolved = "//:Package.resolved", - swift = "//:Package.swift", - ) - """) - - let names = packages.map(\.quoted).joined(separator: ",") - builder.custom(""" - use_repo( - swift_deps, - \(names) - ) - """) - } - - /// The package a product belongs to, the product, and the rule that implements - /// it today. `nil` when the product cannot be traced back to a package. + /// The package a product belongs to and the product's own name. `nil` when the + /// product cannot be traced back to a package. func facadeProduct(_ product: PackageProductDependency) -> FacadeProduct? { remoteProduct(product) ?? localProduct(product) } /// NIO, from a remote package. - /// - /// Only the repository name is sanitized: rules_swift_package_manager keeps the - /// product name verbatim, dashes included (`SwiftUIIntrospect-Static`). private func remoteProduct(_ product: PackageProductDependency) -> FacadeProduct? { let name = product.productName guard let url = product.package ?? remoteURL(forProduct: name) else { return nil } - return .init( - package: Self.packageDirectoryName(url: url), - product: name, - actual: "@\(Self.repositoryName(url: url))//:\(name)") + return .init(package: Self.packageDirectoryName(url: url), product: name) } /// Xcode can reference a package product without linking it back to the package. @@ -128,21 +57,15 @@ final class PluginSwiftPM: PluginBuiltin { let product = product.productName let directory: String - let repository: String if let packagePath = kit.project.localPackagePathByProduct[product] { directory = Path(packagePath).lastComponent - repository = directory.lowercased() - } else if let packageRepo = kit.project.localPackageRepoByProduct[product] { - repository = packageRepo.replacingOccurrences(of: "swiftpkg_", with: "") - directory = repository + } else if let declared = kit.project.localPackageDirectoryByProduct[product] { + directory = declared } else { return nil } - return .init( - package: directory, - product: product, - actual: "@swiftpkg_\(repository)//:\(product)") + return .init(package: directory, product: product) } override var target: [String : [String]]? { @@ -249,12 +172,7 @@ final class PluginSwiftPM: PluginBuiltin { override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } - let manifests = [package, packageResolved].compactMap { $0 } + [ignore] - /// In native mode the package directories hold the rules themselves, so - /// there is nothing to alias and no generator to patch. - guard kit.spm == .rspm else { return manifests } - - return manifests + patchFiles + facadeFiles + return [package, packageResolved].compactMap { $0 } + [ignore] } /// SwiftPM's working directory is not part of the Bazel workspace: a checkout @@ -263,46 +181,31 @@ final class PluginSwiftPM: PluginBuiltin { .init(path: ".bazelignore", content: ".build\n") } - override var tip: String? { - guard hasPackages else { return nil } - guard kit.spm == .rspm else { return nil } - - return """ - # rules_swift_package_manager - After bazelize, run `swift package update` and `bazel mod tidy`. - """ - } - private var hasPackages: Bool { !remotes.isEmpty || !locals.isEmpty } - private var packageRepositories: [String] { - let remoteRepos = remotes.compactMap(\.repositoryURL).map(Self.repositoryName(url:)) - let localRepos = locals.map(\.relativePath).map(Self.repositoryName(path:)) - return Set(remoteRepos + localRepos).sorted() - } - + /// Where a local package is, relative to the generated manifest. + /// + /// Both ends are resolved first: an output directory reached through a symlink + /// would otherwise climb out of the link's real parent, and SwiftPM resolves + /// the path it is given against that real one. private func localPackagePath(_ local: LocalPackage) -> String { let source = (kit.project.workspaceRoot + local.relativePath).absolute() let base = kit.outputRoot.absolute() - return Self.relativePath(from: base.string, to: source.string) + return Self.relativePath( + from: Self.resolved(base.string), + to: Self.resolved(source.string)) } - private static func repositoryName(url: String) -> String { - repositoryName(module: repositoryModuleName(url: url)) - } - - private static func repositoryName(path: String) -> String { - repositoryName(module: Path(path).lastComponent) - } - - private static func repositoryName(module: String) -> String { - "swiftpkg_\(sanitize(module.lowercased()))" - } - - private static func sanitize(_ value: String) -> String { - value.replacingOccurrences(of: "-", with: "_") + /// The real path, symlinks and all. + /// + /// Not `resolvingSymlinksInPath()`: that one drops a leading `/private`, which + /// is exactly the prefix a temporary directory resolves to. + private static func resolved(_ path: String) -> String { + guard let resolved = realpath(path, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) } /// The directory a package's products are exposed under, named the way a human diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM.swift deleted file mode 100644 index 33ca6e2..0000000 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// SwiftPM.swift -// -// -// Which generator produces the rules for Swift packages. -// - -extension SwiftPM { - /// Who generates the rules behind `//Packages/:`. - /// - /// Both modes produce the same labels — the facade is what a target depends - /// on — so a workspace can be regenerated either way without touching a single - /// target. - public enum Mode: String, CaseIterable, Sendable { - /// `rules_swift_package_manager` generates them in an external repository. - case rspm - /// bazelize generates them next to the sources, from the manifests. - case native - } -} diff --git a/Sources/Xcode2/Model/Project/XCode+Project.swift b/Sources/Xcode2/Model/Project/XCode+Project.swift index 4cca783..e0c859c 100644 --- a/Sources/Xcode2/Model/Project/XCode+Project.swift +++ b/Sources/Xcode2/Model/Project/XCode+Project.swift @@ -28,7 +28,9 @@ extension XCode.Project { Path(workspacePath) } - public var localPackageRepoByProduct: [String: String] { + /// The directory of the local package that declares a product, for the + /// products Xcode references without naming their package. + public var localPackageDirectoryByProduct: [String: String] { var result: [String: String] = [:] for package in packages.local { @@ -36,11 +38,9 @@ extension XCode.Project { let manifest = packagePath + "Package.swift" guard let content = try? String(contentsOfFile: manifest.string) else { continue } - let repo = "swiftpkg_" + Path(package.relativePath).lastComponent.lowercased().replacingOccurrences( - of: "-", - with: "_") + let directory = Path(package.relativePath).lastComponent for product in content.swiftPackageProductNames { - result[product] = repo + result[product] = directory } } diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 4dcc40e..3299aed 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -84,16 +84,34 @@ struct RoadmapTreeBuilderTests { let module = try String(contentsOfFile: (output + "MODULE.bazel").string) #expect(module.contains("rules_apple")) #expect(module.contains("rules_swift")) - #expect(module.contains("rules_swift_package_manager")) - #expect(module.contains("swift_deps = use_extension")) - #expect(module.contains("swiftpkg_local1")) - - /// The facade is where the repository that implements a product is named. - let localFacade = try String(contentsOfFile: (output + "Packages/Local1/BUILD").string) - #expect(localFacade.contains("name = \"LocalLib1\"")) - #expect(localFacade.contains("actual = \"@swiftpkg_local1//:LocalLib1\"")) - let remoteFacade = try String(contentsOfFile: (output + "Packages/AnyCodable/BUILD").string) - #expect(remoteFacade.contains("actual = \"@swiftpkg_anycodable//:AnyCodable\"")) + /// The packages are targets of this workspace, so nothing declares a + /// generator for them. + #expect(!module.contains("rules_swift_package_manager")) + #expect(!module.contains("use_repo(")) + + /// A product of a local package is the rules of its targets. + let localBuild = try String(contentsOfFile: (output + "Packages/Local1/BUILD").string) + #expect(localBuild.contains("swift_library(")) + #expect(localBuild.contains("name = \"LocalTarget1\"")) + #expect(localBuild.contains("module_name = \"LocalTarget1\"")) + /// A product of several targets is a group over them. + #expect(localBuild.contains("swift_library_group(")) + #expect(localBuild.contains("name = \"LocalLib1\"")) + #expect(localBuild.contains("\":LocalTarget1\"")) + #expect(localBuild.contains("tags = [")) + #expect(localBuild.contains("\"manual\"")) + #expect(!localBuild.contains("@swiftpkg_")) + + /// A remote package's sources are linked per target, next to its rules. + let remoteBuild = try String(contentsOfFile: (output + "Packages/AnyCodable/BUILD").string) + #expect(remoteBuild.contains("name = \"AnyCodable\"")) + #expect(remoteBuild.contains("Sources/AnyCodable/**/*.swift")) + #expect(!remoteBuild.contains("@swiftpkg_")) + #expect((output + "Packages/AnyCodable/Sources/AnyCodable").isSymlink) + + /// SwiftPM's working directory is not part of the Bazel workspace. + let ignore = try String(contentsOfFile: (output + ".bazelignore").string) + #expect(ignore.contains(".build")) } @Test @@ -189,8 +207,11 @@ struct RoadmapTreeBuilderTests { #expect(!prebuiltBuild.contains("name = \"PIP\"")) #expect(!prebuiltBuild.contains("name = \"CoreDisplay\"")) - let module = try String(contentsOfFile: (output + "MODULE.bazel").string) - #expect(module.contains("swiftpkg_grmustache.swift")) - #expect(!module.contains("swiftpkg_swiftpkg_")) + /// A package whose name carries a dot keeps it: the directory is the name a + /// human refers to the package by. + let mustacheBuild = try String(contentsOfFile: (output + "Packages/GRMustache.swift/BUILD").string) + #expect(mustacheBuild.contains("name = \"Mustache\"")) + #expect(mustacheBuild.contains("name = \"GRMustacheKeyAccess\"")) + #expect(mustacheBuild.contains("objc_library(")) } } From e9ec4794029eed3d1828ab247ee82176422de86b Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 21:17:37 +0800 Subject: [PATCH 124/173] Describe the single path the package rules take now --- docs/SPM.md | 83 ++++++++++++++++++++++---------------------------- docs/SPM_ZH.md | 59 +++++++++++++++-------------------- 2 files changed, 60 insertions(+), 82 deletions(-) diff --git a/docs/SPM.md b/docs/SPM.md index ffb7f04..aa6f5e4 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -2,36 +2,35 @@ ## Goal -Generate the Bazel rules for a project's Swift packages in bazelize, instead of -delegating them to `rules_swift_package_manager` (rspm). +Bazelize generates the Bazel rules for a project's Swift packages itself. It +used to declare `rules_swift_package_manager` (rspm) in `MODULE.bazel` and +write a synthesized `Package.swift`, leaving rspm to generate the package +BUILD files inside an external repository at fetch time. -Today bazelize only declares rspm in `MODULE.bazel` and writes a synthesized -`Package.swift`; rspm generates the package BUILD files inside an external -repository at fetch time. - -This document describes the **output shape** after the switch, the mapping from -SwiftPM concepts to rules, and the staged plan. It is about artifacts and +This document describes the **output shape**, the mapping from SwiftPM +concepts to rules, and how the switch was staged. It is about artifacts and responsibility boundaries, not implementation details. ## Why -1. rspm's output needs 4 vendored patches for real projects +1. rspm's output needed 4 vendored patches for real projects (`Patches/rspm-*.patch` + `single_version_override`), plus a version gate. -2. We are pinned to rspm 1.15.0: from 1.16 every SwiftPM target is transitioned - to the platform floor it declares itself, and analysis fails when a - dependency declares a higher floor. Xcode never does this. Platform +2. We were pinned to rspm 1.15.0: from 1.16 every SwiftPM target is + transitioned to the platform floor it declares itself, and analysis fails + when a dependency declares a higher floor. Xcode never does this. Platform semantics are bazelize's own domain, so generating the rules here removes the conflict. 3. Header, resource and plist handling for Xcode targets already lives in bazelize; package targets behave consistently only if they share it. -4. The output becomes checked-in files: a problem is read in the file, not - traced through a repo rule. +4. The output is checked-in files: a problem is read in the file, not traced + through a repo rule. 5. One fewer step — no `bazel mod tidy` to maintain the `use_repo` list. The cost: SwiftPM semantics (traits, registry, binary targets, plugins, macros) -become our responsibility. +are now our responsibility, and so is the one behaviour still missing — a +package's own platform floor, at the end of this document. -## Current output (rspm, for contrast) +## Previous output (rspm, for contrast) ```text App/ @@ -54,10 +53,10 @@ App/ └── CopyFiles// # copy phase destinations ``` -The package BUILD files are not here; they are in +The package BUILD files were not there; they were in `external/rules_swift_package_manager++swift_deps+swiftpkg_/`. -## New output (generated here) +## Output ```text App/ @@ -109,7 +108,7 @@ The alternative — one `git_repository` per remote package, pinned to the revision in `Package.resolved` — is hermetic but reintroduces external repos and fetches sources Bazel already has on disk. -### Label naming: the facade +### Label naming Every package product — remote or local — has one shape in `Targets/*/BUILD`: @@ -118,26 +117,15 @@ Every package product — remote or local — has one shape in `Targets/*/BUILD` | a remote package | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `//Packages/SFSafeSymbols:SFSafeSymbols` | | a local package | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | -In rspm mode `Packages//BUILD` is a layer of aliases pointing at whatever -implements the product today: - -```python -alias( - name = "SFSafeSymbols", - actual = "@swiftpkg_sfsafesymbols//:SFSafeSymbols", - visibility = ["//visibility:public"], -) -``` - The directory is named after the package as a human reads it (last path component of the URL without `.git`, or the directory name for a local package), so a name with a dot — `//Packages/GRMustache.swift:Mustache` — works too. -What this buys: **replacing the SwiftPM implementation only touches files under -`Packages/`**. No target's `deps` changes when the aliases become the rules -themselves, reverting means pointing the aliases back at rspm, and no test pins -rspm's repository naming. +A product is that label whatever generates it, which is what made the switch a +change to `Packages/` alone: the label shape landed first, as aliases into +rspm, and became the rules themselves without a single target's `deps` moving. +No test pins how a package's rules are produced either. ## SwiftPM concept → generated rule @@ -271,12 +259,13 @@ entire corpus. ## Stage 1 results (measured) -`--spm native` generates rules for pure-Swift package targets. A target whose +At this stage only pure-Swift package targets were generated, behind a flag +with rspm still the default. A target whose kind is not generated yet is skipped with a warning, and so is every target that depends on it: a library missing a target it links is worse than a library that is not there at all. -Native mode across the 7 green macOS apps, `bazel build //...`: +Across the 7 green macOS apps, `bazel build //...`: | app | result | blocking target kind | |---|---|---| @@ -296,7 +285,7 @@ reference a skipped target are the only unresolved labels. Every kind of target a package in the corpus is made of is generated: C-family, resource-carrying, binary and system-library targets, next to the Swift ones. -Native mode, `bazel build //...` followed by launching the app: +`bazel build //...`, followed by launching the app: | app | result | |---|---| @@ -315,9 +304,9 @@ upstream. A package declares the platform versions it supports, and SwiftPM compiles each of its targets at the higher of that floor and the consumer's. Bazelize compiles -every package target at the project's deployment target, which is what pins the -rspm dependency at 1.15.0 — later versions transition each target to its own -floor and then fail analysis when a dependency declares a higher one. +every package target at the project's deployment target, which is what pinned +the rspm dependency at 1.15.0 — later versions transition each target to its +own floor and then fail analysis when a dependency declares a higher one. A package that requires more than the project does therefore fails to compile, with availability errors naming the newer API. UTM is that case: an iOS 14 @@ -336,14 +325,14 @@ reason), plus the 114 unit tests and the iOS fixture. |---|---|---| | 0 ✅ | measure the corpus | see above | | 0.5 ✅ | the `//Packages` facade (aliases into rspm) | all apps; label shape settled | -| 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind `--spm native`, default still rspm | 58 packages build on their own | +| 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind a flag, rspm still the default | 58 packages build on their own | | 2 ✅ | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude` / module maps), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | the 7 green apps build and run; every package of the other five builds | | 3 | macros, source-generating build tool plugins, per-target platform floors | when something outside the corpus needs it | -| 4 | flip the default, drop the rspm dependency, `Patches/` and the version gate | everything | +| 4 ✅ | the rspm dependency, `Patches/`, the version gate and the mode flag are gone | the 7 green apps build and run | -Through stages 1–3 rspm and the native generator are **never mixed**: a -workspace takes one path or the other, chosen by the flag. Mixing them would -produce two dependency graphs. +Stage 4 removed the alternative rather than keeping a flag: two paths would +mean two dependency graphs, and the generated one is at least as good on every +app in the corpus. ## Open questions @@ -352,5 +341,5 @@ produce two dependency graphs. are updated. 2. Which stage supports registry packages (`.package(id:)`)? Nothing in the corpus uses one. -3. Should the rspm patches still go upstream during stages 1–4? They are small - and useful to others, so probably yes. +3. Should the four rspm patches still go upstream? They are small and useful to + whoever still uses rspm. diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index c947a3b..3a0a342 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -2,31 +2,29 @@ ## 目標 -由 bazelize 自己產生專案裡 Swift package 的 Bazel 規則,不再交給 -`rules_swift_package_manager`(以下 rspm)。 - -目前 bazelize 只在 `MODULE.bazel` 裡宣告 rspm、寫一份合成的 `Package.swift`, +由 bazelize 自己產生專案裡 Swift package 的 Bazel 規則。以前是在 `MODULE.bazel` +裡宣告 `rules_swift_package_manager`(以下 rspm)、寫一份合成的 `Package.swift`, package 的 BUILD 由 rspm 在 fetch 階段產生在 external repo 裡。 -這份文件描述改成自己產生之後的**輸出結構**、SwiftPM 概念到規則的對應,以及 -分階段的做法。它只談產物形狀與責任邊界,不談實作細節。 +這份文件描述**輸出結構**、SwiftPM 概念到規則的對應,以及這次替換的分階段做法。 +它只談產物形狀與責任邊界,不談實作細節。 ## 為什麼要換 -1. rspm 產出的 BUILD 有幾處對真實專案不夠用,現在用 4 個 vendored patch 補 +1. rspm 產出的 BUILD 有幾處對真實專案不夠用,當時用 4 個 vendored patch 補 (`Patches/rspm-*.patch` + `single_version_override`),還得帶版本守門。 2. 我們被釘在 rspm 1.15.0:≥1.16 會把每個 SwiftPM target 轉場到它自己宣告的 platform floor,然後在依賴宣告更高版本時 analysis 失敗——Xcode 從不這樣做。 平台語義本來就是 bazelize 的主場,自己產生就不會打架。 3. Xcode target 的 header/resource/plist 處理已經在 bazelize 裡了,package target 走同一套才會行為一致。 -4. 產物變成簽入的檔案,出問題直接讀檔,不必追 repo rule。 +4. 產物是簽入的檔案,出問題直接讀檔,不必追 repo rule。 5. 少一段 `bazel mod tidy` 補 `use_repo` 清單的流程。 代價:SwiftPM 的語義(traits、registry、binary target、plugin、macro)從此是 -我們的責任。 +我們的責任;還沒做到的那一項——package 自己的 platform floor——寫在文件最後。 -## 現在的輸出(rspm 版,作為對照) +## 以前的輸出(rspm 版,作為對照) ```text App/ @@ -49,10 +47,10 @@ App/ └── CopyFiles// # copy phase 目的地 ``` -package 的 BUILD 不在這裡,而在 +package 的 BUILD 不在那裡,而在 `external/rules_swift_package_manager++swift_deps+swiftpkg_/`。 -## 新的輸出(自己產生) +## 輸出 ```text App/ @@ -102,7 +100,7 @@ manifest 所在的位置,就地讀取。 的 revision 釘住:那是 hermetic 的,但又把 external repo 帶回來,還會重抓一份 Bazel 手上已經有的原始碼。 -### Label 命名:facade +### Label 命名 所有 package product——遠端或本地——在 `Targets/*/BUILD` 裡都是同一個形狀: @@ -111,22 +109,12 @@ Bazel 手上已經有的原始碼。 | 遠端 package 的 product | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `//Packages/SFSafeSymbols:SFSafeSymbols` | | 本地 package 的 product | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | -rspm 模式下 `Packages//BUILD` 是一層 alias,指向目前實作它的東西: - -```python -alias( - name = "SFSafeSymbols", - actual = "@swiftpkg_sfsafesymbols//:SFSafeSymbols", - visibility = ["//visibility:public"], -) -``` - 目錄名取人看得懂的 package 名(remote 用 URL 最後一段去掉 `.git`,local 用 目錄名),所以 `//Packages/GRMustache.swift:Mustache` 這種帶點的名字也成立。 -意義:**換掉 SwiftPM 實作只動 `Packages/` 底下的檔案**。把 alias 換成規則本體時, -沒有任何 target 的 `deps` 需要改;要回退,把 alias 指回 rspm 即可。測試也不再 -釘 rspm 的 repo 命名規則。 +不論規則是誰產生的,product 就是這個 label——這也是這次替換只動 `Packages/` 的 +原因:label 形狀先落地(當時是指向 rspm 的 alias),之後換成規則本體,沒有任何 +target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生的。 ## SwiftPM 概念 → 產出的規則 @@ -250,11 +238,12 @@ target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: ## 階段 1 的結果(已量測) -`--spm native` 會為純 Swift 的 package target 產生規則。還不支援的種類會略過 +這個階段只產生純 Swift 的 package target,由一個 flag 切換,預設仍是 rspm。還不 +支援的種類會略過 並印警告,依賴它的 target 也一起略過:一個少了它要連結的 target 的 library, 比根本不存在更糟。 -7 個原本綠燈的 macOS app 在 native 模式下跑 `bazel build //...`: +7 個原本綠燈的 macOS app 跑 `bazel build //...`: | app | 結果 | 卡住的 target 種類 | |---|---|---| @@ -274,7 +263,7 @@ target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: 語料裡 package 會用到的每一種 target 都會產生了:C 系、帶 resource、binary、 system library,加上原本的 Swift。 -native 模式下跑 `bazel build //...`,再啟動 app: +跑 `bazel build //...`,再啟動 app: | app | 結果 | |---|---| @@ -292,7 +281,7 @@ compiler 與 SDK,一個是上游 manifest。 package 會宣告自己支援的平台版本,SwiftPM 編它的 target 時取「自己的 floor 和 使用端的 floor 之中較高的那個」。bazelize 一律用專案的 deployment target 編所有 -package target,而這正是 rspm 依賴被釘在 1.15.0 的原因——之後的版本會把每個 +package target,而這正是 rspm 依賴當時被釘在 1.15.0 的原因——之後的版本會把每個 target 轉場到它自己的 floor,然後在依賴宣告更高版本時 analysis 失敗。 所以 package 要求比專案高時就會編不過,錯誤是那些新 API 的 availability。UTM 就是 @@ -310,17 +299,17 @@ target 轉場到它自己的 floor,然後在依賴宣告更高版本時 analys |---|---|---| | 0 ✅ | 量測語料 | 見上 | | 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | -| 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由 `--spm native` 切換,預設仍 rspm | 58 個 package 能單獨建起來 | +| 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由一個 flag 切換,預設仍 rspm | 58 個 package 能單獨建起來 | | 2 ✅ | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`/module map)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 7 個綠燈 app 建得起來也跑得起來;另外五個的 package 全部建得起來 | | 3 | macro、會產生原始碼的 build tool plugin、逐 target 的 platform floor | 語料外的需求出現時再做 | -| 4 | 預設切換,移除 rspm 依賴、`Patches/` 與版本守門 | 全部 | +| 4 ✅ | rspm 依賴、`Patches/`、版本守門與模式 flag 全部移除 | 7 個綠燈 app 建得起來也跑得起來 | -階段 1–3 期間 rspm 與自製產生器**不混用**:同一個 workspace 只走其中一條,由 -flag 決定;混用會產生兩張依賴圖。 +階段 4 是把另一條路整個移除,而不是留一個 flag:兩條路就是兩張依賴圖,而語料裡 +每個 app 用自製產生器的結果都不比 rspm 差。 ## 待決事項 1. `Package.swift` 是否還需要出現在產物裡?只有 `swift package resolve` 需要它, 可以改成只在更新 pin 時才產生。 2. registry package(`.package(id:)`)階段幾支援?目前語料沒有。 -3. 階段 1–4 期間,上游 rspm PR 還要不要送?patch 很小、對別人也有用,我建議要。 +3. 上游 rspm PR 還要不要送?那 4 個 patch 很小,對還在用 rspm 的人也有用。 From 94464cce71c2829d2ae9b5752d328b02bdb5cbc2 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 21:18:11 +0800 Subject: [PATCH 125/173] Drop the resolve and tidy steps from the fixture and CI Resolution runs inside bazelize now, and there is no module extension whose repository list `bazel mod tidy` would keep in step. --- .github/workflows/swift.yml | 10 ---------- fixture/iOS/Makefile | 6 ------ 2 files changed, 16 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index de21f99..cf8336f 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -115,16 +115,6 @@ jobs: run: | ../../bazelize --project Example.xcodeproj --output App - - name: Resolve SPM Deps - working-directory: fixture/iOS/App - run: | - swift package resolve - - - name: Update SPM Deps - working-directory: fixture/iOS/App - run: | - bazel mod tidy - - name: Build Application working-directory: fixture/iOS/App run: | diff --git a/fixture/iOS/Makefile b/fixture/iOS/Makefile index d94f60f..06bd98a 100644 --- a/fixture/iOS/Makefile +++ b/fixture/iOS/Makefile @@ -15,8 +15,6 @@ TESTS = \ .PHONY: bazelize bazelize: @$(BAZELIZE) --project Example.xcodeproj --output $(OUTPUT) - cd $(OUTPUT) && swift package resolve - cd $(OUTPUT) && bazel mod tidy .PHONY: build build: bazelize @@ -38,10 +36,6 @@ test: uitest: cd $(OUTPUT) && bazel test $(SIMULATOR_FLAGS) //Targets/ExampleUITests -.PHONY: updatePkg -updatePkg: - cd $(OUTPUT) && bazel mod tidy - .PHONY: clear clear: @$(BAZELIZE) --project Example.xcodeproj --output $(OUTPUT) --clear From 6d47e439c8dc3684f4b71d11ab33d54491d92e2b Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 22:40:09 +0800 Subject: [PATCH 126/173] Compile a package's shaders against its own headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.metal` file is a resource SwiftPM compiles into the target's Metal library, and it includes the target's headers like any other source. The bundler treats a bundled header as a Metal header rather than copying it, so the headers belong in the same resource group — without them the shader compile cannot find what it includes. --- Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index 15951d2..30c260d 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -57,6 +57,16 @@ extension SwiftPM.Generator { + matching(Self.discoveredResources(prefix: prefix), files) structured = matching(structured, files) + /// A shader compiles like any other source: it includes the target's + /// headers, so they belong to the same resource group. The bundler treats a + /// bundled header as a Metal header and compiles it into the library + /// instead of copying it. + if resources.contains(where: { $0.hasSuffix(".metal") }) { + resources += matching( + SwiftPM.Generator.headerExtensions.map { "\(prefix)/**/*.\($0)" }, + relativeFiles(of: target, in: package, prefix: prefix, excluding: false)) + } + /// A declared resource that is not on disk leaves nothing to bundle, and a /// bundle rule without resources is an empty bundle. guard !resources.isEmpty || !structured.isEmpty else { return nil } From 06ac102ec5659cfc0ecf859e48be5827b000021f Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 22:40:09 +0800 Subject: [PATCH 127/173] Reach a C-family package target's module through its headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module map was handed to consumers as a compiler flag, which meant knowing the whole graph's maps up front and could not reach a project target at all: UTM's Objective-C `@import`s a package module and never found it. clang looks for `module.modulemap` in the directory a header was found in, so the map belongs next to the headers — and a checkout is not ours to write into. The public headers are therefore linked into a generated interface directory beside the map, the way an Xcode target's flattened header tree works, and that directory is the target's header search path. Every consumer then resolves the module through the include path alone: Swift or C-family, same package, another package, or an Xcode target. The map travels with the headers as a textual header so it is staged where the include path points. The target's own directory is no longer a header search path: clang would find a module map a package keeps outside its public headers on its own, and a second map for the same module fails the build. --- Sources/BazelRules/Rules+Builtin.swift | 9 -- .../BazelizeKit/SwiftPM/SwiftPM+Clang.swift | 143 ++++++++++++------ .../SwiftPM/SwiftPM+Generator.swift | 140 +---------------- 3 files changed, 96 insertions(+), 196 deletions(-) diff --git a/Sources/BazelRules/Rules+Builtin.swift b/Sources/BazelRules/Rules+Builtin.swift index 815eab4..86d401a 100644 --- a/Sources/BazelRules/Rules+Builtin.swift +++ b/Sources/BazelRules/Rules+Builtin.swift @@ -76,15 +76,6 @@ extension Rules.Builtin.Call { } } - /// Makes files of a package usable by another one. - /// - /// Reference: [Bazel `exports_files`](https://bazel.build/reference/be/functions#exports_files) - public static func exports_files(_ paths: [String]) -> Starlark.Statement.Call { - .init("exports_files") { - .positional(.array(paths.map { .string($0) })) - } - } - public static func alias( name: String, actual: Starlark.Label, diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift index 936451c..eb26a11 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift @@ -26,7 +26,6 @@ extension SwiftPM.Generator { guard let directory = sourceDirectory(of: target, in: package) else { return } let module = Self.moduleName(target.name) - let imported = moduleMaps(of: target, in: package) let extensions = extensions(of: target, in: package) let headers = publicHeaders(of: target, in: directory) let compiled = Self.compileExtensions.filter { extensions.contains($0) } @@ -34,24 +33,24 @@ extension SwiftPM.Generator { /// directory that a header search path still points into. let files = relativeFiles(of: target, in: package, prefix: prefix, excluding: false) - /// The hint is what names the module: without it the module is named after - /// the label, and a Swift `import` of the target's own name fails. A module - /// map the package wrote itself replaces the generated one, because it is - /// the interface the package intends. + /// The module map is what names the module: without one the name comes + /// from the label, and neither a Swift `import` nor a C-family `@import` of + /// the target's own name resolves. A map the package wrote itself is kept, + /// because it is the interface the package intends. let name = ruleName(of: target.name, in: package) let hint = "\(name)_interop" let headerPrefix = headers.map { Self.path(prefix, $0) } - let moduleMap = try? write( - moduleMapOf: target, - in: package, - headers: headerPrefix, + let interface = try? mirror( + headersOf: target, + at: headers.map { directory + $0 }, + module: module, root: root) builder.load(loadableRule: Rules.Swift.swift_interop_hint) builder.call( Rules.Swift.Call.swift_interop_hint( name: hint, - module_map: moduleMap.map { .named($0) }, + module_map: interface.map { .named("\($0)/module.modulemap") }, module_name: module)) builder.load(loadableRule: Rules.Objc.objc_library) @@ -72,9 +71,11 @@ extension SwiftPM.Generator { + (resources?.accessors ?? []), exclude: excludedClang(target, prefix: prefix) + (headerPrefix.map { $0 == prefix ? [] : ["\($0)/**"] } ?? [])), - hdrs: headerPrefix + hdrs: interface .map { path in - matching(Self.headerExtensions.map { "\(path)/**/*.\($0)" }, files) + matching( + Self.headerExtensions.map { "\(path)/**/*.\($0)" }, + Self.relativeFiles(under: path, in: root)) }? .nonEmpty .map { Starlark.glob($0) }, @@ -91,47 +92,93 @@ extension SwiftPM.Generator { module: module, resources: resources).nonEmpty, enable_modules: true, - includes: includes(of: target, prefix: prefix, headers: headers).nonEmpty, + includes: includes( + of: target, + prefix: prefix, + interface: interface).nonEmpty, linkopts: linkopts(of: target).nonEmpty, /// The module a dependent's `@import` names: the package target's /// own name, not the one Bazel derives from the label. module_name: module, tags: Self.manual, - /// A dependency's module map is what makes its `@import` resolve; - /// a C-family consumer, unlike a Swift one, gets none from the - /// rules. - textual_hdrs: imported.nonEmpty.map { maps in - .build { maps.map { Starlark.Label.named($0.label) } } + /// The map travels with the headers: it is on the include path of + /// everything that depends on the target, and clang has to find the + /// file there. + textual_hdrs: interface.map { path in + .build { [Starlark.Label.named("\(path)/module.modulemap")] } }, visibility: .public)) } - /// The target's own module map: the one the package ships, or one written - /// here over its public headers. + /// The files of a generated directory, named the way a glob pattern is. + private static func relativeFiles(under directory: String, in root: Path) -> [String] { + let base = root.normalize().string + + return SwiftPM.Generator.walk(root + directory).compactMap { file in + let path = file.normalize().string + guard path.hasPrefix(base) else { return nil } + return String(path.dropFirst(base.count)).trimmingCharacters(in: ["/"]) + } + } + + /// The target's public interface: its headers and the module map, in one + /// directory of our own. /// - /// SwiftPM writes one for a clang target that ships none, and the map is - /// what both a Swift `import` and a C-family `@import` of this target - /// resolve through. - private func write( - moduleMapOf target: SwiftPM.PackageTarget, - in package: SwiftPM.Package, - headers: String?, + /// clang looks for `module.modulemap` in the directory a header was found in, + /// so the map has to sit next to the headers — and the checkout is not ours to + /// write into. The headers are therefore linked into a generated directory + /// beside the map, the way an Xcode target's flattened header tree works. Every + /// consumer then resolves the module through a header search path alone: a + /// Swift `import`, a C-family `@import`, from this package or any other. + private func mirror( + headersOf target: SwiftPM.PackageTarget, + at headers: Path?, + module: String, root: Path) throws -> String? { - guard let map = module(of: target.name, in: package) else { return nil } + guard let headers, headers.isDirectory else { return nil } + + let relative = "Generated/\(target.name)Interface" + let interface = root + relative + if interface.exists || interface.isSymlink { + try? interface.delete() + } + try interface.mkpath() - let relative = map.label.split(separator: ":").last.map(String.init) ?? "" - guard relative.hasPrefix("Generated/") else { return relative } - guard let headers else { return nil } + let files = SwiftPM.Generator.walk(headers) + let base = headers.normalize().string + var shipped: Path? - try (root + "Generated").mkpath() - try (root + relative).write(""" - module \(Self.moduleName(target.name)) { - umbrella "../\(headers)" - export * + for file in files { + let path = file.normalize().string + guard path.hasPrefix(base) else { continue } + + let name = String(path.dropFirst(base.count)).trimmingCharacters(in: ["/"]) + if name == "module.modulemap" { + shipped = file + continue + } + + let link = interface + name + try link.parent().mkpath() + try link.symlink(file) } - """) + /// A map the package ships is its intended interface; without one the + /// module is every header in the directory, which is what SwiftPM + /// generates for a clang target too. + let map = interface + "module.modulemap" + if let shipped { + try map.symlink(shipped) + } else { + try map.write(""" + module \(module) { + umbrella "." + export * + } + + """) + } return relative } @@ -173,17 +220,20 @@ extension SwiftPM.Generator { Path("\(prefix)/\(path)").normalize().string } - /// What a header lookup can reach: the public headers, the target directory - /// itself — a target's own sources include each other by relative path — and - /// whatever `headerSearchPath` adds. + /// What a header lookup can reach: the target's interface directory and + /// whatever `headerSearchPath` adds, which is the shape SwiftPM passes. + /// + /// Not the target directory itself: a module map sitting there would be found + /// by clang on its own, and a package that ships one outside its public + /// headers would end up with two maps for the same module. private func includes( of target: SwiftPM.PackageTarget, prefix: String, - headers: String?) -> [String] + interface: String?) -> [String] { - var paths = [prefix] - if let headers { - paths.append(Self.path(prefix, headers)) + var paths: [String] = [] + if let interface { + paths.append(interface) } for setting in target.settings @@ -205,11 +255,6 @@ extension SwiftPM.Generator { { var copts = ["-fmodule-name=\(module)"] + clangDefines(of: target) - /// The module map of each dependency, so its `@import` resolves. - for map in moduleMaps(of: target, in: package) { - copts.append("-fmodule-map-file=\(map.path)") - } - /// SwiftPM force-includes the accessor, so a source reaches its bundle /// without importing anything. if let header = resources?.header { diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index f7fcba0..c0b153a 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -22,9 +22,6 @@ extension SwiftPM { let output: Path let workspace: Workspace - /// The clang modules of the whole graph, so a C-family target can be handed - /// the module maps of what it imports. - private var modules: [String: Module] = [:] private var kinds: [String: [String: TargetKind]] = [:] init(output: Path, workspace: Workspace) { @@ -32,21 +29,9 @@ extension SwiftPM { self.workspace = workspace } - /// A clang module a dependent can `@import`. - struct Module { - /// The module map as a compile action sees it. - let path: String - /// The label that makes the map an input of that action. - let label: String - } - func generate() throws { - /// The modules come first: a C-family target needs the module maps of - /// its dependencies, which may be in a package generated later. for package in workspace.packages { - let supported = try supportedTargets(of: package) - kinds[package.directory] = supported - register(modulesOf: package, supported: supported) + kinds[package.directory] = try supportedTargets(of: package) } for package in workspace.packages { @@ -54,117 +39,6 @@ extension SwiftPM { } } - /// Where each C-family target's module map is, or will be written. - private func register(modulesOf package: Package, supported: [String: TargetKind]) { - for target in package.manifest.targets { - guard let kind = supported[target.name] else { continue } - guard let directory = sourceDirectory(of: target, in: package) else { continue } - - let prefix = "\(Self.sourcesRoot)/\(target.name)" - let relative: String? - - switch kind { - case .clang: - let headers = publicHeaders(of: target, in: directory) - let shipped = headers.map { "\(prefix)/\($0)/module.modulemap" } - - if let shipped, (directory + (headers ?? "") + "module.modulemap").exists { - relative = shipped - } else { - /// Written by the generator, the way SwiftPM writes one for - /// a clang target that ships none. - relative = headers == nil ? nil : "Generated/\(target.name).modulemap" - } - case .system: - relative = "\(prefix)/module.modulemap" - case .swift, .binary, .unsupported: - relative = nil - } - - guard let relative else { continue } - let directoryLabel = "//\(PluginSwiftPM.packagesDirectory)/\(package.directory)" - modules["\(package.directory)/\(target.name)"] = Module( - path: "\(PluginSwiftPM.packagesDirectory)/\(package.directory)/\(relative)", - label: "\(directoryLabel):\(relative)") - } - } - - /// The module map of a target, if it has one. - func module(of target: String, in package: Package) -> Module? { - modules["\(package.directory)/\(target)"] - } - - /// The module maps a C-family target compiles against: its dependencies', - /// and theirs, because a module map can import another module. - func moduleMaps(of target: PackageTarget, in package: Package) -> [Module] { - var found: [String: Module] = [:] - var seen: Set = ["\(package.directory)/\(target.name)"] - var queue: [(Package, PackageTarget)] = [(package, target)] - - while let (owner, current) = queue.popLast() { - for (nextPackage, next) in dependencies(of: current, in: owner) { - let key = "\(nextPackage.directory)/\(next.name)" - guard seen.insert(key).inserted else { continue } - - if let module = modules[key] { found[key] = module } - queue.append((nextPackage, next)) - } - } - - return found.keys.sorted().compactMap { found[$0] } - } - - /// The targets a target depends on, in the packages that own them. - private func dependencies( - of target: PackageTarget, - in package: Package) -> [(Package, PackageTarget)] - { - let targetsByName = Dictionary( - package.manifest.targets.map { ($0.name, $0) }, - uniquingKeysWith: { first, _ in first }) - - return target.dependencies.flatMap { dependency -> [(Package, PackageTarget)] in - switch dependency.kind { - case .target(let name): - return targetsByName[name].map { [(package, $0)] } ?? [] - case .byName(let name): - if let local = targetsByName[name] { return [(package, local)] } - if let product = package.manifest.products.first(where: { $0.name == name }) { - return targets(of: product, in: package) - } - return targets(ofProduct: name, package: nil, from: package) - case .product(let name, let packageName): - return targets(ofProduct: name, package: packageName, from: package) - } - } - } - - private func targets( - ofProduct product: String, - package name: String?, - from package: Package) -> [(Package, PackageTarget)] - { - guard let owner = self.package(ofProduct: product, package: name, from: package) else { - return [] - } - guard let declared = owner.manifest.products.first(where: { $0.name == product }) else { - return [] - } - - return targets(of: declared, in: owner) - } - - private func targets( - of product: PackageProduct, - in package: Package) -> [(Package, PackageTarget)] - { - product.targets.compactMap { name in - package.manifest.targets - .first { $0.name == name } - .map { (package, $0) } - } - } - // MARK: Private private var packagesRoot: Path { @@ -236,16 +110,6 @@ extension SwiftPM { build(product, emitted: Set(emitted.keys), package: package, builder: builder) } - /// A C-family target in another package compiles against these maps, - /// so they have to be readable from there. - let maps = emitted.keys - .compactMap { module(of: $0, in: package) } - .map(\.label) - .compactMap { $0.split(separator: ":").last.map(String.init) } - if let maps = maps.nonEmpty { - builder.call(Rules.Builtin.Call.exports_files(maps.sorted())) - } - try (root + "BUILD").write(builder.build()) } @@ -353,7 +217,7 @@ extension SwiftPM { break } - guard let directory = sourceDirectory(of: target, in: package) else { + guard sourceDirectory(of: target, in: package) != nil else { return .unsupported("no source directory") } From 320ffccad2d02816d62a3a33c4f0d04bd5ad10a7 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 22:40:09 +0800 Subject: [PATCH 128/173] Link and compile only what a build file's platform filter allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xcode can restrict a build file to some platforms, and the entry is then invisible to every other one. UTM uses it for visionOS: a keyboard package and a handful of sources belong to the visionOS build alone. Ignoring the filter linked a visionOS-only package into the iOS build and put two files of the same name in one module. The filter applies to every phase — sources, headers, resources, copy files and frameworks — and to the target's own list of package products, because that list carries no filter of its own while the build file does. Which platform a target builds for now has one implementation: `SDKROOT`, then `SUPPORTED_PLATFORMS`, then the device family, then whichever deployment target is set. The loader needs the same answer the codegen did. --- .../Codegen/Codegen+Platform.swift | 30 +-------- .../Xcode2/Loader/XCode+ProjectLoader.swift | 7 ++- .../Xcode2/Loader/XCode+TargetLoader.swift | 62 ++++++++++++++++++- .../Config/XCode+BuildSettings+Platform.swift | 31 ++++++++++ 4 files changed, 97 insertions(+), 33 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift index 00ab188..6ad1fca 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift @@ -7,35 +7,7 @@ extension Target { /// implied by the deployment target — so the rule choice cannot depend on the /// setting being present. var platformSDK: SDK? { - if let sdk = prefer(\.platform.sdk), sdk != .auto { - return sdk - } - - /// `SDKROOT = auto` means the target is multiplatform: `SUPPORTED_PLATFORMS` - /// narrows it down, and failing that an iPhone device family does. - if let platform = prefer(\.platform.supportedPlatforms)?.first, platform != .auto { - return platform - } - if prefer(\.platform.deviceFamily)?.contains(.iphone) == true { - return .iOS - } - - /// No usable `SDKROOT`: the deployment targets still say which platform the - /// target builds for. - if prefer(\.platform.iOS) != nil { - return .iOS - } - if prefer(\.platform.macOS) != nil { - return .macOS - } - if prefer(\.platform.tvOS) != nil { - return .tvOS - } - if prefer(\.platform.watchOS) != nil { - return .watchOS - } - - return prefer(\.platform.sdk) + prefer(\.platform.resolvedSDK) } /// The device families a bundle rule is built for. diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift index ec5d3c1..462cc82 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode2/Loader/XCode+ProjectLoader.swift @@ -32,9 +32,14 @@ final class ProjectLoader { path.parent() } + /// `PROJECT_NAME`, which build settings reference as freely as any other. + var name: String { + rootProject?.name ?? path.lastComponentWithoutExtension + } + func model() throws -> XCode.Project { XCode.Project( - name: rootProject?.name ?? path.lastComponentWithoutExtension, + name: name, workspacePath: workspacePath.string, projectPath: path.string, preferConfig: preferConfig, diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index cb02226..8ee07db 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -22,6 +22,7 @@ struct TargetLoader { mergedConfig = configList.merge(defaultConfigList).mapValues { settings in settings.with(overrides: [ "TARGET_NAME": native.name, + "PROJECT_NAME": project.name, "SRCROOT": workspace, "SOURCE_ROOT": workspace, "PROJECT_DIR": workspace, @@ -183,8 +184,11 @@ struct TargetLoader { /// Xcode records a linked package product either on the target or on the /// build file in the Frameworks phase, depending on how it was added. - let productDependencies = (native.packageProductDependencies ?? []) + frameworkBuildFiles.compactMap { buildFile in + let excluded = filteredProductNames + let productDependencies = ((native.packageProductDependencies ?? []) + frameworkBuildFiles.compactMap { buildFile in buildFile.product + }).filter { product in + !excluded.contains(product.productName) } let packageProducts = unique(productDependencies) { $0.productName }.map { dependency in @@ -224,7 +228,7 @@ struct TargetLoader { } private var sourceBuildFiles: [PBXBuildFile] { - (try? native.sourcesBuildPhase()?.files) ?? [] + ((try? native.sourcesBuildPhase()?.files) ?? []).filter(links) } private var headerBuildFiles: [PBXBuildFile] { @@ -232,21 +236,73 @@ struct TargetLoader { .compactMap { $0 as? PBXHeadersBuildPhase } .compactMap(\.files) .flatMap { $0 } + .filter(links) } private var resourceBuildFiles: [PBXBuildFile] { - (try? native.resourcesBuildPhase()?.files) ?? [] + ((try? native.resourcesBuildPhase()?.files) ?? []).filter(links) } private var frameworkBuildFiles: [PBXBuildFile] { + allFrameworkBuildFiles.filter(links) + } + + private var allFrameworkBuildFiles: [PBXBuildFile] { (try? native.frameworksBuildPhase()?.files) ?? [] } + /// Package products the target links only on another platform. + /// + /// The filter is on the build file, while the product is also listed on the + /// target itself, so the target's own list has to be read through the filter. + private var filteredProductNames: Set { + let linked = Set(frameworkBuildFiles.compactMap { $0.product?.productName }) + let filtered = allFrameworkBuildFiles + .filter { !links($0) } + .compactMap { $0.product?.productName } + + return Set(filtered).subtracting(linked) + } + + /// Whether the target links a build file at all. + /// + /// Xcode can restrict a linked framework or package product to some platforms + /// — UTM links a visionOS keyboard only when building for visionOS — and the + /// entry is invisible to every other platform, sources and all. + private func links(_ buildFile: PBXBuildFile) -> Bool { + let filters = (buildFile.platformFilters ?? []) + [buildFile.platformFilter].compactMap { $0 } + guard !filters.isEmpty else { return true } + guard let platform = platformFilterName else { return true } + + return filters.contains { filter in + filter == platform || filter.hasPrefix("\(platform)-") + } + } + + /// The platform as a build file's filter names it. + private var platformFilterName: String? { + switch selectedConfig?.platform.resolvedSDK { + case .iOS: + return "ios" + case .macOS: + return "macos" + case .tvOS: + return "tvos" + case .watchOS: + return "watchos" + case .driverKit: + return "driverkit" + case .auto, .none: + return nil + } + } + private var copyBuildFiles: [PBXBuildFile] { native.buildPhases .compactMap { $0 as? PBXCopyFilesBuildPhase } .compactMap(\.files) .flatMap { $0 } + .filter(links) } private var synchronizedGroupFiles: [SynchronizedFile] { diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift index 5d76023..0fc1261 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift @@ -60,6 +60,37 @@ extension XCode.BuildSettings { ].compactMapValues { $0 } } + /// The platform the settings build for. + /// + /// `SDKROOT` is optional in a project file, and `auto` means the target is + /// multiplatform: `SUPPORTED_PLATFORMS` narrows it down, then the device + /// family, then whichever deployment target is set. + public var resolvedSDK: SDK? { + if let sdk, sdk != .auto { + return sdk + } + if let platform = supportedPlatforms.first(where: { $0 != .auto }) { + return platform + } + if deviceFamily.contains(.iphone) { + return .iOS + } + if iOS != nil { + return .iOS + } + if macOS != nil { + return .macOS + } + if tvOS != nil { + return .tvOS + } + if watchOS != nil { + return .watchOS + } + + return sdk + } + public var deviceFamily: [XCode.DeviceFamily] { XCode.DeviceFamily.parse(settings["TARGETED_DEVICE_FAMILY"]) } From 19d162238d3b9e0801c0edb6973bf53cd8f0a25e Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 22:40:09 +0800 Subject: [PATCH 129/173] Name a module the way the target's own sources import it The module name was the target name, while Xcode takes `PRODUCT_MODULE_NAME`, then `PRODUCT_NAME`, then the target name. UTM's `iOS` target builds a module called `UTM` and its Objective-C includes `UTM-Swift.h`; iina's xcconfig names the product `IINA` and its Objective-C includes `IINA-Swift.h`. Neither header exists when the module is named after the target. `PROJECT_NAME` joins the built-in settings for the same reason the others are there: UTM's product name is `$(PROJECT_NAME)`. --- .../Codegen/Language/Codegen+Library.swift | 18 +++++++++++++++++- .../XCode2Tests/RoadmapTreeBuilderTests.swift | 4 +++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index d5df663..75114d0 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -10,8 +10,24 @@ import Util import Starlark extension Target { + /// The Swift module name, which is also what the generated Objective-C + /// interop header is named after. + /// + /// `PRODUCT_MODULE_NAME` is the name a target's own sources import — UTM's + /// `iOS` target builds a module called `UTM`, and its Objective-C sources + /// include `UTM-Swift.h`. Without the setting Xcode falls back to the product + /// name, and then to the target name. var codegenModuleName: String { - name.replacingOccurrences(of: "-", with: "_") + let declared = prefer(\.metadata.moduleName) + ?? prefer(\.metadata.productName) + ?? name + + /// An unresolved reference is no name at all; a module name is an + /// identifier, so anything else becomes an underscore. + let resolved = declared.contains("$") || declared.isEmpty ? name : declared + return String(resolved.map { character in + character.isLetter || character.isNumber || character == "_" ? character : "_" + }) } func generateLibrary(_ builder: CodeBuilder, _ kit: Kit) { diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift index 3299aed..385fb10 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift @@ -165,7 +165,9 @@ struct RoadmapTreeBuilderTests { let appBuild = try String(contentsOfFile: (output + "Targets/iina/BUILD").string) #expect(appBuild.contains("mixed_language_library(")) #expect(appBuild.contains("name = \"iina_mixed\"")) - #expect(appBuild.contains("module_name = \"iina\"")) + /// `PRODUCT_NAME` comes from the target's xcconfig, and it is the module a + /// target's own sources import: iina's Objective-C includes `IINA-Swift.h`. + #expect(appBuild.contains("module_name = \"IINA\"")) #expect(appBuild.contains("app_icons = glob([")) #expect(appBuild.contains("Sources/iina/Assets.xcassets/AppIcon.appiconset/**")) #expect(appBuild.contains("sdk_frameworks = [")) From ee9c17ef3a44ca22a8f9ec65efbb21a5b0cc85fa Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 22:49:49 +0800 Subject: [PATCH 130/173] Say which platform version a package is compiled at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package's own `platforms:` declaration was read by nothing. SwiftPM takes the higher of that and what the consumer asks for; here every package target is compiled at the project's deployment target, because the version lives in the platform transition of the bundle rule that pulls the target in and a library rule has no version of its own. So the version is now decided the way SwiftPM decides it — the manifest's declaration, else the oldest version SwiftPM builds that platform for, else what the installed SDK reports through the `XCTest` it ships — and compared with the lowest deployment target among the project's own targets. A package that needs more is named, with both versions, because the failure otherwise surfaces as an availability error deep in someone else's source. --- Sources/BazelizeKit/Kit.swift | 61 +++++- .../SwiftPM/SwiftPM+Deployment.swift | 177 ++++++++++++++++++ .../SwiftPM/SwiftPM+Generator.swift | 27 ++- .../XCode2Tests/PackageDeploymentTests.swift | 74 ++++++++ 4 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift create mode 100644 Tests/XCode2Tests/PackageDeploymentTests.swift diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 20c5f38..6114a3b 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -55,6 +55,10 @@ public final class Kit { // MARK: Public + /// Notes the run has for the user, collected while the package rules were + /// generated. + private var packageTips: [String] = [] + public final func run(_: Path) async throws { defer { tips() } @@ -84,6 +88,10 @@ extension Kit { print(tip) } + packageTips.forEach { tip in + print("# Swift package\n\(tip)") + } + plugins.forEach { plugin in plugin.tip() } @@ -96,11 +104,62 @@ extension Kit { /// manifests instead of by `rules_swift_package_manager`. private final func generateSwiftPackages() async throws { let workspace = try await SwiftPM.loadWorkspace(output: outputRoot) - try SwiftPM.Generator(output: outputRoot, workspace: workspace).generate() + let deployment = await deployment() + + let generator = SwiftPM.Generator( + output: outputRoot, + workspace: workspace, + deployment: deployment) + try generator.generate() + packageTips = generator.unmetDeployment let count = workspace.packages.count Log.codeGenerate.info("Generate \(count, privacy: .public) Swift packages") } + + /// The versions a package's targets end up compiled at: the lowest deployment + /// target of the project's own targets, per platform, because that is the one + /// a package has to be buildable against. + /// + /// A platform no target of the project builds for cannot fail, so it is left + /// out. Where the project says nothing, the oldest version the installed SDK + /// can build for stands in — the same answer SwiftPM reads out of the SDK. + private final func deployment() async -> SwiftPM.Deployment { + var floors: [String: String] = [:] + var platforms: Set = [] + + for target in project.targets { + if let platform = target.platformSDK.flatMap(SwiftPM.Deployment.platform(of:)) { + platforms.insert(platform) + } + + let declared: [(String, String?)] = [ + ("macos", target.prefer(\.platform.macOS)), + ("ios", target.prefer(\.platform.iOS)), + ("tvos", target.prefer(\.platform.tvOS)), + ("watchos", target.prefer(\.platform.watchOS)), + ] + + for (platform, version) in declared { + guard let version, !version.isEmpty else { continue } + guard let floor = floors[platform] else { + floors[platform] = version + continue + } + if SwiftPM.Deployment.isNewer(floor, than: version) { + floors[platform] = version + } + } + } + + /// A platform the project builds for without saying which version: the + /// oldest the installed SDK can build is what Xcode would use. + for platform in platforms where floors[platform] == nil { + floors[platform] = await SwiftPM.Deployment.sdkFloor(platform: platform) + } + + return .init(project: floors) + } } // MARK: - Generate diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift new file mode 100644 index 0000000..b303fb7 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift @@ -0,0 +1,177 @@ +// +// SwiftPM+Deployment.swift +// +// +// The platform version a package's targets are compiled at. +// + +import Foundation +@preconcurrency import PathKit +import XCode2 +import Subprocess +import Util + +extension SwiftPM { + /// Which platform version each of a package's targets is compiled at. + /// + /// SwiftPM takes the higher of what the package declares and what the consumer + /// asks for. A package target here is compiled at the project's deployment + /// target, because the version lives in the platform transition of the bundle + /// rule that pulls the target in, and a library rule has no version of its + /// own. A package that requires more than the project does therefore fails to + /// compile, and saying so beats an availability error deep in someone else's + /// source. + struct Deployment: Sendable { + /// The project's deployment target per platform, keyed the way a manifest + /// names the platform. + let project: [String: String] + + /// What the package requires: its own declaration, or the oldest version + /// SwiftPM builds that platform for. + func required(_ package: Package, platform: String) -> String? { + if let declared = package.manifest.platforms.first(where: { $0.platformName == platform }) { + return declared.version + } + + return Self.oldest[platform] + } + + /// The platforms a package would be compiled for at a version the project + /// does not provide. + func unmet(_ package: Package) -> [(platform: String, required: String, project: String)] { + project.keys.sorted().compactMap { platform in + guard + let floor = project[platform], + let required = required(package, platform: platform), + Self.isNewer(required, than: floor) + else { + return nil + } + + return (platform, required, floor) + } + } + + /// SwiftPM's own floors, the versions it builds a platform for when a + /// package declares nothing. + /// + /// They follow the installed toolchain rather than a table of our own: + /// SwiftPM reads them out of the SDK when it can, and the table is what it + /// falls back to. + static let oldest: [String: String] = [ + "macos": "12.0", + "maccatalyst": "15.0", + "ios": "15.0", + "tvos": "15.0", + "watchos": "9.0", + "visionos": "1.0", + "driverkit": "21.0", + ] + + static func isNewer(_ version: String, than other: String) -> Bool { + let left = components(version) + let right = components(other) + + for index in 0 ..< max(left.count, right.count) { + let lhs = index < left.count ? left[index] : 0 + let rhs = index < right.count ? right[index] : 0 + if lhs != rhs { return lhs > rhs } + } + + return false + } + + private static func components(_ version: String) -> [Int] { + version.split(separator: ".").map { Int($0) ?? 0 } + } + } +} + +extension SwiftPM.Deployment { + /// The oldest version the installed SDK can build a platform for. + /// + /// This is how SwiftPM answers the question for a platform it has no floor + /// for: the deployment target of the `XCTest` the SDK ships is the oldest + /// version that SDK supports. A platform the toolchain does not have stays at + /// SwiftPM's own floor. + static func sdkFloor(platform: String) async -> String? { + guard let sdk = sdkName[platform] else { return nil } + + guard + let platformPath = try? await run("xcrun", ["--sdk", sdk, "--show-sdk-platform-path"]), + !platformPath.isEmpty + else { + return nil + } + + let binary = Path(platformPath.trimmingCharacters(in: .whitespacesAndNewlines)) + + "Developer/Library/Frameworks/XCTest.framework/XCTest" + guard binary.exists else { return nil } + + guard let build = try? await run("xcrun", ["vtool", "-show-build", binary.string]) else { + return nil + } + + /// `vtool` prints the load command as `platform IOS` followed by + /// `minos 15.0`. + var seen = false + for line in build.split(separator: "\n") { + let statement = line.trimmingCharacters(in: .whitespaces) + if statement.hasPrefix("platform ") { + seen = statement.hasSuffix(platformName[platform] ?? "") + continue + } + if seen, statement.hasPrefix("minos ") { + return String(statement.dropFirst("minos ".count)) + } + } + + return nil + } + + /// The name a manifest gives the platform an SDK builds for. + static func platform(of sdk: SDK) -> String? { + switch sdk { + case .iOS: + return "ios" + case .macOS: + return "macos" + case .tvOS: + return "tvos" + case .watchOS: + return "watchos" + case .driverKit: + return "driverkit" + case .auto: + return nil + } + } + + private static let sdkName: [String: String] = [ + "macos": "macosx", + "maccatalyst": "macosx", + "ios": "iphoneos", + "tvos": "appletvos", + "watchos": "watchos", + "visionos": "xros", + ] + + private static let platformName: [String: String] = [ + "macos": "MACOS", + "maccatalyst": "MACCATALYST", + "ios": "IOS", + "tvos": "TVOS", + "watchos": "WATCHOS", + "visionos": "XROS", + ] + + private static func run(_ executable: String, _ arguments: [String]) async throws -> String { + let result = try await Subprocess.run( + .name(executable), + arguments: Arguments(arguments), + output: .string(limit: 1024 * 1024)) + + guard result.terminationStatus.isSuccess else { return "" } + return result.standardOutput ?? "" + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index c0b153a..e7e1eea 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -22,16 +22,24 @@ extension SwiftPM { let output: Path let workspace: Workspace + let deployment: Deployment + private var kinds: [String: [String: TargetKind]] = [:] - init(output: Path, workspace: Workspace) { + /// What a caller tells the user about: the packages whose platform version + /// the project does not reach. + private(set) var unmetDeployment: [String] = [] + + init(output: Path, workspace: Workspace, deployment: Deployment) { self.output = output self.workspace = workspace + self.deployment = deployment } func generate() throws { for package in workspace.packages { kinds[package.directory] = try supportedTargets(of: package) + report(deploymentOf: package) } for package in workspace.packages { @@ -39,6 +47,23 @@ extension SwiftPM { } } + /// A package that declares a platform version the project does not reach is + /// compiled at the project's version anyway, and fails in whichever newer + /// API it uses. The reason is in the manifest, not in that error, so it is + /// said out loud. + private func report(deploymentOf package: Package) { + for unmet in deployment.unmet(package) { + let message = """ + \(package.directory) declares \(unmet.platform) \(unmet.required), \ + and the project builds \(unmet.platform) \(unmet.project): \ + the package is compiled at \(unmet.project) and may not support it. + """ + + Log.codeGenerate.warning("\(message, privacy: .public)") + unmetDeployment.append(message) + } + } + // MARK: Private private var packagesRoot: Path { diff --git a/Tests/XCode2Tests/PackageDeploymentTests.swift b/Tests/XCode2Tests/PackageDeploymentTests.swift new file mode 100644 index 0000000..f1e33f9 --- /dev/null +++ b/Tests/XCode2Tests/PackageDeploymentTests.swift @@ -0,0 +1,74 @@ +@testable import BazelizeKit +import Foundation +import Testing + +/// What version a package's targets end up compiled at, and when that is a +/// problem worth telling the user about. +struct PackageDeploymentTests { + private func manifest(platforms: [(String, String)]) -> SwiftPM.Manifest { + let entries = platforms.map { platform, version in + """ + {"platformName": "\(platform)", "version": "\(version)"} + """ + }.joined(separator: ",") + + let json = """ + {"name": "Package", "platforms": [\(entries)], "products": [], "targets": [], "dependencies": []} + """ + + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(SwiftPM.Manifest.self, from: Data(json.utf8)) + } + + private func package(platforms: [(String, String)]) -> SwiftPM.Package { + .init( + directory: "Example", + root: "/tmp/Example", + manifest: manifest(platforms: platforms), + isLocal: false) + } + + @Test + func declaredVersionWins() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0"]) + + #expect(deployment.required(package(platforms: [("ios", "16.0")]), platform: "ios") == "16.0") + } + + @Test + func undeclaredPlatformFallsBackToWhatSwiftPMBuilds() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0"]) + let declared = package(platforms: [("macos", "13.0")]) + + /// The package says nothing about iOS, so SwiftPM's own floor for the + /// platform is what it would be built at. + #expect(deployment.required(declared, platform: "ios") == SwiftPM.Deployment.oldest["ios"]) + #expect(deployment.required(declared, platform: "macos") == "13.0") + } + + @Test + func aPackageAskingForMoreThanTheProjectIsReported() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0", "macos": "13.0"]) + let unmet = deployment.unmet(package(platforms: [("ios", "16.0"), ("macos", "12.0")])) + + #expect(unmet.count == 1) + #expect(unmet.first?.platform == "ios") + #expect(unmet.first?.required == "16.0") + #expect(unmet.first?.project == "14.0") + } + + @Test + func aPackageWithinTheProjectsReachIsNotReported() { + let deployment = SwiftPM.Deployment(project: ["ios": "18.5"]) + + #expect(deployment.unmet(package(platforms: [("ios", "16.0")])).isEmpty) + } + + @Test + func versionsCompareByComponent() { + #expect(SwiftPM.Deployment.isNewer("10.15", than: "10.9")) + #expect(SwiftPM.Deployment.isNewer("16.0", than: "15.4")) + #expect(!SwiftPM.Deployment.isNewer("14.0", than: "14")) + #expect(!SwiftPM.Deployment.isNewer("13.0", than: "14.0")) + } +} From 632aa73b237b27d4b29719df7fc20e72d9cf4ca5 Mon Sep 17 00:00:00 2001 From: yume190 Date: Wed, 16 Sep 2026 22:50:46 +0800 Subject: [PATCH 131/173] Describe how a package's platform version is decided --- docs/SPM.md | 52 +++++++++++++++++++++++++++++++++----------------- docs/SPM_ZH.md | 43 +++++++++++++++++++++++++++-------------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/docs/SPM.md b/docs/SPM.md index aa6f5e4..20e1a2a 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -139,7 +139,7 @@ No test pins how a package's rules are produced either. | library product, one target | `alias` | | library product, several targets | `swift_library_group` | | `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | -| auto-discovered resources (xib/xcassets/metal/xcstrings) | as above; `.metal` enters the resource group with that target's headers | +| auto-discovered resources (xib/xcassets/metal/xcstrings/`.lproj`) | as above; a `.metal` file takes the target's headers into the resource group, because the bundler compiles them as Metal headers | | `defines` | `-D` flags, not the `defines` attribute, which would propagate to every dependent | | `headerSearchPath` | `includes`, and the headers there stay inputs even when `exclude` drops the directory | | `linkedLibrary` / `linkedFramework` | `linkopts` | @@ -161,12 +161,16 @@ package target is built through the bundle rule that transitions it to a platform, so a wildcard pattern must not compile an iOS-only package for the host. -A module map is what names a C-family module. Without one the module is named +A C-family target's public headers are linked into a generated interface +directory with its module map beside them, and that directory is the header +search path. clang looks for `module.modulemap` in the directory a header was +found in, so the map has to sit next to the headers, and the checkout is not +ours to write into. A module map is what names a C-family module. Without one the module is named after the label and the target cannot be imported by the name its own sources use; a module map the package ships is preferred, because it is the interface -the package intends. The map of every dependency is passed to the compiler as -well: a Swift consumer is handed a module by the rules, a C-family one -`@import`ing a sibling target is not. +the package intends. Reaching it through a header search path is what lets every +consumer resolve the module — Swift or C-family, this package, another one, or an +Xcode target — since only a Swift consumer is handed a module by the rules. A package's sources are linked one target at a time, so the rest of a checkout stays out of the build, and `.bazelignore` keeps SwiftPM's working directory @@ -293,27 +297,39 @@ resource-carrying, binary and system-library targets, next to the Swift ones. | CodeEdit | every package builds; the app's own sources are rejected by Swift 6.4 | | CotEditor | every package builds; the app's own sources are rejected by Swift 6.4 | | IceCubesApp | every package builds; the app's own sources collide with the iOS 27 SDK (`SwiftUI.Document`) | -| UTM | see the platform floor below | +| UTM | its packages build; the app needs a prebuilt sysroot, and one source imports a header by basename through Xcode's project headermap | | PlayCover | `swift package resolve` fails on the package's own manifest | The four that do not build fail in code that is not generated here: three in their own sources against a newer compiler and SDK, one in a package manifest upstream. -### Known limitation: platform floors +### Platform versions A package declares the platform versions it supports, and SwiftPM compiles each -of its targets at the higher of that floor and the consumer's. Bazelize compiles -every package target at the project's deployment target, which is what pinned -the rspm dependency at 1.15.0 — later versions transition each target to its -own floor and then fail analysis when a dependency declares a higher one. - -A package that requires more than the project does therefore fails to compile, -with availability errors naming the newer API. UTM is that case: an iOS 14 -project consuming packages that declare iOS 16 and iOS 18. - -Honouring the floor per target needs a transition that raises the deployment -target without splitting the graph, which is stage 3 work. +of its targets at the higher of that and the consumer's. Bazelize compiles every +package target at the project's deployment target: the version lives in the +platform transition of the bundle rule that pulls the target in, and a library +rule has no version of its own. Honouring it per target is what pinned the rspm +dependency at 1.15.0 — later versions transition each target to its own floor +and then fail analysis when a dependency declares a higher one. + +The version a package asks for is still decided, the way SwiftPM decides it: + +1. what the manifest's `platforms:` declares for that platform; +2. else the oldest version SwiftPM builds that platform for — macOS 12, iOS and + tvOS 15, watchOS 9, visionOS 1, Mac Catalyst 15, DriverKit 21; +3. else, for a platform the project builds without naming a version, what the + installed SDK reports: the deployment target of the `XCTest` it ships, which + is how SwiftPM asks the same question. + +That version is compared with the lowest deployment target among the project's +own targets. A package that needs more is named at the end of the run, with both +versions, because the failure otherwise surfaces as an availability error deep +in someone else's source. + +Compiling such a package at the version it asks for needs a transition that +raises the deployment target without splitting the graph, which is stage 3 work. ## Stages and exit criteria diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 3a0a342..4852303 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -128,7 +128,7 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 | library product,單一 target | `alias` | | library product,多個 target | `swift_library_group` | | `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | -| auto-discovered resources(xib/xcassets/metal/xcstrings) | 同上,`.metal` 連同該 target 的 header 一起進 resource group | +| auto-discovered resources(xib/xcassets/metal/xcstrings/`.lproj`) | 同上;有 `.metal` 時該 target 的 header 也一起進 resource group,因為 bundler 會把它們當 Metal header 編 | | `defines` | `-D` flag,不用 `defines` 屬性——那會往每個下游傳 | | `headerSearchPath` | `includes`,而且該目錄被 `exclude` 丟掉時 header 仍然留作輸入 | | `linkedLibrary` / `linkedFramework` | `linkopts` | @@ -148,10 +148,14 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 `manual`:package target 是透過會轉場到某個平台的 bundle 規則建起來的,wildcard pattern 不該把 iOS-only 的 package 拿去編 host。 +C 系 target 的公開 header 會連結到一個產生出來的 interface 目錄,module map 就放在 +同一層,而那個目錄就是 header search path。clang 只在「找到 header 的那個目錄」找 +`module.modulemap`,所以 map 必須和 header 同層,而 checkout 不是我們能寫的地方。 + module map 決定 C 系模組叫什麼。沒有它,模組名會由 label 推導出來,原始碼就沒辦法 -用自己寫的名字 import;package 自己帶的 map 優先,因為那是它想提供的介面。每個依賴 -的 map 也會一起交給 compiler:Swift 端的模組是規則給的,C 系端 `@import` 兄弟 -target 則沒人給。 +用自己寫的名字 import;package 自己帶的 map 優先,因為那是它想提供的介面。用 header +search path 找得到,是每個消費端都能解到模組的原因——Swift 或 C 系、同一個 package、 +別的 package、或 Xcode target 都一樣,因為只有 Swift 端的模組是規則給的。 package 的原始碼是一個 target 一條 symlink,checkout 其餘部分不會進 build; `.bazelignore` 也把 SwiftPM 的工作目錄排除在外。兩件事同一個理由:package 可能 @@ -271,24 +275,35 @@ system library,加上原本的 Swift。 | CodeEdit | package 全部建得起來;app 自己的原始碼被 Swift 6.4 擋下 | | CotEditor | package 全部建得起來;app 自己的原始碼被 Swift 6.4 擋下 | | IceCubesApp | package 全部建得起來;app 自己的原始碼和 iOS 27 SDK 撞名(`SwiftUI.Document`) | -| UTM | 見下面的 platform floor | +| UTM | package 都建得起來;app 本身需要預先 build 的 sysroot,另有一處原始碼靠 Xcode 的 project headermap 用檔名 include header | | PlayCover | `swift package resolve` 在 package 自己的 manifest 上就失敗 | 沒建起來的四個,失敗點都不在我們產生的東西裡:三個是自己的原始碼碰上更新的 compiler 與 SDK,一個是上游 manifest。 -### 已知限制:platform floor +### 平台版本 + +package 會宣告自己支援的平台版本,SwiftPM 編它的 target 時取「自己宣告的」和 +「使用端的」之中較高的那個。bazelize 一律用專案的 deployment target 編所有 package +target:版本存在於「拉它進來的 bundle 規則」的 platform transition 裡,library +規則本身沒有版本這個屬性。逐 target 遵守它,正是 rspm 依賴當時被釘在 1.15.0 的 +原因——之後的版本會把每個 target 轉場到它自己的 floor,然後在依賴宣告更高版本時 +analysis 失敗。 + +package 要求的版本還是會算出來,算法和 SwiftPM 一樣: -package 會宣告自己支援的平台版本,SwiftPM 編它的 target 時取「自己的 floor 和 -使用端的 floor 之中較高的那個」。bazelize 一律用專案的 deployment target 編所有 -package target,而這正是 rspm 依賴當時被釘在 1.15.0 的原因——之後的版本會把每個 -target 轉場到它自己的 floor,然後在依賴宣告更高版本時 analysis 失敗。 +1. manifest 的 `platforms:` 對該平台宣告的值; +2. 沒宣告就用 SwiftPM 建該平台的最低版本——macOS 12、iOS 與 tvOS 15、watchOS 9、 + visionOS 1、Mac Catalyst 15、DriverKit 21; +3. 專案有建該平台但沒寫版本時,問已安裝的 SDK:它附的 `XCTest` 的 deployment + target,就是 SwiftPM 問同一個問題的方式。 -所以 package 要求比專案高時就會編不過,錯誤是那些新 API 的 availability。UTM 就是 -這個情形:iOS 14 的專案,用到宣告 iOS 16 與 iOS 18 的 package。 +算出來的值會和「專案自己的 target 之中最低的 deployment target」比。package 要求 +更高時,會在執行結束時把兩個版本一起講出來——不然失敗會以「別人原始碼深處的 +availability 錯誤」的形式出現。 -要逐 target 遵守 floor,需要一個「拉高 deployment target 又不把依賴圖切開」的 -轉場,那是階段 3 的事。 +要用 package 要求的版本去編它,需要一個「拉高 deployment target 又不把依賴圖切開」 +的轉場,那是階段 3 的事。 ## 分階段與通過條件 From 45095cef670f120eb8db6c74796506cf5f4b4ced Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 09:35:05 +0800 Subject: [PATCH 132/173] Generate a package's macro as a compiler plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A macro target was skipped, which took every target declaring a macro down with it. It is now a swift_compiler_plugin: a program the compiler runs while it compiles whatever uses the macro, built for the machine doing the building rather than the platform the project targets. That is also why it is not a dependency. A target that uses the macro lists it in `plugins`, and a product never exports it — a consumer of the package links the library that declares the macro, not the program that expands it. --- Sources/BazelRules/Rules+Swift.swift | 12 +++++ .../SwiftPM/SwiftPM+Generator.swift | 50 ++++++++++++++++--- .../BazelizeKit/SwiftPM/SwiftPM+Macro.swift | 44 ++++++++++++++++ .../SwiftPM/SwiftPM+Resources.swift | 2 +- 4 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index b0d8b74..b65a8e6 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -136,6 +136,7 @@ extension Rules.Swift { copts: [String]? = nil, module_name: String? = nil, package_name: String? = nil, + plugins: Starlark.Value? = nil, srcs: Starlark.Value, deps: Starlark.Value? = nil, data: Starlark.Value? = nil, @@ -166,6 +167,9 @@ extension Rules.Swift { if let package_name { "package_name" => package_name } + if let plugins { + "plugins" => plugins + } "srcs" => srcs if let deps { @@ -555,13 +559,21 @@ extension Rules.Swift { /// Repo-local convenience for emitting a `visibility` attribute. public static func swift_compiler_plugin( name: String, + srcs: Starlark.Value? = nil, + copts: [String]? = nil, deps: Starlark.Value? = nil, + module_name: String? = nil, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Swift.swift_compiler_plugin.call { "name" => name + if let srcs { "srcs" => srcs } + if let copts { "copts" => copts } if let deps { "deps" => deps } + if let module_name { "module_name" => module_name } + if let tags { "tags" => tags } if let visibility { visibility } } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index e7e1eea..1c47246 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -111,6 +111,8 @@ extension SwiftPM { builder: builder) switch kind { + case .macro: + buildMacro(target, in: package, prefix: prefix, builder: builder) case .swift: build( target, @@ -148,7 +150,7 @@ extension SwiftPM { for target in targets { guard let kind = try kind(of: target, in: package) else { continue } switch kind { - case .swift, .clang, .binary, .system: + case .swift, .clang, .binary, .system, .macro: supported[target.name] = kind case .unsupported(let reason): Log.codeGenerate.warning(""" @@ -219,6 +221,8 @@ extension SwiftPM { case clang case binary case system + /// A macro: a program the compiler loads, not a library the target links. + case macro case unsupported(String) } @@ -237,7 +241,7 @@ extension SwiftPM { case "system": return .system case "macro": - return .unsupported("macro targets are not generated yet") + return .macro default: break } @@ -454,6 +458,9 @@ extension SwiftPM { /// Which targets `package` visibility reaches: every target of /// the same package, which is what the name identifies. package_name: package.manifest.name, + plugins: plugins(of: target, in: package).nonEmpty.map { macros in + .build { macros } + }, srcs: Starlark.glob( matching( sources(of: target, prefix: prefix, extensions: ["swift"]), @@ -503,6 +510,28 @@ extension SwiftPM { /// Directory types SwiftPM's file rules ignore. static let ignoredExtensions = ["docc", "xcprivacy"] + /// The macros a target loads: a macro target is a program the compiler + /// runs, so it belongs in `plugins` rather than in `deps`. + func plugins(of target: PackageTarget, in package: Package) -> [Starlark.Label] { + let macros = package.manifest.targets.filter { other in + if case .macro = kinds[package.directory]?[other.name] { return true } + return false + }.map(\.name) + + let names = target.dependencies.compactMap { dependency -> String? in + switch dependency.kind { + case .target(let name), .byName(let name): + return macros.contains(name) ? name : nil + case .product: + return nil + } + } + + return Set(names).sorted().map { name in + Starlark.Label.named(":\(ruleName(of: name, in: package))") + } + } + func deps(of target: PackageTarget, in package: Package) -> [Starlark.Label] { let localTargets = Set(package.manifest.targets.map(\.name)) let localProducts = Dictionary( @@ -512,11 +541,11 @@ extension SwiftPM { let labels: [String] = target.dependencies.compactMap { dependency in switch dependency.kind { case .target(let name): - return localTargets.contains(name) - ? ":\(ruleName(of: name, in: package))" - : nil + guard localTargets.contains(name), !isMacro(name, in: package) else { return nil } + return ":\(ruleName(of: name, in: package))" case .byName(let name): if localTargets.contains(name) { + guard !isMacro(name, in: package) else { return nil } return ":\(ruleName(of: name, in: package))" } if localProducts[name] != nil { return ":\(name)" } @@ -529,6 +558,11 @@ extension SwiftPM { return Array(Set(labels)).sorted().map(Starlark.Label.named) } + private func isMacro(_ target: String, in package: Package) -> Bool { + if case .macro = kinds[package.directory]?[target] { return true } + return false + } + /// A product of another package is reached through the facade, so the label /// does not depend on how that package's rules are generated. private func label(product: String, package name: String?, from package: Package) -> String? { @@ -569,7 +603,11 @@ extension SwiftPM { { guard product.kind == .library else { return } - let targets = product.targets.filter { emitted.contains($0) } + /// A macro is not part of a product a consumer links: it is loaded by + /// the compiler of whatever declares the macro, inside its own package. + let targets = product.targets.filter { target in + emitted.contains(target) && !isMacro(target, in: package) + } guard !targets.isEmpty else { return } /// A product of one target is that target under another name; several diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift new file mode 100644 index 0000000..274d2b8 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift @@ -0,0 +1,44 @@ +// +// SwiftPM+Macro.swift +// +// +// Rules for a package target the compiler loads instead of linking. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A macro target becomes a `swift_compiler_plugin`: a program the compiler + /// runs while it compiles whatever declares the macro. + /// + /// It is built for the machine doing the building rather than the platform the + /// project targets, which is why it cannot be an ordinary library — and why a + /// target that uses the macro lists it in `plugins`, never in `deps`. + func buildMacro( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + builder: CodeBuilder) + { + builder.load(loadableRule: Rules.Swift.swift_compiler_plugin) + builder.call( + Rules.Swift.Call.swift_compiler_plugin( + name: ruleName(of: target.name, in: package), + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)), + exclude: excluded(target, prefix: prefix)), + copts: copts(of: target).nonEmpty, + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + module_name: Self.moduleName(target.name), + tags: Self.manual, + visibility: .public)) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index 30c260d..2a327ea 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -104,7 +104,7 @@ extension SwiftPM.Generator { label: ":\(name)", accessors: [header, implementation], header: header) - case .binary, .system, .unsupported: + case .binary, .system, .macro, .unsupported: return nil } } From 2a47179dd5cbc5520a4c6a8aa055554a0bcb6c7a Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 09:35:05 +0800 Subject: [PATCH 133/173] Expand a macro in the iOS fixture The corpus has no macro target, so the fixture grows one: the local package declares a `#stringify` macro, expands it in its own sources, and the app's tests assert the expanded value. The assertion only holds if the compiler loaded the generated plugin, which also exercises swift-syntax being built from its own manifest. --- fixture/iOS/ExampleTests/ExampleTests.swift | 8 +++++++ fixture/iOS/Local1/Package.swift | 12 ++++++++-- .../Sources/Local1Macros/Local1Macros.swift | 23 +++++++++++++++++++ .../Sources/LocalTarget1/LocalTarget1.swift | 11 +++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift diff --git a/fixture/iOS/ExampleTests/ExampleTests.swift b/fixture/iOS/ExampleTests/ExampleTests.swift index 7230aed..a5d959a 100644 --- a/fixture/iOS/ExampleTests/ExampleTests.swift +++ b/fixture/iOS/ExampleTests/ExampleTests.swift @@ -5,6 +5,7 @@ // Created by Yume on 2023/1/7. // +import LocalTarget1 import XCTest @testable import Example @@ -12,4 +13,11 @@ final class ExampleTests: XCTestCase { func testExample() throws { XCTAssertEqual(test(), 0b1111) } + + /// The local package declares a macro and expands it in its own sources, so + /// the value only exists if the compiler loaded the generated plugin. + func testPackageMacroExpands() throws { + XCTAssertEqual(LocalTarget1.stringified.0, 2) + XCTAssertEqual(LocalTarget1.stringified.1, "1 + 1") + } } diff --git a/fixture/iOS/Local1/Package.swift b/fixture/iOS/Local1/Package.swift index bb6bc52..9f7509a 100644 --- a/fixture/iOS/Local1/Package.swift +++ b/fixture/iOS/Local1/Package.swift @@ -1,6 +1,7 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import PackageDescription let package = Package( @@ -17,13 +18,20 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.5.0"), + .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. + .macro( + name: "Local1Macros", + dependencies: [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + ]), .target( name: "LocalTarget1", - dependencies: ["RxSwift"]), + dependencies: ["RxSwift", "Local1Macros"]), .target( name: "LocalTarget2", dependencies: ["RxSwift"]), diff --git a/fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift b/fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift new file mode 100644 index 0000000..77e74cf --- /dev/null +++ b/fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift @@ -0,0 +1,23 @@ +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// `#stringify(1 + 1)` expands to `(1 + 1, "1 + 1")`. +public struct StringifyMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in _: some MacroExpansionContext) throws -> ExprSyntax + { + guard let argument = node.arguments.first?.expression else { + fatalError("#stringify takes one argument") + } + + return "(\(argument), \(literal: argument.description))" + } +} + +@main +struct Local1MacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [StringifyMacro.self] +} diff --git a/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift b/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift index 5146472..8840b19 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift +++ b/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift @@ -1,5 +1,16 @@ +/// Expanded by the package's own macro target. +@freestanding(expression) +public macro stringify(_ value: T) -> (T, String) = #externalMacro( + module: "Local1Macros", + type: "StringifyMacro") + public struct LocalTarget1 { public private(set) var text = "Hello, World!" public init() { } + + /// `("1 + 1", 2)` without writing either out twice. + public static var stringified: (Int, String) { + #stringify(1 + 1) + } } From eb14e4ec9e4aa92d6d4b62cdfe67118b7712efc0 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 09:35:24 +0800 Subject: [PATCH 134/173] Record that a package's macro is generated --- docs/SPM.md | 8 +++++--- docs/SPM_ZH.md | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/SPM.md b/docs/SPM.md index 20e1a2a..2b6291e 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -150,7 +150,7 @@ No test pins how a package's rules are produced either. | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | | build tool plugin (SwiftLint etc.) | stage 3; skipped with a warning | -| macro / compiler plugin | stage 3; `swift_compiler_plugin` | +| macro target | `swift_compiler_plugin`, and `plugins` on whatever declares the macro | | traits (SE-0450) | expanded into `-D` and conditional deps per enabled trait | Two SwiftPM behaviours are matched on every generated `swift_library`: @@ -198,6 +198,8 @@ output of `swift package dump-package` and `describe`). | PluginTarget | 1 | No macro targets, and no mixed-language targets (SwiftPM does not allow them). +The iOS fixture declares one instead, so the rules for a macro are exercised by +a build rather than by inspection. ### Build settings (targets / packages using them) @@ -240,7 +242,7 @@ a plugin target nobody consumes" as rules, **all 119 packages fall into stages |---|---| | pure Swift libraries, no resources | 58 | | + clang / resources / binary / system | 61 (119 cumulative) | -| macros, source-generating plugins | 0 (none in the corpus) | +| macros, source-generating plugins | 0 (none in the corpus; the fixture has a macro) | The minimum stage each app needs (expanded from each workspace's `Package.resolved`): @@ -343,7 +345,7 @@ reason), plus the 114 unit tests and the iOS fixture. | 0.5 ✅ | the `//Packages` facade (aliases into rspm) | all apps; label shape settled | | 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind a flag, rspm still the default | 58 packages build on their own | | 2 ✅ | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude` / module maps), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | the 7 green apps build and run; every package of the other five builds | -| 3 | macros, source-generating build tool plugins, per-target platform floors | when something outside the corpus needs it | +| 3 | macro targets ✅; source-generating build tool plugins and per-target platform versions remain | when something outside the corpus needs it | | 4 ✅ | the rspm dependency, `Patches/`, the version gate and the mode flag are gone | the 7 green apps build and run | Stage 4 removed the alternative rather than keeping a flag: two paths would diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 4852303..1deab8b 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -139,7 +139,7 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | | build tool plugin(SwiftLint 等) | 階段 3;先跳過並警告 | -| macro / compiler plugin | 階段 3;`swift_compiler_plugin` | +| macro target | `swift_compiler_plugin`,並在宣告該 macro 的 target 上加 `plugins` | | traits(SE-0450) | 依 enabled traits 展開成 `-D` 與條件依賴 | 每個產生的 `swift_library` 都對齊兩個 SwiftPM 行為:`alwayslink`,因為 SwiftPM @@ -180,7 +180,8 @@ package 自己宣告的 platform floor 是**故意忽略**的——逐 package | SystemLibraryTarget | 2 | | PluginTarget | 1 | -沒有 macro target,也沒有混合語言 target(SwiftPM 本來就不允許)。 +沒有 macro target,也沒有混合語言 target(SwiftPM 本來就不允許)。iOS fixture 自己 +補了一個 macro,所以 macro 的規則是用「建起來並驗證展開結果」來檢查,不是靠讀產出。 ### build settings(用到的 target 數/package 數) @@ -221,7 +222,7 @@ target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: |---|---| | 純 Swift library、無 resource | 58 | | + clang/resources/binary/system | 61(累計 119) | -| macro、會產生原始碼的 plugin | 0(語料裡沒有) | +| macro、會產生原始碼的 plugin | 0(語料裡沒有;fixture 自己有一個 macro) | 每個 app 需要的最低階段(用各 workspace 的 `Package.resolved` 展開): @@ -316,7 +317,7 @@ availability 錯誤」的形式出現。 | 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | | 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由一個 flag 切換,預設仍 rspm | 58 個 package 能單獨建起來 | | 2 ✅ | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`/module map)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 7 個綠燈 app 建得起來也跑得起來;另外五個的 package 全部建得起來 | -| 3 | macro、會產生原始碼的 build tool plugin、逐 target 的 platform floor | 語料外的需求出現時再做 | +| 3 | macro target ✅;會產生原始碼的 build tool plugin 與逐 target 的平台版本還沒做 | 語料外的需求出現時再做 | | 4 ✅ | rspm 依賴、`Patches/`、版本守門與模式 flag 全部移除 | 7 個綠燈 app 建得起來也跑得起來 | 階段 4 是把另一條路整個移除,而不是留一個 flag:兩條路就是兩張依賴圖,而語料裡 From 02bda56c8202d83db5b0bf2b48f3e73401ab80cd Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 09:43:10 +0800 Subject: [PATCH 135/173] Say which plugin a package asked for and did not get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build tool plugin is not run, and the manifest's `pluginUsages` was read by nothing, so the difference was invisible. Every plugin in the corpus is a linter, which produces no source: a build without it is the same build. One that generates source leaves a target missing files it expects, and that compile error says nothing about a plugin — so the plugin is named at the end of the run instead, next to the platform versions, which now share one channel. --- Sources/BazelizeKit/Kit.swift | 9 ++++-- .../SwiftPM/SwiftPM+Generator.swift | 32 ++++++++++++++++--- .../SwiftPM/SwiftPM+Manifest.swift | 21 ++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 6114a3b..71fb4a4 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -88,8 +88,11 @@ extension Kit { print(tip) } - packageTips.forEach { tip in - print("# Swift package\n\(tip)") + if !packageTips.isEmpty { + print("# Swift packages") + packageTips.forEach { tip in + print(tip) + } } plugins.forEach { plugin in @@ -111,7 +114,7 @@ extension Kit { workspace: workspace, deployment: deployment) try generator.generate() - packageTips = generator.unmetDeployment + packageTips = generator.notes let count = workspace.packages.count Log.codeGenerate.info("Generate \(count, privacy: .public) Swift packages") diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index 1c47246..72a657b 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -26,9 +26,9 @@ extension SwiftPM { private var kinds: [String: [String: TargetKind]] = [:] - /// What a caller tells the user about: the packages whose platform version - /// the project does not reach. - private(set) var unmetDeployment: [String] = [] + /// What a caller tells the user about: where the build differs from what + /// the package asked for, and why. + private(set) var notes: [String] = [] init(output: Path, workspace: Workspace, deployment: Deployment) { self.output = output @@ -40,6 +40,7 @@ extension SwiftPM { for package in workspace.packages { kinds[package.directory] = try supportedTargets(of: package) report(deploymentOf: package) + report(pluginsOf: package) } for package in workspace.packages { @@ -60,7 +61,30 @@ extension SwiftPM { """ Log.codeGenerate.warning("\(message, privacy: .public)") - unmetDeployment.append(message) + notes.append(message) + } + } + + /// A build tool plugin is not run. + /// + /// Every plugin in the corpus is a linter, which produces no source: a + /// build without it is the same build. One that generates source would + /// leave a target missing the files it expects, and that compile error says + /// nothing about a plugin, so the plugin is named here instead. + private func report(pluginsOf package: Package) { + let used = package.manifest.targets + .filter { $0.type != "test" } + .flatMap(\.pluginUsages) + .map(\.name) + + for plugin in Set(used).sorted() { + let message = """ + \(package.directory) asks for the \(plugin) plugin, which is not run: \ + a linter changes nothing, a plugin that generates source does. + """ + + Log.codeGenerate.warning("\(message, privacy: .public)") + notes.append(message) } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift index ba45237..e0271f9 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift @@ -74,6 +74,8 @@ extension SwiftPM { let settings: [Setting] let resources: [Resource] let dependencies: [TargetDependency] + /// The plugins the target asks to be run while it is built. + let pluginUsages: [PluginUsage] /// A binary target's remote archive. let url: String? let checksum: String? @@ -89,11 +91,30 @@ extension SwiftPM { settings = container.list(Setting.self, "settings") resources = container.list(Resource.self, "resources") dependencies = container.list(TargetDependency.self, "dependencies") + pluginUsages = container.list(PluginUsage.self, "pluginUsages") url = container.value(String.self, "url") checksum = container.value(String.self, "checksum") } } + /// `{"plugin": ["SwiftLint", "SwiftLintPlugin"]}`: the plugin's name first, + /// then the package it comes from, which is absent for one in the same + /// package. + struct PluginUsage: Decodable { + let name: String + let package: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + let values = container.value([String?].self, "plugin") + ?? container.value([String?].self, "byName") + ?? [] + + name = values.first.flatMap { $0 } ?? "" + package = values.count > 1 ? values[1] : nil + } + } + /// `{"tool": "swift", "kind": {"define": {"_0": "FOO"}}}` struct Setting: Decodable { let tool: String From 3a15d155bf82e0374856106cdce2e12eafa6b699 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 09:43:16 +0800 Subject: [PATCH 136/173] Record that a plugin is named rather than run --- docs/SPM.md | 2 +- docs/SPM_ZH.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/SPM.md b/docs/SPM.md index 2b6291e..c398036 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -149,7 +149,7 @@ No test pins how a package's rules are produced either. | `interoperabilityMode` | `-cxx-interoperability-mode=` | | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | -| build tool plugin (SwiftLint etc.) | stage 3; skipped with a warning | +| build tool plugin (SwiftLint etc.) | not run; the plugin is named at the end of the run | | macro target | `swift_compiler_plugin`, and `plugins` on whatever declares the macro | | traits (SE-0450) | expanded into `-D` and conditional deps per enabled trait | diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 1deab8b..a12c5a0 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -138,7 +138,7 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 | `interoperabilityMode` | `-cxx-interoperability-mode=` | | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | -| build tool plugin(SwiftLint 等) | 階段 3;先跳過並警告 | +| build tool plugin(SwiftLint 等) | 不執行;結束時把該 plugin 的名字講出來 | | macro target | `swift_compiler_plugin`,並在宣告該 macro 的 target 上加 `plugins` | | traits(SE-0450) | 依 enabled traits 展開成 `-D` 與條件依賴 | From 02af1483ddfbfe05f56dbf546150a577fbd59a6b Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 10:19:49 +0800 Subject: [PATCH 137/173] Report only a platform version a package actually declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftPM raises both the package's and the consumer's floor to its own default before comparing them, so a package that declares nothing is never the reason a graph is rejected — reporting the default made every package with no platforms look like a problem on an older project. The same experiment settles what compiling at the package's version would be worth: nothing. SwiftPM refuses a graph where a product requires more than its consumer, because a module built for a newer platform cannot be imported by an older one. The resolution is the project raising its deployment target or the package lowering what it declares, so naming the package and both versions is the whole of it, and no transition is needed. --- .../SwiftPM/SwiftPM+Deployment.swift | 16 +++++++++----- .../XCode2Tests/PackageDeploymentTests.swift | 9 ++++++++ docs/SPM.md | 21 ++++++++++++++++--- docs/SPM_ZH.md | 19 ++++++++++++++--- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift index b303fb7..1de7222 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift @@ -36,19 +36,25 @@ extension SwiftPM { return Self.oldest[platform] } - /// The platforms a package would be compiled for at a version the project - /// does not provide. + /// The platforms where the package asks for more than the project provides. + /// + /// Only a version the manifest states counts. SwiftPM raises both sides to + /// its own floor before comparing them — a package that declares nothing is + /// never the reason a graph is rejected — so the default is not something to + /// warn about. func unmet(_ package: Package) -> [(platform: String, required: String, project: String)] { project.keys.sorted().compactMap { platform in guard let floor = project[platform], - let required = required(package, platform: platform), - Self.isNewer(required, than: floor) + let declared = package.manifest.platforms + .first(where: { $0.platformName == platform })? + .version, + Self.isNewer(declared, than: floor) else { return nil } - return (platform, required, floor) + return (platform, declared, floor) } } diff --git a/Tests/XCode2Tests/PackageDeploymentTests.swift b/Tests/XCode2Tests/PackageDeploymentTests.swift index f1e33f9..66c09b4 100644 --- a/Tests/XCode2Tests/PackageDeploymentTests.swift +++ b/Tests/XCode2Tests/PackageDeploymentTests.swift @@ -57,6 +57,15 @@ struct PackageDeploymentTests { #expect(unmet.first?.project == "14.0") } + @Test + func anUndeclaredPlatformIsNotReported() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0"]) + + /// SwiftPM raises the consumer to its own floor too, so a package that + /// declares nothing is never why a graph is rejected. + #expect(deployment.unmet(package(platforms: [("macos", "13.0")])).isEmpty) + } + @Test func aPackageWithinTheProjectsReachIsNotReported() { let deployment = SwiftPM.Deployment(project: ["ios": "18.5"]) diff --git a/docs/SPM.md b/docs/SPM.md index c398036..9d0d9d8 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -330,8 +330,23 @@ own targets. A package that needs more is named at the end of the run, with both versions, because the failure otherwise surfaces as an availability error deep in someone else's source. -Compiling such a package at the version it asks for needs a transition that -raises the deployment target without splitting the graph, which is stage 3 work. +Compiling such a package at the version it asks for is not the answer, because +SwiftPM does not do that either. It rejects the graph: + +```text +error: The package product 'Dep-product' requires minimum platform version 14.0 +for the macOS platform, but this target supports 12.0 +``` + +A module built for a newer platform cannot be imported by an older one — Swift +errors on that too — so the only resolution is the project raising its own +deployment target, or the package lowering what it declares. Saying which +package and which two versions is therefore the whole of it. + +The same experiment shows SwiftPM raises *both* sides to its own floor before +comparing them (the project above declares macOS 11 and is reported as 12), so a +package that declares nothing is never the reason a graph is rejected. Only a +version a manifest states is reported here. ## Stages and exit criteria @@ -345,7 +360,7 @@ reason), plus the 114 unit tests and the iOS fixture. | 0.5 ✅ | the `//Packages` facade (aliases into rspm) | all apps; label shape settled | | 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind a flag, rspm still the default | 58 packages build on their own | | 2 ✅ | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude` / module maps), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | the 7 green apps build and run; every package of the other five builds | -| 3 | macro targets ✅; source-generating build tool plugins and per-target platform versions remain | when something outside the corpus needs it | +| 3 | macro targets ✅; per-target platform versions ✅ (nothing to build — SwiftPM rejects such a graph, so the report is the answer); source-generating build tool plugins remain | when something outside the corpus needs it | | 4 ✅ | the rspm dependency, `Patches/`, the version gate and the mode flag are gone | the 7 green apps build and run | Stage 4 removed the alternative rather than keeping a flag: two paths would diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index a12c5a0..56c694d 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -303,8 +303,21 @@ package 要求的版本還是會算出來,算法和 SwiftPM 一樣: 更高時,會在執行結束時把兩個版本一起講出來——不然失敗會以「別人原始碼深處的 availability 錯誤」的形式出現。 -要用 package 要求的版本去編它,需要一個「拉高 deployment target 又不把依賴圖切開」 -的轉場,那是階段 3 的事。 +「用 package 要求的版本去編它」並不是解法,因為 SwiftPM 自己也不這樣做——它直接拒絕 +這張圖: + +```text +error: The package product 'Dep-product' requires minimum platform version 14.0 +for the macOS platform, but this target supports 12.0 +``` + +為較新平台建出來的模組,較舊平台不能 import(Swift 也是直接報錯),所以唯一的解法 +是專案拉高自己的 deployment target,或 package 降低它宣告的版本。把「是哪個 package、 +哪兩個版本」講出來,就是這件事的全部。 + +同一個實驗也顯示 SwiftPM 比較之前會把**兩邊**都拉到它自己的最低版本(上面那個專案 +宣告 macOS 11,錯誤訊息裡是 12),所以「沒宣告」的 package 永遠不會是圖被拒絕的原因。 +因此這裡只回報 manifest 明確宣告的版本。 ## 分階段與通過條件 @@ -317,7 +330,7 @@ availability 錯誤」的形式出現。 | 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | | 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由一個 flag 切換,預設仍 rspm | 58 個 package 能單獨建起來 | | 2 ✅ | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`/module map)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 7 個綠燈 app 建得起來也跑得起來;另外五個的 package 全部建得起來 | -| 3 | macro target ✅;會產生原始碼的 build tool plugin 與逐 target 的平台版本還沒做 | 語料外的需求出現時再做 | +| 3 | macro target ✅;逐 target 的平台版本 ✅(不需要做——SwiftPM 自己就會拒絕這種圖,所以回報就是答案);會產生原始碼的 build tool plugin 還沒做 | 語料外的需求出現時再做 | | 4 ✅ | rspm 依賴、`Patches/`、版本守門與模式 flag 全部移除 | 7 個綠燈 app 建得起來也跑得起來 | 階段 4 是把另一條路整個移除,而不是留一個 flag:兩條路就是兩張依賴圖,而語料裡 From 4eced3846593dfcb4a35d6c5710376a596eb4b62 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 10:32:06 +0800 Subject: [PATCH 138/173] Build a package's command line tool as a binary An executable target was classified by its files like any other, so it became a library: a rule that neither produces a tool nor compiles a top-level `main.swift`, and its product was dropped entirely. It is a swift_binary now, and an executable product aliases it rather than wrapping it, because two binary rules over the same sources would build the tool twice. This is what a build tool plugin runs, and what a project consuming a package's CLI needs. --- Sources/BazelRules/Rules+Swift.swift | 4 ++ .../SwiftPM/SwiftPM+Executable.swift | 65 +++++++++++++++++++ .../SwiftPM/SwiftPM+Generator.swift | 25 ++++++- .../SwiftPM/SwiftPM+Resources.swift | 2 +- 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index b65a8e6..1740341 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -248,6 +248,7 @@ extension Rules.Swift { srcs: Starlark.Value? = nil, stamp: Int? = nil, swiftc_inputs: Starlark.Value? = nil, + tags: [String]? = nil, testonly: Bool? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call @@ -275,6 +276,9 @@ extension Rules.Swift { if let swiftc_inputs { "swiftc_inputs" => swiftc_inputs } + if let tags { + "tags" => tags + } if let testonly { "testonly" => testonly } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift new file mode 100644 index 0000000..b6454a4 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift @@ -0,0 +1,65 @@ +// +// SwiftPM+Executable.swift +// +// +// Rules for a command line tool a package builds. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// An executable target becomes a `swift_binary`: it has a `main`, so it links + /// instead of being linked, and a library rule would neither produce a tool nor + /// compile a top-level `main.swift`. + func buildExecutable( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + resources: ResourceBundle?, + builder: CodeBuilder) + { + builder.load(loadableRule: Rules.Swift.swift_binary) + builder.call( + Rules.Swift.Call.swift_binary( + name: ruleName(of: target.name, in: package), + copts: copts(of: target).nonEmpty, + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + linkopts: linkopts(of: target).nonEmpty, + module_name: Self.moduleName(target.name), + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)) + + (resources?.accessors ?? []), + exclude: excluded(target, prefix: prefix)), + tags: Self.manual, + visibility: .public)) + } + + /// An executable product is the tool under the name a consumer writes, so it + /// aliases the target rather than wrapping it: two `swift_binary` rules over + /// the same sources would build the tool twice. + func buildExecutable( + _ product: SwiftPM.PackageProduct, + emitted: Set, + package: SwiftPM.Package, + builder: CodeBuilder) + { + guard let target = product.targets.first(where: emitted.contains) else { return } + let rule = ruleName(of: target, in: package) + guard rule != product.name else { return } + + builder.call( + Rules.Builtin.Call.alias( + name: product.name, + actual: .named(":\(rule)"), + tags: Self.manual, + visibility: .public)) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index 72a657b..bf09575 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -137,6 +137,13 @@ extension SwiftPM { switch kind { case .macro: buildMacro(target, in: package, prefix: prefix, builder: builder) + case .executable: + buildExecutable( + target, + in: package, + prefix: prefix, + resources: resources, + builder: builder) case .swift: build( target, @@ -174,7 +181,7 @@ extension SwiftPM { for target in targets { guard let kind = try kind(of: target, in: package) else { continue } switch kind { - case .swift, .clang, .binary, .system, .macro: + case .swift, .clang, .binary, .system, .macro, .executable: supported[target.name] = kind case .unsupported(let reason): Log.codeGenerate.warning(""" @@ -247,6 +254,8 @@ extension SwiftPM { case system /// A macro: a program the compiler loads, not a library the target links. case macro + /// A command line tool the package builds. + case executable case unsupported(String) } @@ -266,6 +275,10 @@ extension SwiftPM { return .system case "macro": return .macro + case "executable", "snippet": + /// A tool the package builds: it has a `main`, so it links rather + /// than being linked. + return .executable default: break } @@ -625,7 +638,15 @@ extension SwiftPM { package: Package, builder: CodeBuilder) { - guard product.kind == .library else { return } + switch product.kind { + case .library: + break + case .executable: + buildExecutable(product, emitted: emitted, package: package, builder: builder) + return + case .plugin: + return + } /// A macro is not part of a product a consumer links: it is loaded by /// the compiler of whatever declares the macro, inside its own package. diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index 2a327ea..156c521 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -89,7 +89,7 @@ extension SwiftPM.Generator { tags: Self.manual)) switch kind { - case .swift: + case .swift, .executable: let accessor = "Generated/\(target.name)ResourceBundleAccessor.swift" try (root + accessor).write(Self.swiftAccessor(bundle: bundle)) return ResourceBundle(label: ":\(name)", accessors: [accessor], header: nil) From 4fd2115b6b73510ccd3383844110ecdfbebfaf6d Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 10:32:06 +0800 Subject: [PATCH 139/173] Build a tool target in the iOS fixture The corpus has one executable target and nobody consumes it, so the fixture's local package grows a tool: `bazel run //Packages/Local1:local1-tool` prints its output, which is the product name a consumer writes rather than the target's. --- fixture/iOS/Local1/Package.swift | 5 +++++ fixture/iOS/Local1/Sources/Local1Tool/main.swift | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 fixture/iOS/Local1/Sources/Local1Tool/main.swift diff --git a/fixture/iOS/Local1/Package.swift b/fixture/iOS/Local1/Package.swift index 9f7509a..865c197 100644 --- a/fixture/iOS/Local1/Package.swift +++ b/fixture/iOS/Local1/Package.swift @@ -14,6 +14,9 @@ let package = Package( .library( name: "LocalLib2", targets: ["LocalTarget2"]), + .executable( + name: "local1-tool", + targets: ["Local1Tool"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -37,6 +40,8 @@ let package = Package( dependencies: ["RxSwift"]), .target( name: "LocalTarget3"), + .executableTarget( + name: "Local1Tool"), .testTarget( name: "Local1Tests", dependencies: ["LocalTarget1"]), diff --git a/fixture/iOS/Local1/Sources/Local1Tool/main.swift b/fixture/iOS/Local1/Sources/Local1Tool/main.swift new file mode 100644 index 0000000..7e1c2d7 --- /dev/null +++ b/fixture/iOS/Local1/Sources/Local1Tool/main.swift @@ -0,0 +1,2 @@ +/// A tool the package builds, which is what a build tool plugin would run. +print("Local1Tool") From 18765a1f49bc57280d3a780f487b2f5eb19f648b Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 10:32:32 +0800 Subject: [PATCH 140/173] Record how a tool is generated and what running a plugin would take --- docs/SPM.md | 27 ++++++++++++++++++++++++++- docs/SPM_ZH.md | 22 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/SPM.md b/docs/SPM.md index 9d0d9d8..9d212a4 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -136,6 +136,8 @@ No test pins how a package's rules are produced either. | system-library target | `cc_library` + `swift_interop_hint` over the module map the package ships | | binary target (xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | | binary target (local archive) | unarchived first, then as above | +| executable target | `swift_binary` | +| executable product | `alias` to the target's binary | | library product, one target | `alias` | | library product, several targets | `swift_library_group` | | `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | @@ -149,7 +151,7 @@ No test pins how a package's rules are produced either. | `interoperabilityMode` | `-cxx-interoperability-mode=` | | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | -| build tool plugin (SwiftLint etc.) | not run; the plugin is named at the end of the run | +| build tool plugin (SwiftLint etc.) | not run; the plugin is named at the end of the run (see below) | | macro target | `swift_compiler_plugin`, and `plugins` on whatever declares the macro | | traits (SE-0450) | expanded into `-D` and conditional deps per enabled trait | @@ -348,6 +350,29 @@ comparing them (the project above declares macOS 11 and is reported as 12), so a package that declares nothing is never the reason a graph is rejected. Only a version a manifest states is reported here. +### Build tool plugins + +A plugin is not run, and the plugin is named at the end of the run instead. Two +ways to change that were considered: + +- **Speak SwiftPM's plugin protocol.** A plugin is a program the host asks for + build commands over a pipe, and the request carries the whole package graph in + SwiftPM's own `HostToPluginMessage` format — an internal type, serialized by + some five hundred lines inside SwiftPM. Reimplementing that host ties bazelize + to a private schema that moves with every toolchain. +- **Let SwiftPM materialize the generated sources.** SwiftPM runs the plugins + when it builds a target and leaves their output under + `.build/plugins/outputs/`. Bazelize could build the plugin-using targets at + generation time and take those files into `srcs`, the way it takes everything + else SwiftPM already produced. The cost is a SwiftPM build of those packages + during generation, and generated sources that only change when bazelize runs + again — which is already true of every file bazelize writes. + +The second is the one to build when a package in the corpus generates source. +Every plugin in the corpus is a linter, so today neither is needed: the pieces a +plugin needs — an executable target, and a tool from a binary target — are +generated either way. + ## Stages and exit criteria The exit criterion is the same at every stage: **the 12 apps at least hold diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 56c694d..bfb155e 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -125,6 +125,8 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 | system-library target | `cc_library` + `swift_interop_hint`,用 package 自己帶的 module map | | binary target(xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | | binary target(本地 archive) | 先解壓,再同上 | +| executable target | `swift_binary` | +| executable product | `alias` 指向該 target 的 binary | | library product,單一 target | `alias` | | library product,多個 target | `swift_library_group` | | `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | @@ -138,7 +140,7 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 | `interoperabilityMode` | `-cxx-interoperability-mode=` | | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | -| build tool plugin(SwiftLint 等) | 不執行;結束時把該 plugin 的名字講出來 | +| build tool plugin(SwiftLint 等) | 不執行;結束時把該 plugin 的名字講出來(見下) | | macro target | `swift_compiler_plugin`,並在宣告該 macro 的 target 上加 `plugins` | | traits(SE-0450) | 依 enabled traits 展開成 `-D` 與條件依賴 | @@ -319,6 +321,24 @@ for the macOS platform, but this target supports 12.0 宣告 macOS 11,錯誤訊息裡是 12),所以「沒宣告」的 package 永遠不會是圖被拒絕的原因。 因此這裡只回報 manifest 明確宣告的版本。 +### build tool plugin + +plugin 不會被執行,而是在結束時把它的名字講出來。要改變這件事,考慮過兩條路: + +- **自己實作 SwiftPM 的 plugin 協定。** plugin 是一個「host 透過 pipe 向它要 build + command」的程式,而那個請求裡帶著整張 package graph,用的是 SwiftPM 自己的 + `HostToPluginMessage` 格式——那是 internal type,SwiftPM 內部用大約五百行在序列化它。 + 自己實作 host 等於把 bazelize 綁在一個會跟著 toolchain 變動的私有 schema 上。 +- **讓 SwiftPM 幫我們產生原始碼。** SwiftPM 在建 target 時就會執行 plugin,並把輸出 + 留在 `.build/plugins/outputs/` 底下。bazelize 可以在產生階段建那些用到 plugin 的 + target,再把那些檔案收進 `srcs`——和它收 SwiftPM 既有產物的做法一樣。代價是產生階段 + 要跑一次 SwiftPM build,而且那些產生出來的原始碼只會在「再跑一次 bazelize」時更新 + ——不過這對 bazelize 寫出來的每個檔案本來就成立。 + +語料裡真的出現「會產生原始碼的 package」時,要做的是第二條。目前語料裡的 plugin 全是 +linter,所以兩條都還不需要:plugin 需要的零件——executable target、binary target 提供 +的工具——不論如何都已經會產生。 + ## 分階段與通過條件 每一階段的通過條件都一樣:**12 個 app 至少維持現狀**(7 個綠的仍綠、blocked 的 From c69080994efe4b0f62327f5dcbdf5c30fcc65674 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 10:39:15 +0800 Subject: [PATCH 141/173] Tell a command plugin apart from a build tool plugin A plugin target has one of two capabilities. A command plugin runs when someone asks for it by name and never during a build, so a build missing it is not a difference worth mentioning; a build tool plugin does run while a target is built, and that is the one the run reports. Both were reported as an ungenerated target, which made SwiftLint's fix-it command look like something the build needs. --- .../SwiftPM/SwiftPM+Generator.swift | 12 +++++++++--- .../BazelizeKit/SwiftPM/SwiftPM+Manifest.swift | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index bf09575..c474826 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -266,9 +266,15 @@ extension SwiftPM { case "test": return nil case "plugin": - /// Every plugin in the wild so far is a linter: it produces no - /// source, so a build without it is the same build. - return .unsupported("plugin targets are not generated") + /// A command plugin runs when someone asks for it by name, so a + /// build never needs it. A build tool plugin does run while a + /// target is built, and not running it is what the run reports. + switch target.capability { + case .command: + return nil + case .buildTool, .none: + return .unsupported("a build tool plugin is not run") + } case "binary": return .binary case "system": diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift index e0271f9..b301299 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift @@ -76,6 +76,15 @@ extension SwiftPM { let dependencies: [TargetDependency] /// The plugins the target asks to be run while it is built. let pluginUsages: [PluginUsage] + /// `{"buildTool": null}` or `{"command": [...]}` for a plugin target. + let pluginCapability: [String: AnyDecodable?]? + + var capability: PluginCapability? { + guard let pluginCapability else { return nil } + if pluginCapability.keys.contains("command") { return .command } + if pluginCapability.keys.contains("buildTool") { return .buildTool } + return nil + } /// A binary target's remote archive. let url: String? let checksum: String? @@ -92,11 +101,20 @@ extension SwiftPM { resources = container.list(Resource.self, "resources") dependencies = container.list(TargetDependency.self, "dependencies") pluginUsages = container.list(PluginUsage.self, "pluginUsages") + pluginCapability = container.value([String: AnyDecodable?].self, "pluginCapability") url = container.value(String.self, "url") checksum = container.value(String.self, "checksum") } } + /// What a plugin target can be asked to do. + enum PluginCapability { + /// Runs while a target is built, and may generate source. + case buildTool + /// Runs when someone asks for it by name, never during a build. + case command + } + /// `{"plugin": ["SwiftLint", "SwiftLintPlugin"]}`: the plugin's name first, /// then the package it comes from, which is absent for one in the same /// package. From 45b0927dd0da38560106d2f1c0c2cc89cbbbb95c Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 12:17:35 +0800 Subject: [PATCH 142/173] Take a Swift package as the thing to bazelize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything a package needs was already generated for a package a project depends on: the rules live under `Packages/`, reached by a product label. A package handed in directly is the same thing — the one local package of a project with nothing else in it — so it is loaded as exactly that, and nothing downstream has to know the input was a manifest rather than an `.xcodeproj`. `--project` therefore takes a `Package.swift`, or the directory holding one. --- .gitignore | 1 + .../Xcode2/Model/Project/XCode+Project.swift | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 38da6cd..dcec68e 100644 --- a/.gitignore +++ b/.gitignore @@ -184,6 +184,7 @@ cache/ .codex-local-skills/ .vendor/ app/ +spm/ Generated/ # bazelize output inside the iOS fixture diff --git a/Sources/Xcode2/Model/Project/XCode+Project.swift b/Sources/Xcode2/Model/Project/XCode+Project.swift index e0c859c..74abfdf 100644 --- a/Sources/Xcode2/Model/Project/XCode+Project.swift +++ b/Sources/Xcode2/Model/Project/XCode+Project.swift @@ -14,7 +14,42 @@ extension XCode { public let targets: [Target] public static func load(path: Path, preferConfig: String?) throws -> Self { - try ProjectLoader(path: path, preferConfig: preferConfig).model() + if let manifest = Self.manifest(at: path) { + return package(at: manifest) + } + + return try ProjectLoader(path: path, preferConfig: preferConfig).model() + } + + /// The `Package.swift` a path names, directly or as its directory. + static func manifest(at path: Path) -> Path? { + if path.lastComponent == "Package.swift", path.exists { return path } + + let manifest = path + "Package.swift" + return manifest.exists ? manifest : nil + } + + /// A Swift package on its own, described as a project with no targets of its + /// own. + /// + /// Everything a package needs is already generated for a package a project + /// depends on: the rules live under `Packages/`, reached by product labels. + /// A package given directly is the same thing — the one local package of a + /// project that has nothing else in it — so nothing else has to know the + /// input was a manifest. + private static func package(at manifest: Path) -> Self { + let root = manifest.parent().absolute() + + return .init( + name: root.lastComponent, + workspacePath: root.string, + projectPath: manifest.string, + preferConfig: nil, + configs: [:], + packages: .init( + remote: [], + local: [.init(name: root.lastComponent, relativePath: ".")]), + targets: []) } } } From effa10d23d05f9ceba71cb94b7b1cc109f45a359 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 13:24:15 +0800 Subject: [PATCH 143/173] Run a package's tests, with the sources its plugins generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pointing the tool at a package is pointing it at that package's tests, so a test target of the package handed in becomes a swift_test. The tests of a package a project merely depends on are not generated: running them says nothing about the project, and they pull in dependencies nothing else needs. A test target is also looked for under `Tests`, and a plugin under `Plugins`, the way SwiftPM looks for them. Those tests are what needs a build tool plugin. A plugin reads whatever it likes under the package directory — TbCodeGenerater's generator reads a `.tb` file at the package root, which belongs to no target and is excluded from one — and it puts its output into the target that asked for it, not into itself. Declaring all of that to Bazel means knowing commands only the plugin can produce, over a protocol private to SwiftPM. So SwiftPM runs them: building a target is what makes it run that target's plugins, and it leaves the result under `.build/plugins/outputs`. Those files are linked next to the package's rules and compiled into the target that asked for the plugin, the way every other thing SwiftPM already produced is taken as it is. Only a package in the project's own repository is built this way — running a plugin costs a SwiftPM build of its package, and doing that for every dependency that merely lints would make generating a workspace unusable. --- Sources/BazelizeKit/Kit.swift | 4 +- .../SwiftPM/SwiftPM+Executable.swift | 2 + .../SwiftPM/SwiftPM+Generator.swift | 69 +++++++++++- .../BazelizeKit/SwiftPM/SwiftPM+Plugin.swift | 102 ++++++++++++++++++ .../SwiftPM/SwiftPM+Resources.swift | 2 +- .../BazelizeKit/SwiftPM/SwiftPM+Test.swift | 51 +++++++++ .../SwiftPM/SwiftPM+Workspace.swift | 14 ++- .../Xcode2/Model/Project/XCode+Project.swift | 10 +- .../XCode2Tests/PackageDeploymentTests.swift | 3 +- 9 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 71fb4a4..5d1bbd9 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -106,7 +106,9 @@ extension Kit { /// Rules for the packages the project depends on, generated from their /// manifests instead of by `rules_swift_package_manager`. private final func generateSwiftPackages() async throws { - let workspace = try await SwiftPM.loadWorkspace(output: outputRoot) + let workspace = try await SwiftPM.loadWorkspace( + output: outputRoot, + root: project.packageRoot) let deployment = await deployment() let generator = SwiftPM.Generator( diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift index b6454a4..7ebc966 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift @@ -19,6 +19,7 @@ extension SwiftPM.Generator { _ target: SwiftPM.PackageTarget, in package: SwiftPM.Package, prefix: String, + generated: [String], resources: ResourceBundle?, builder: CodeBuilder) { @@ -36,6 +37,7 @@ extension SwiftPM.Generator { matching( sources(of: target, prefix: prefix, extensions: ["swift"]), relativeFiles(of: target, in: package, prefix: prefix)) + + generated + (resources?.accessors ?? []), exclude: excluded(target, prefix: prefix)), tags: Self.manual, diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index c474826..7bf3003 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -126,6 +126,11 @@ extension SwiftPM { continue } + let generated = try materialize( + pluginOutputsOf: target, + in: package, + at: root) + let resources = try buildResources( target, in: package, @@ -142,6 +147,15 @@ extension SwiftPM { target, in: package, prefix: prefix, + generated: generated, + resources: resources, + builder: builder) + case .test: + buildTest( + target, + in: package, + prefix: prefix, + generated: generated, resources: resources, builder: builder) case .swift: @@ -149,6 +163,7 @@ extension SwiftPM { target, in: package, prefix: prefix, + generated: generated, resources: resources, builder: builder) case .clang: @@ -175,13 +190,16 @@ extension SwiftPM { /// on one that cannot: a library missing a target it links is worse than a /// library that is not there at all. private func supportedTargets(of package: Package) throws -> [String: TargetKind] { - let targets = package.manifest.targets.filter { $0.type != "test" } + /// Which targets are generated is `kind(of:)`'s answer, tests included: + /// the package under the tool gets its tests, one a project depends on + /// does not. + let targets = package.manifest.targets var supported: [String: TargetKind] = [:] for target in targets { guard let kind = try kind(of: target, in: package) else { continue } switch kind { - case .swift, .clang, .binary, .system, .macro, .executable: + case .swift, .clang, .binary, .system, .macro, .executable, .test: supported[target.name] = kind case .unsupported(let reason): Log.codeGenerate.warning(""" @@ -226,6 +244,30 @@ extension SwiftPM { /// the rest of the checkout out of the build: a package can ship `BUILD` /// files of its own — swift-syntax and Yams both do — and Bazel would load /// them as packages of this workspace. + /// The sources a plugin generated, linked next to the package's rules and + /// compiled into the target that asked for the plugin. + func materialize( + pluginOutputsOf target: PackageTarget, + in package: Package, + at root: Path) throws -> [String] + { + let files = workspace.pluginOutputs.files(of: target.name, in: package) + guard !files.isEmpty else { return [] } + + let directory = "Generated/\(target.name)Plugin" + let generated = root + directory + if generated.exists || generated.isSymlink { + try? generated.delete() + } + try generated.mkpath() + + return try files.map { file in + let link = generated + file.lastComponent + try link.symlink(file) + return "\(directory)/\(file.lastComponent)" + } + } + private func materialize(_ target: PackageTarget, in package: Package, at root: Path) throws -> String? { guard let directory = sourceDirectory(of: target, in: package) else { return nil } @@ -256,6 +298,8 @@ extension SwiftPM { case macro /// A command line tool the package builds. case executable + /// A test suite, generated for the package the tool was pointed at. + case test case unsupported(String) } @@ -264,7 +308,9 @@ extension SwiftPM { private func kind(of target: PackageTarget, in package: Package) throws -> TargetKind? { switch target.type { case "test": - return nil + /// Only the package under the tool: the tests of a package a project + /// depends on say nothing about the project. + return package.isRoot ? .test : nil case "plugin": /// A command plugin runs when someone asks for it by name, so a /// build never needs it. A build tool plugin does run while a @@ -472,7 +518,20 @@ extension SwiftPM { return directory.exists ? directory : nil } - for candidate in ["Sources", "Source", "src", "srcs"] { + /// A test target is looked for under `Tests` first, the way SwiftPM + /// looks for it, and a plugin under `Plugins`. + let conventional = ["Sources", "Source", "src", "srcs"] + let candidates: [String] + switch target.type { + case "test": + candidates = ["Tests"] + conventional + case "plugin": + candidates = ["Plugins"] + conventional + default: + candidates = conventional + } + + for candidate in candidates { let directory = package.root + candidate + target.name if directory.exists { return directory } } @@ -485,6 +544,7 @@ extension SwiftPM { _ target: PackageTarget, in package: Package, prefix: String, + generated: [String], resources: ResourceBundle?, builder: CodeBuilder) { @@ -508,6 +568,7 @@ extension SwiftPM { matching( sources(of: target, prefix: prefix, extensions: ["swift"]), relativeFiles(of: target, in: package, prefix: prefix)) + + generated + (resources?.accessors ?? []), exclude: excluded(target, prefix: prefix)), deps: deps(of: target, in: package).nonEmpty.map { labels in diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift new file mode 100644 index 0000000..00a31e1 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift @@ -0,0 +1,102 @@ +// +// SwiftPM+Plugin.swift +// +// +// The sources a build tool plugin generates. +// + +import Foundation +@preconcurrency import PathKit +import Subprocess +import Util + +extension SwiftPM { + /// What a build tool plugin produced, by target. + /// + /// A plugin is a program the compiler host asks for build commands over a + /// private protocol, so bazelize does not run it: SwiftPM does, and leaves the + /// result under `.build/plugins/outputs`. Those files are taken as they are, + /// the way every other thing SwiftPM already produced is. + /// + /// The consequence is the one every generated file here has: they change when + /// bazelize runs again, not when the input changes. That is also why only a + /// package in the project's own repository is built — running a plugin means + /// building its package with SwiftPM, and doing that for every dependency that + /// merely lints would make generating a workspace cost a full SwiftPM build. + struct PluginOutputs: Sendable { + /// Keyed `/`. + private let files: [String: [Path]] + + init(files: [String: [Path]] = [:]) { + self.files = files + } + + func files(of target: String, in package: Package) -> [Path] { + files["\(package.directory)/\(target)"] ?? [] + } + } + + /// Runs the plugins of the packages this project owns, and collects what they + /// wrote. + static func runPlugins(of packages: [Package]) async -> PluginOutputs { + var files: [String: [Path]] = [:] + + for package in packages where package.isRoot || package.isLocal { + let targets = package.manifest.targets + .filter { !$0.pluginUsages.isEmpty } + .map(\.name) + guard !targets.isEmpty else { continue } + + for target in targets { + await build(target: target, of: package) + + let produced = outputs(of: target, in: package) + guard !produced.isEmpty else { continue } + files["\(package.directory)/\(target)"] = produced + } + } + + return .init(files: files) + } + + // MARK: Private + + /// Building the target is what makes SwiftPM run its plugins; there is no + /// command that only runs them. + private static func build(target: String, of package: Package) async { + do { + let result = try await Subprocess.run( + .name("swift"), + arguments: Arguments([ + "build", + "--package-path", package.root.string, + "--target", target, + ]), + output: .discarded, + error: .discarded) + + guard result.terminationStatus.isSuccess else { + Log.codeGenerate.warning(""" + Cannot run the plugins of \(package.directory, privacy: .public)/\ + \(target, privacy: .public): swift build failed + """) + return + } + } catch { + Log.codeGenerate.warning(""" + Cannot run the plugins of \(package.directory, privacy: .public)/\ + \(target, privacy: .public): \(error.localizedDescription, privacy: .public) + """) + } + } + + /// `.build/plugins/outputs/////…` + private static func outputs(of target: String, in package: Package) -> [Path] { + let root = package.root + ".build/plugins/outputs" + package.identity + target + guard root.isDirectory else { return [] } + + return Generator.walk(root).filter { file in + file.extension == "swift" + }.sorted() + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index 156c521..e4aca7e 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -89,7 +89,7 @@ extension SwiftPM.Generator { tags: Self.manual)) switch kind { - case .swift, .executable: + case .swift, .executable, .test: let accessor = "Generated/\(target.name)ResourceBundleAccessor.swift" try (root + accessor).write(Self.swiftAccessor(bundle: bundle)) return ResourceBundle(label: ":\(name)", accessors: [accessor], header: nil) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift new file mode 100644 index 0000000..50a0134 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift @@ -0,0 +1,51 @@ +// +// SwiftPM+Test.swift +// +// +// Rules for the tests of the package that was handed to bazelize. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A test target becomes a `swift_test`, for the package that was handed in. + /// + /// The tests of a package a project merely depends on are not the project's + /// tests: running them says nothing about the project, and they pull in test + /// dependencies nothing else needs. The tests of the package under the tool are + /// the whole point of pointing the tool at it. + func buildTest( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + generated: [String], + resources: ResourceBundle?, + builder: CodeBuilder) + { + builder.load(loadableRule: Rules.Swift.swift_test) + builder.call( + Rules.Swift.Call.swift_test( + name: ruleName(of: target.name, in: package), + copts: copts(of: target).nonEmpty, + data: resources.map { bundle in + .build { [Starlark.Label.named(bundle.label)] } + }, + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + linkopts: linkopts(of: target).nonEmpty, + module_name: Self.moduleName(target.name), + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)) + + generated + + (resources?.accessors ?? []), + exclude: excluded(target, prefix: prefix)), + visibility: .public)) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift index 3ed1271..0e6e4b0 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift @@ -23,6 +23,9 @@ extension SwiftPM { let manifest: Manifest /// `true` for a package in the project's own repository. let isLocal: Bool + /// `true` for the package that was handed to bazelize, as opposed to one + /// something else depends on. + let isRoot: Bool /// The name SwiftPM files the package's artifacts under. var identity: String { @@ -34,6 +37,9 @@ extension SwiftPM { struct Workspace { let packages: [Package] + /// What the build tool plugins of this project's own packages wrote. + let pluginOutputs: PluginOutputs + /// Where SwiftPM unpacked the binary targets it fetched. let artifacts: Path @@ -50,7 +56,7 @@ extension SwiftPM { /// checkouts are the sources the rules will point at. `dump-package` is read /// per checkout because it is the manifest SwiftPM itself evaluated — cheap, /// offline, and it spans every tools version in the graph. - static func loadWorkspace(output: Path) async throws -> Workspace { + static func loadWorkspace(output: Path, root input: Path?) async throws -> Workspace { try await resolve(output: output) let checkouts = output + ".build/checkouts" @@ -64,7 +70,10 @@ extension SwiftPM { directory: root.directory, root: root.path, manifest: manifest, - isLocal: root.isLocal) + isLocal: root.isLocal, + /// Both sides are made absolute: the output can be a relative path, + /// and the package handed in is named however the caller named it. + isRoot: input.map { $0.absolute().normalize() == root.path.absolute().normalize() } ?? false) packages.append(package) for identity in [manifest.name, root.directory, root.path.lastComponent] { @@ -74,6 +83,7 @@ extension SwiftPM { return .init( packages: packages, + pluginOutputs: await runPlugins(of: packages), artifacts: output + ".build/artifacts", directoryByIdentity: directoryByIdentity) } diff --git a/Sources/Xcode2/Model/Project/XCode+Project.swift b/Sources/Xcode2/Model/Project/XCode+Project.swift index 74abfdf..2b36cbd 100644 --- a/Sources/Xcode2/Model/Project/XCode+Project.swift +++ b/Sources/Xcode2/Model/Project/XCode+Project.swift @@ -43,7 +43,7 @@ extension XCode { return .init( name: root.lastComponent, workspacePath: root.string, - projectPath: manifest.string, + projectPath: (root + manifest.lastComponent).string, preferConfig: nil, configs: [:], packages: .init( @@ -55,6 +55,14 @@ extension XCode { } extension XCode.Project { + /// The package that was handed to bazelize, when the input was a manifest + /// rather than an `.xcodeproj`. + public var packageRoot: Path? { + let path = Path(projectPath) + guard path.lastComponent == "Package.swift" else { return nil } + return path.parent() + } + public var config: [String: XCode.BuildSettings]? { configs } diff --git a/Tests/XCode2Tests/PackageDeploymentTests.swift b/Tests/XCode2Tests/PackageDeploymentTests.swift index 66c09b4..78cfa0c 100644 --- a/Tests/XCode2Tests/PackageDeploymentTests.swift +++ b/Tests/XCode2Tests/PackageDeploymentTests.swift @@ -25,7 +25,8 @@ struct PackageDeploymentTests { directory: "Example", root: "/tmp/Example", manifest: manifest(platforms: platforms), - isLocal: false) + isLocal: false, + isRoot: false) } @Test From 7d3b572b3fe39dbf07b47a42e3ac4c654fd7770a Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 15:03:03 +0800 Subject: [PATCH 144/173] Describe the package input and how plugins are run --- README.md | 8 ++++++- docs/SPM.md | 62 ++++++++++++++++++++++++++++++++------------------ docs/SPM_ZH.md | 47 ++++++++++++++++++++++++++------------ 3 files changed, 79 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index ce91daa..3dfded3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bazelize -A cli tool turn your xcode project to bazel. +A cli tool turn your xcode project or Swift package to bazel. --- @@ -16,6 +16,12 @@ mint install XCodeBazelize/Bazelize bazelize --project YOUR.xcodeproj ``` +Or a Swift package — the `Package.swift`, or the directory holding one: + +```sh +bazelize --project path/to/Package.swift +``` + --- ## Bazel diff --git a/docs/SPM.md b/docs/SPM.md index 9d212a4..5dcffbb 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -58,6 +58,12 @@ The package BUILD files were not there; they were in ## Output +The input is an `.xcodeproj`, or a `Package.swift` — a package handed in directly +is loaded as the one local package of a project with nothing else in it, so +everything below is the same either way. The difference is what a package input +adds: its own test targets, as `swift_test`, because pointing the tool at a +package is pointing it at that package's tests. + ```text App/ ├── MODULE.bazel # no rspm @@ -137,6 +143,7 @@ No test pins how a package's rules are produced either. | binary target (xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | | binary target (local archive) | unarchived first, then as above | | executable target | `swift_binary` | +| test target of the package handed in | `swift_test` | | executable product | `alias` to the target's binary | | library product, one target | `alias` | | library product, several targets | `swift_library_group` | @@ -151,7 +158,9 @@ No test pins how a package's rules are produced either. | `interoperabilityMode` | `-cxx-interoperability-mode=` | | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | -| build tool plugin (SwiftLint etc.) | not run; the plugin is named at the end of the run (see below) | +| build tool plugin, own package | run by SwiftPM at generation time; the sources it wrote go into the target that asked for it | +| build tool plugin, dependency | not run; the plugin is named at the end of the run | +| command plugin | nothing: it runs when someone asks for it by name, never during a build | | macro target | `swift_compiler_plugin`, and `plugins` on whatever declares the macro | | traits (SE-0450) | expanded into `-D` and conditional deps per enabled trait | @@ -352,26 +361,35 @@ version a manifest states is reported here. ### Build tool plugins -A plugin is not run, and the plugin is named at the end of the run instead. Two -ways to change that were considered: - -- **Speak SwiftPM's plugin protocol.** A plugin is a program the host asks for - build commands over a pipe, and the request carries the whole package graph in - SwiftPM's own `HostToPluginMessage` format — an internal type, serialized by - some five hundred lines inside SwiftPM. Reimplementing that host ties bazelize - to a private schema that moves with every toolchain. -- **Let SwiftPM materialize the generated sources.** SwiftPM runs the plugins - when it builds a target and leaves their output under - `.build/plugins/outputs/`. Bazelize could build the plugin-using targets at - generation time and take those files into `srcs`, the way it takes everything - else SwiftPM already produced. The cost is a SwiftPM build of those packages - during generation, and generated sources that only change when bazelize runs - again — which is already true of every file bazelize writes. - -The second is the one to build when a package in the corpus generates source. -Every plugin in the corpus is a linter, so today neither is needed: the pieces a -plugin needs — an executable target, and a tool from a binary target — are -generated either way. +A plugin reads whatever it likes under the package directory and puts its output +into the target that asked for it, not into itself. TbCodeGenerater is the shape +of it: a plugin whose tool is an executable target of the same package, reading a +`.tb` file at the package root — a file that belongs to no target and is excluded +from one — and generating a source file for the package's test target. + +Declaring that to Bazel means knowing commands only the plugin can produce, and a +plugin produces them over a protocol private to SwiftPM: the host asks for build +commands over a pipe, and the request carries the whole package graph in +SwiftPM's own `HostToPluginMessage` format, serialized by some five hundred lines +inside SwiftPM. Reimplementing that host ties bazelize to a schema that moves +with every toolchain. + +So SwiftPM runs them. Building a target is what makes it run that target's +plugins — there is no command that only runs them — and it leaves the result +under `.build/plugins/outputs///`. Those files are linked into +`Generated/Plugin/` and compiled into the target that asked for the +plugin, the way everything else SwiftPM already produced is taken as it is. + +What that buys and costs: + +- A plugin's inputs need no declaring, and a `prebuildCommand` writing a whole + directory needs no tree artifact: whatever it wrote is globbed afterwards. +- The generated sources change when bazelize runs again, not when their inputs + do — already true of every file bazelize writes. +- Only a package in the project's own repository is built this way. Running a + plugin costs a SwiftPM build of its package, and doing that for every + dependency that merely lints would make generating a workspace unusable; a + dependency's plugin is named at the end of the run instead. ## Stages and exit criteria @@ -385,7 +403,7 @@ reason), plus the 114 unit tests and the iOS fixture. | 0.5 ✅ | the `//Packages` facade (aliases into rspm) | all apps; label shape settled | | 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind a flag, rspm still the default | 58 packages build on their own | | 2 ✅ | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude` / module maps), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | the 7 green apps build and run; every package of the other five builds | -| 3 | macro targets ✅; per-target platform versions ✅ (nothing to build — SwiftPM rejects such a graph, so the report is the answer); source-generating build tool plugins remain | when something outside the corpus needs it | +| 3 ✅ | macro targets; per-target platform versions (nothing to build — SwiftPM rejects such a graph, so the report is the answer); build tool plugins, run by SwiftPM at generation time | `spm/TbCodeGenerater`'s tests pass through a plugin-generated source | | 4 ✅ | the rspm dependency, `Patches/`, the version gate and the mode flag are gone | the 7 green apps build and run | Stage 4 removed the alternative rather than keeping a flag: two paths would diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index bfb155e..10f7251 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -52,6 +52,11 @@ package 的 BUILD 不在那裡,而在 ## 輸出 +輸入可以是 `.xcodeproj`,也可以是 `Package.swift`——直接傳進來的 package 會被當成 +「一個什麼都沒有的 project 底下唯一那個本地 package」來載入,所以以下結構兩種輸入都 +一樣。package 輸入多出來的是:它自己的測試 target 會產生成 `swift_test`,因為把工具 +指向一個 package,就是指向那個 package 的測試。 + ```text App/ ├── MODULE.bazel # 不再有 rspm @@ -126,6 +131,7 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 | binary target(xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | | binary target(本地 archive) | 先解壓,再同上 | | executable target | `swift_binary` | +| 傳進來那個 package 的測試 target | `swift_test` | | executable product | `alias` 指向該 target 的 binary | | library product,單一 target | `alias` | | library product,多個 target | `swift_library_group` | @@ -140,7 +146,9 @@ target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生 | `interoperabilityMode` | `-cxx-interoperability-mode=` | | `strictMemorySafety` | `-strict-memory-safety` | | `unsafeFlags` | `copts` | -| build tool plugin(SwiftLint 等) | 不執行;結束時把該 plugin 的名字講出來(見下) | +| build tool plugin(自己的 package) | 產生階段由 SwiftPM 執行;它寫出來的原始碼進「要求它的那個 target」 | +| build tool plugin(依賴的 package) | 不執行;結束時把該 plugin 的名字講出來 | +| command plugin | 不處理:它是有人指名才跑,build 永遠用不到 | | macro target | `swift_compiler_plugin`,並在宣告該 macro 的 target 上加 `plugins` | | traits(SE-0450) | 依 enabled traits 展開成 `-D` 與條件依賴 | @@ -323,21 +331,30 @@ for the macOS platform, but this target supports 12.0 ### build tool plugin -plugin 不會被執行,而是在結束時把它的名字講出來。要改變這件事,考慮過兩條路: +plugin 會讀 package 目錄下任何它想讀的檔案,而且產物是塞進**使用它的那個 target**, +不是塞回自己。TbCodeGenerater 就是這個形狀:plugin 用的工具是同一個 package 的 +executable target,它讀 package 根的一個 `.tb` 檔——那個檔不屬於任何 target,還被 +`exclude` 掉——然後為這個 package 的測試 target 產生一份原始碼。 + +要把這些告訴 Bazel,就得知道「只有 plugin 能產生」的那些 command;而 plugin 產生它們 +走的是 SwiftPM 的私有協定:host 透過 pipe 向 plugin 要 build command,請求裡帶著整張 +package graph,用 SwiftPM 自己的 `HostToPluginMessage` 格式,它內部用大約五百行在 +序列化。自己實作那個 host 等於綁在一個會隨 toolchain 變動的 schema 上。 + +所以讓 SwiftPM 去跑。「建那個 target」就是讓它跑該 target 的 plugin 的唯一方式——沒有 +只跑 plugin 的指令——跑完結果留在 `.build/plugins/outputs///`。那些 +檔案被連結到 `Generated/Plugin/`,並編進「要求該 plugin 的那個 target」, +和我們對待 SwiftPM 其他既有產物的方式一樣。 -- **自己實作 SwiftPM 的 plugin 協定。** plugin 是一個「host 透過 pipe 向它要 build - command」的程式,而那個請求裡帶著整張 package graph,用的是 SwiftPM 自己的 - `HostToPluginMessage` 格式——那是 internal type,SwiftPM 內部用大約五百行在序列化它。 - 自己實作 host 等於把 bazelize 綁在一個會跟著 toolchain 變動的私有 schema 上。 -- **讓 SwiftPM 幫我們產生原始碼。** SwiftPM 在建 target 時就會執行 plugin,並把輸出 - 留在 `.build/plugins/outputs/` 底下。bazelize 可以在產生階段建那些用到 plugin 的 - target,再把那些檔案收進 `srcs`——和它收 SwiftPM 既有產物的做法一樣。代價是產生階段 - 要跑一次 SwiftPM build,而且那些產生出來的原始碼只會在「再跑一次 bazelize」時更新 - ——不過這對 bazelize 寫出來的每個檔案本來就成立。 +換到什麼、付出什麼: -語料裡真的出現「會產生原始碼的 package」時,要做的是第二條。目前語料裡的 plugin 全是 -linter,所以兩條都還不需要:plugin 需要的零件——executable target、binary target 提供 -的工具——不論如何都已經會產生。 +- plugin 的輸入完全不用宣告;`prebuildCommand` 寫出一整個目錄也不需要 tree artifact: + 跑完再 glob 就好。 +- 產生的原始碼在「重跑 bazelize」時更新,不是在輸入改變時更新——這對 bazelize 寫出來的 + 每個檔案本來都成立。 +- 只對「專案自己 repository 裡的 package」這樣做。跑一次 plugin 等於用 SwiftPM 建一次 + 它的 package;對每個只做 lint 的依賴都建一次會讓產生工作癱掉,所以依賴的 plugin 是 + 在結束時具名告知。 ## 分階段與通過條件 @@ -350,7 +367,7 @@ linter,所以兩條都還不需要:plugin 需要的零件——executable ta | 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | | 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由一個 flag 切換,預設仍 rspm | 58 個 package 能單獨建起來 | | 2 ✅ | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`/module map)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 7 個綠燈 app 建得起來也跑得起來;另外五個的 package 全部建得起來 | -| 3 | macro target ✅;逐 target 的平台版本 ✅(不需要做——SwiftPM 自己就會拒絕這種圖,所以回報就是答案);會產生原始碼的 build tool plugin 還沒做 | 語料外的需求出現時再做 | +| 3 ✅ | macro target;逐 target 的平台版本(不需要做——SwiftPM 自己就會拒絕這種圖,所以回報就是答案);build tool plugin,由 SwiftPM 在產生階段執行 | `spm/TbCodeGenerater` 的測試靠 plugin 產生的原始碼通過 | | 4 ✅ | rspm 依賴、`Patches/`、版本守門與模式 flag 全部移除 | 7 個綠燈 app 建得起來也跑得起來 | 階段 4 是把另一條路整個移除,而不是留一個 flag:兩條路就是兩張依賴圖,而語料裡 From 5b78664bad06c7b5e0589df062e62ffe614fbb22 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 15:38:16 +0800 Subject: [PATCH 145/173] Mirror a package's sources when they link back into themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package can keep a symlink pointing at its own directory — GRDB's test fixtures link `GRDB -> ../..` — and Bazel cannot glob through the cycle: the whole package fails to load. Such a tree is mirrored entry by entry instead, without the link that closes the loop, which is also what SwiftPM does with it. Everything else keeps the single link a target's sources were. --- .../SwiftPM/SwiftPM+Generator.swift | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index 7bf3003..fb59409 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -277,11 +277,65 @@ extension SwiftPM { if link.isSymlink || link.exists { try? link.delete() } - try link.symlink(directory) + + /// One link for the whole directory is what a target's sources are, but + /// a package can keep a symlink pointing back into that directory — + /// GRDB's test fixtures do — and Bazel cannot glob through the cycle. + /// Such a tree is mirrored instead, entry by entry, without the link + /// that closes the loop. + if Self.hasCycle(directory) { + try Self.mirror(directory, at: link) + } else { + try link.symlink(directory) + } return prefix } + /// Whether anything under the directory links back into it. + private static func hasCycle(_ directory: Path) -> Bool { + let root = directory.url.resolvingSymlinksInPath().path + + for entry in entries(of: directory) where entry.isSymlink { + let resolved = entry.url.resolvingSymlinksInPath().path + if root == resolved || root.hasPrefix("\(resolved)/") { return true } + } + + return false + } + + /// A copy of the directory's shape, with one link per file. + private static func mirror(_ directory: Path, at destination: Path) throws { + try destination.mkpath() + + let root = directory.url.resolvingSymlinksInPath().path + for child in (try? directory.children()) ?? [] { + let target = destination + child.lastComponent + + if child.isSymlink { + let resolved = child.url.resolvingSymlinksInPath().path + /// The link that closes the loop; SwiftPM ignores it too. + if root == resolved || root.hasPrefix("\(resolved)/") { continue } + } + + if child.isDirectory { + try mirror(child, at: target) + } else { + try target.symlink(child) + } + } + } + + /// Everything under a directory, links included and not followed. + private static func entries(of directory: Path) -> [Path] { + let children = (try? directory.children()) ?? [] + + return children.flatMap { child -> [Path] in + guard !child.isSymlink, child.isDirectory else { return [child] } + return [child] + entries(of: child) + } + } + static let sourcesRoot = "Sources" /// A package rule is built through the bundle rule that transitions it to a From a3b9dda1317f5ce1b0e45b82711b78a2ca1029f2 Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 15:38:16 +0800 Subject: [PATCH 146/173] Copy a package's resources to where it copies them `.copy` keeps an item's own name and inner structure and nothing above it, while a structured resource keeps its whole path: GRDB's `Betty.jpeg` ended up at `Sources/GRDBTests/GRDBTests/Betty.jpeg` inside the bundle, and the test asking the bundle for `Betty.jpeg` found nothing. Each directory a copied item sits in becomes a resource group that strips the path above the item. A group and a glob cannot be added together in one attribute, so once there is a group the processed resources become one too. --- .../SwiftPM/SwiftPM+Resources.swift | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index e4aca7e..d5c416d 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -41,21 +41,25 @@ extension SwiftPM.Generator { let files = relativeFiles(of: target, in: package, prefix: prefix) - /// `.copy` keeps the item's own name and inner structure, which is what a - /// structured resource is; `.process` lets the bundler place each file. + /// `.copy` keeps the item's own name and inner structure and nothing above + /// it, which is a structured resource with the path above the item stripped; + /// `.process` lets the bundler place each file. var resources: [String] = [] - var structured: [String] = [] + var copied: [String: [String]] = [:] for resource in target.resources { let pattern = Self.pattern(of: resource.path, in: directory, prefix: prefix) if resource.isCopy { - structured.append(pattern) + let above = Path("\(prefix)/\(resource.path)").parent().normalize().string + copied[above, default: []].append(pattern) } else { resources.append(pattern) } } resources = matching(resources, files) + matching(Self.discoveredResources(prefix: prefix), files) - structured = matching(structured, files) + let structured = copied + .mapValues { matching($0, files) } + .filter { !$0.value.isEmpty } /// A shader compiles like any other source: it includes the target's /// headers, so they belong to the same resource group. The bundler treats a @@ -78,14 +82,49 @@ extension SwiftPM.Generator { let plist = "Generated/\(target.name)ResourceBundle-Info.plist" try (root + plist).write(Self.infoPlist(bundle: bundle)) + /// One group per directory a copied item sits in: the group is what can say + /// how much of the path to drop, so the item lands at the bundle's root the + /// way SwiftPM copies it. + var groups: [String] = [] + + /// Processed resources join the groups when there is one, so the bundle's + /// attribute stays one kind of thing. + if !structured.isEmpty, let patterns = resources.nonEmpty { + let group = "\(name)Processed" + groups.append(group) + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_group) + builder.call( + Rules.Apple.Resources.Call.apple_resource_group( + name: group, + resources: Starlark.glob(patterns))) + } + + for (index, prefixToStrip) in structured.keys.sorted().enumerated() { + guard let patterns = structured[prefixToStrip] else { continue } + + let group = "\(name)Copied\(index)" + groups.append(group) + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_group) + builder.call( + Rules.Apple.Resources.Call.apple_resource_group( + name: group, + strip_structured_resources_prefixes: [prefixToStrip], + structured_resources: Starlark.glob(patterns))) + } + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_bundle) builder.call( Rules.Apple.Resources.Call.apple_resource_bundle( name: name, bundle_name: bundle, infoplists: .build { [Starlark.Label.named(plist)] }, - resources: resources.nonEmpty.map { Starlark.glob($0) }, - structured_resources: structured.nonEmpty.map { Starlark.glob($0) }, + /// A glob and a group cannot be added together in one attribute, so + /// once there is a group everything is a group. + resources: groups.isEmpty + ? resources.nonEmpty.map { Starlark.glob($0) } + : .build { groups.map { Starlark.Label.named(":\($0)") } }, tags: Self.manual)) switch kind { From f61687d0552f23349e97be92e54abedfb5dcd79c Mon Sep 17 00:00:00 2001 From: yume190 Date: Thu, 17 Sep 2026 15:38:16 +0800 Subject: [PATCH 147/173] Bundle a package test's resources by running it as a test bundle A `swift_test` never bundles resources: `apple_resource_bundle` is something a bundling rule consumes, so `Bundle.module` had nothing to find and GRDB's tests died on the first resource they asked for. A test target is now a library plus the macOS test bundle over it, at the version the package asks of macOS. --- .../BazelizeKit/SwiftPM/SwiftPM+Test.swift | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift index 50a0134..1fc591c 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift @@ -12,12 +12,17 @@ import Starlark import Util extension SwiftPM.Generator { - /// A test target becomes a `swift_test`, for the package that was handed in. + /// A test target of the package handed in becomes a test bundle over a library + /// of its sources. /// - /// The tests of a package a project merely depends on are not the project's - /// tests: running them says nothing about the project, and they pull in test - /// dependencies nothing else needs. The tests of the package under the tool are - /// the whole point of pointing the tool at it. + /// The tests of a package a project merely depends on are not generated: + /// running them says nothing about the project, and they pull in dependencies + /// nothing else needs. The tests of the package under the tool are the whole + /// point of pointing the tool at it. + /// + /// It is a bundle rather than a plain `swift_test` because a test target has + /// resources like any other, and only a bundling rule puts them where + /// `Bundle.module` looks. func buildTest( _ target: SwiftPM.PackageTarget, in package: SwiftPM.Package, @@ -26,19 +31,17 @@ extension SwiftPM.Generator { resources: ResourceBundle?, builder: CodeBuilder) { - builder.load(loadableRule: Rules.Swift.swift_test) + let name = ruleName(of: target.name, in: package) + let library = "\(name)_library" + + builder.load(loadableRule: Rules.Swift.swift_library) builder.call( - Rules.Swift.Call.swift_test( - name: ruleName(of: target.name, in: package), + Rules.Swift.Call.swift_library( + name: library, + always_include_developer_search_paths: true, copts: copts(of: target).nonEmpty, - data: resources.map { bundle in - .build { [Starlark.Label.named(bundle.label)] } - }, - deps: deps(of: target, in: package).nonEmpty.map { labels in - .build { labels } - }, - linkopts: linkopts(of: target).nonEmpty, module_name: Self.moduleName(target.name), + package_name: package.manifest.name, srcs: Starlark.glob( matching( sources(of: target, prefix: prefix, extensions: ["swift"]), @@ -46,6 +49,25 @@ extension SwiftPM.Generator { + generated + (resources?.accessors ?? []), exclude: excluded(target, prefix: prefix)), + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + data: resources.map { bundle in + .build { [Starlark.Label.named(bundle.label)] } + }, + linkopts: linkopts(of: target).nonEmpty, + tags: Self.manual, + testonly: true, + visibility: .private)) + + builder.load(.macos_unit_test) + builder.call( + Rules.Apple.MacOS.Call.macos_unit_test( + name: name, + deps: .build { [Starlark.Label.named(":\(library)")] }, + /// A package's tests run where the tool runs, so the version is the + /// one the package asks of macOS. + minimum_os_version: deployment.required(package, platform: "macos"), visibility: .public)) } } From bc3e7cbd2fdcd9337a853c971b27ddec952cc153 Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 12:18:27 +0800 Subject: [PATCH 148/173] Take a package's local paths from the caller that wrote them The generated manifest's `path:` dependencies were read back out of it with a regular expression, to find the local packages whose sources are read in place. The plugin that writes that manifest already has them, so they are handed to the loader instead: one function and one failure mode fewer, and a local package can no longer go missing because the manifest could not be read. --- Sources/BazelizeKit/Kit.swift | 5 ++- .../SwiftPM/SwiftPM+Workspace.swift | 37 +++++++------------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 5d1bbd9..2c4b576 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -108,7 +108,10 @@ extension Kit { private final func generateSwiftPackages() async throws { let workspace = try await SwiftPM.loadWorkspace( output: outputRoot, - root: project.packageRoot) + root: project.packageRoot, + locals: project.packages.local.map { local in + project.workspaceRoot + local.relativePath + }) let deployment = await deployment() let generator = SwiftPM.Generator( diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift index 0e6e4b0..af82596 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift @@ -56,14 +56,19 @@ extension SwiftPM { /// checkouts are the sources the rules will point at. `dump-package` is read /// per checkout because it is the manifest SwiftPM itself evaluated — cheap, /// offline, and it spans every tools version in the graph. - static func loadWorkspace(output: Path, root input: Path?) async throws -> Workspace { + /// + /// `locals` are the packages of the project's own repository, the same ones the + /// generated manifest declares as `path:` dependencies. They are handed in + /// rather than read back out of that manifest: the caller that wrote it knows + /// them. + static func loadWorkspace(output: Path, root input: Path?, locals: [Path]) async throws -> Workspace { try await resolve(output: output) let checkouts = output + ".build/checkouts" var packages: [Package] = [] var directoryByIdentity: [String: String] = [:] - for root in try roots(output: output, checkouts: checkouts) { + for root in try roots(checkouts: checkouts, locals: locals) { guard let manifest = try await manifest(at: root.path) else { continue } let package = Package( @@ -113,9 +118,9 @@ extension SwiftPM { } } - /// Remote packages live in `.build/checkouts`; a local one is wherever the - /// manifest points, and is read in place. - private static func roots(output: Path, checkouts: Path) throws -> [Root] { + /// Remote packages live in `.build/checkouts`; a local one is wherever its + /// manifest is, and is read in place. + private static func roots(checkouts: Path, locals: [Path]) throws -> [Root] { var roots: [Root] = [] if checkouts.exists { @@ -124,29 +129,15 @@ extension SwiftPM { } } - for path in localPaths(output: output) { - let resolved = (output + path).normalize() - guard resolved.exists else { continue } - roots.append(.init(directory: resolved.lastComponent, path: resolved, isLocal: true)) + for path in locals { + let root = path.absolute().normalize() + guard root.exists else { continue } + roots.append(.init(directory: root.lastComponent, path: root, isLocal: true)) } return roots.sorted { $0.directory < $1.directory } } - /// The `path:` dependencies of the generated manifest. - private static func localPaths(output: Path) -> [String] { - guard let manifest: String = try? (output + "Package.swift").read() else { return [] } - - let pattern = #"\.package\(path:\s*"([^"]+)"\)"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } - - return regex.matches(in: manifest, range: NSRange(manifest.startIndex..., in: manifest)) - .compactMap { match in - guard let range = Range(match.range(at: 1), in: manifest) else { return nil } - return String(manifest[range]) - } - } - private static func manifest(at root: Path) async throws -> Manifest? { let result = try await Subprocess.run( .name("swift"), From b7ea30a8a2f320341ca548c2ce4edcafc759b5aa Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 12:18:37 +0800 Subject: [PATCH 149/173] Say which plugins did not run, and why A package in the project's own repository has its build tool plugins run while the workspace is generated, and the run said they were not: the report covered every package, and a plugin target was listed as an unsupported kind on top of that. Only a dependency's plugins go unrun, so only those are named. A plugin that was meant to run and could not is named as well, through the notes a run prints rather than the unified log nothing shows: what the target loses is whatever the plugin generates, and the compile error that follows names those files instead of the plugin. A plugin target is never a rule this workspace builds, whatever its capability, so the distinction between the two kinds no longer decides anything and is gone. --- .../SwiftPM/SwiftPM+Generator.swift | 31 +++++++------ .../SwiftPM/SwiftPM+Manifest.swift | 26 +++-------- .../BazelizeKit/SwiftPM/SwiftPM+Plugin.swift | 43 ++++++++++++------- 3 files changed, 50 insertions(+), 50 deletions(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index fb59409..1f5b9ba 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -37,6 +37,8 @@ extension SwiftPM { } func generate() throws { + notes.append(contentsOf: workspace.pluginOutputs.notes) + for package in workspace.packages { kinds[package.directory] = try supportedTargets(of: package) report(deploymentOf: package) @@ -65,13 +67,18 @@ extension SwiftPM { } } - /// A build tool plugin is not run. + /// A dependency's build tool plugin is not run. /// - /// Every plugin in the corpus is a linter, which produces no source: a - /// build without it is the same build. One that generates source would - /// leave a target missing the files it expects, and that compile error says - /// nothing about a plugin, so the plugin is named here instead. + /// The plugins of a package in the project's own repository are run while + /// the workspace is generated; a dependency's are not, because running one + /// costs a SwiftPM build of its package. Every plugin in the corpus is a + /// linter, which produces no source: a build without it is the same build. + /// One that generates source would leave a target missing the files it + /// expects, and that compile error says nothing about a plugin, so the + /// plugin is named here instead. private func report(pluginsOf package: Package) { + guard !package.isRoot, !package.isLocal else { return } + let used = package.manifest.targets .filter { $0.type != "test" } .flatMap(\.pluginUsages) @@ -366,15 +373,11 @@ extension SwiftPM { /// depends on say nothing about the project. return package.isRoot ? .test : nil case "plugin": - /// A command plugin runs when someone asks for it by name, so a - /// build never needs it. A build tool plugin does run while a - /// target is built, and not running it is what the run reports. - switch target.capability { - case .command: - return nil - case .buildTool, .none: - return .unsupported("a build tool plugin is not run") - } + /// A plugin is a program SwiftPM runs, never a rule this workspace + /// builds: a command plugin runs when someone asks for it by name, + /// and a build tool plugin runs while the workspace is generated. + /// Whether it ran is what the run reports. + return nil case "binary": return .binary case "system": diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift index b301299..08e3aa4 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - SwiftPM /// Generating Bazel rules for the Swift packages a project depends on. -public enum SwiftPM {} +public enum SwiftPM { } extension SwiftPM { /// A package manifest, as `swift package dump-package` prints it. @@ -76,15 +76,6 @@ extension SwiftPM { let dependencies: [TargetDependency] /// The plugins the target asks to be run while it is built. let pluginUsages: [PluginUsage] - /// `{"buildTool": null}` or `{"command": [...]}` for a plugin target. - let pluginCapability: [String: AnyDecodable?]? - - var capability: PluginCapability? { - guard let pluginCapability else { return nil } - if pluginCapability.keys.contains("command") { return .command } - if pluginCapability.keys.contains("buildTool") { return .buildTool } - return nil - } /// A binary target's remote archive. let url: String? let checksum: String? @@ -101,20 +92,11 @@ extension SwiftPM { resources = container.list(Resource.self, "resources") dependencies = container.list(TargetDependency.self, "dependencies") pluginUsages = container.list(PluginUsage.self, "pluginUsages") - pluginCapability = container.value([String: AnyDecodable?].self, "pluginCapability") url = container.value(String.self, "url") checksum = container.value(String.self, "checksum") } } - /// What a plugin target can be asked to do. - enum PluginCapability { - /// Runs while a target is built, and may generate source. - case buildTool - /// Runs when someone asks for it by name, never during a build. - case command - } - /// `{"plugin": ["SwiftLint", "SwiftLintPlugin"]}`: the plugin's name first, /// then the package it comes from, which is absent for one in the same /// package. @@ -295,11 +277,13 @@ extension KeyedDecodingContainer where Key == SwiftPM.AnyKey { try? decodeIfPresent(type, forKey: SwiftPM.AnyKey(key)) } - func list(_ type: T.Type, _ key: String) -> [T] { + func list(_: T.Type, _ key: String) -> [T] { (try? decodeIfPresent([T].self, forKey: SwiftPM.AnyKey(key))) ?? [] } } +// MARK: - AnyDecodable + /// Anything, decoded only to be ignored. struct AnyDecodable: Decodable { let value: Any? @@ -314,7 +298,7 @@ struct AnyDecodable: Decodable { } else if let value = try? container.decode(Bool.self) { self.value = value } else { - self.value = nil + value = nil } } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift index 00a31e1..12564fb 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift @@ -24,11 +24,17 @@ extension SwiftPM { /// building its package with SwiftPM, and doing that for every dependency that /// merely lints would make generating a workspace cost a full SwiftPM build. struct PluginOutputs: Sendable { + /// What kept a plugin from producing what a target expects, for the run to + /// say out loud: the compile error a missing generated file causes names + /// the file, never the plugin. + let notes: [String] + /// Keyed `/`. private let files: [String: [Path]] - init(files: [String: [Path]] = [:]) { + init(files: [String: [Path]] = [:], notes: [String] = []) { self.files = files + self.notes = notes } func files(of target: String, in package: Package) -> [Path] { @@ -40,6 +46,7 @@ extension SwiftPM { /// wrote. static func runPlugins(of packages: [Package]) async -> PluginOutputs { var files: [String: [Path]] = [:] + var notes: [String] = [] for package in packages where package.isRoot || package.isLocal { let targets = package.manifest.targets @@ -48,7 +55,10 @@ extension SwiftPM { guard !targets.isEmpty else { continue } for target in targets { - await build(target: target, of: package) + if let failure = await build(target: target, of: package) { + notes.append(failure) + continue + } let produced = outputs(of: target, in: package) guard !produced.isEmpty else { continue } @@ -56,14 +66,17 @@ extension SwiftPM { } } - return .init(files: files) + return .init(files: files, notes: notes) } // MARK: Private /// Building the target is what makes SwiftPM run its plugins; there is no /// command that only runs them. - private static func build(target: String, of package: Package) async { + /// + /// Returns why the plugins did not run, or `nil` when they did. + private static func build(target: String, of package: Package) async -> String? { + let failure: String do { let result = try await Subprocess.run( .name("swift"), @@ -75,19 +88,19 @@ extension SwiftPM { output: .discarded, error: .discarded) - guard result.terminationStatus.isSuccess else { - Log.codeGenerate.warning(""" - Cannot run the plugins of \(package.directory, privacy: .public)/\ - \(target, privacy: .public): swift build failed - """) - return - } + if result.terminationStatus.isSuccess { return nil } + failure = "swift build failed" } catch { - Log.codeGenerate.warning(""" - Cannot run the plugins of \(package.directory, privacy: .public)/\ - \(target, privacy: .public): \(error.localizedDescription, privacy: .public) - """) + failure = error.localizedDescription } + + let message = """ + \(package.directory)/\(target) did not run its plugins: \(failure). \ + The target is built with SwiftPM to run them, so whatever they generate \ + is missing from it. + """ + Log.codeGenerate.warning("\(message, privacy: .public)") + return message } /// `.build/plugins/outputs/////…` From d95bd160d356cbd9b4aac359437450a26cec5af2 Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 12:18:45 +0800 Subject: [PATCH 150/173] Split what a build tool plugin produced the way SwiftPM does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the Swift files a plugin wrote were taken, so a plugin that generates C sources, a header for them, or a resource wrote into a target that never saw them. SwiftPM splits what a plugin produced by the target's own file rules, into its sources and its resources, and that split is now mirrored: - an extension the target compiles goes into its `srcs`; - a header is neither compiled nor bundled. It is an input of the generated source beside it, which includes it by name — the only thing SwiftPM offers either, since a hand-written source cannot reach a generated header; - everything else is a resource, so a target whose only resources come from a plugin gets a bundle, as it does under SwiftPM. --- .../BazelizeKit/SwiftPM/SwiftPM+Clang.swift | 7 ++ .../SwiftPM/SwiftPM+Generator.swift | 78 +++++++++++++++---- .../BazelizeKit/SwiftPM/SwiftPM+Macro.swift | 4 +- .../BazelizeKit/SwiftPM/SwiftPM+Plugin.swift | 8 +- .../SwiftPM/SwiftPM+Resources.swift | 8 ++ 5 files changed, 84 insertions(+), 21 deletions(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift index eb26a11..4779c1b 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift @@ -20,6 +20,7 @@ extension SwiftPM.Generator { in package: SwiftPM.Package, prefix: String, root: Path, + generated: PluginGenerated, resources: ResourceBundle?, builder: CodeBuilder) { @@ -68,6 +69,12 @@ extension SwiftPM.Generator { ? [] : Self.headerExtensions.map { "\(prefix)/**/*.\($0)" }), files) + /// A plugin's output compiles like a source of the target, + /// and the header beside it is an input the same way a + /// private header is: the generated source includes it by + /// name, which is all SwiftPM offers either. + + generated.sources + + generated.headers + (resources?.accessors ?? []), exclude: excludedClang(target, prefix: prefix) + (headerPrefix.map { $0 == prefix ? [] : ["\($0)/**"] } ?? [])), diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index 1f5b9ba..181dd5f 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -136,7 +136,8 @@ extension SwiftPM { let generated = try materialize( pluginOutputsOf: target, in: package, - at: root) + at: root, + kind: kind) let resources = try buildResources( target, @@ -144,17 +145,23 @@ extension SwiftPM { prefix: prefix, root: root, kind: kind, + generated: generated.resources, builder: builder) switch kind { case .macro: - buildMacro(target, in: package, prefix: prefix, builder: builder) + buildMacro( + target, + in: package, + prefix: prefix, + generated: generated.sources, + builder: builder) case .executable: buildExecutable( target, in: package, prefix: prefix, - generated: generated, + generated: generated.sources, resources: resources, builder: builder) case .test: @@ -162,7 +169,7 @@ extension SwiftPM { target, in: package, prefix: prefix, - generated: generated, + generated: generated.sources, resources: resources, builder: builder) case .swift: @@ -170,7 +177,7 @@ extension SwiftPM { target, in: package, prefix: prefix, - generated: generated, + generated: generated.sources, resources: resources, builder: builder) case .clang: @@ -179,6 +186,7 @@ extension SwiftPM { in: package, prefix: prefix, root: root, + generated: generated, resources: resources, builder: builder) case .binary, .system, .unsupported: @@ -244,22 +252,29 @@ extension SwiftPM { return supported } - /// The sources stay where SwiftPM put them; the package directory carries - /// one link per target, the way a target's `Sources/` does. + /// What a plugin generated, linked next to the package's rules. /// - /// A link per target rather than one for the whole checkout is what keeps - /// the rest of the checkout out of the build: a package can ship `BUILD` - /// files of its own — swift-syntax and Yams both do — and Bazel would load - /// them as packages of this workspace. - /// The sources a plugin generated, linked next to the package's rules and - /// compiled into the target that asked for the plugin. + /// Split the way SwiftPM splits it: a file whose extension the target + /// compiles is a source of that target, anything else is one of its + /// resources. A header is neither compiled nor bundled — it is an input of + /// the generated source that includes it, which is the only thing SwiftPM + /// lets reach it too. + struct PluginGenerated { + let sources: [String] + let headers: [String] + let resources: [String] + + static let none = PluginGenerated(sources: [], headers: [], resources: []) + } + func materialize( pluginOutputsOf target: PackageTarget, in package: Package, - at root: Path) throws -> [String] + at root: Path, + kind: TargetKind) throws -> PluginGenerated { let files = workspace.pluginOutputs.files(of: target.name, in: package) - guard !files.isEmpty else { return [] } + guard !files.isEmpty else { return .none } let directory = "Generated/\(target.name)Plugin" let generated = root + directory @@ -268,13 +283,42 @@ extension SwiftPM { } try generated.mkpath() - return try files.map { file in + /// What the target's own rule compiles; a Swift target compiles Swift, + /// and a C-family one whatever clang takes. + let compiled: Set = { + if case .clang = kind { return Set(Self.compileExtensions) } + return ["swift"] + }() + + var sources: [String] = [] + var headers: [String] = [] + var resources: [String] = [] + + for file in files { let link = generated + file.lastComponent try link.symlink(file) - return "\(directory)/\(file.lastComponent)" + + let path = "\(directory)/\(file.lastComponent)" + let `extension` = file.extension ?? "" + if compiled.contains(`extension`) { + sources.append(path) + } else if Self.headerExtensions.contains(`extension`) { + headers.append(path) + } else { + resources.append(path) + } } + + return .init(sources: sources, headers: headers, resources: resources) } + /// The sources stay where SwiftPM put them; the package directory carries + /// one link per target, the way a target's `Sources/` does. + /// + /// A link per target rather than one for the whole checkout is what keeps + /// the rest of the checkout out of the build: a package can ship `BUILD` + /// files of its own — swift-syntax and Yams both do — and Bazel would load + /// them as packages of this workspace. private func materialize(_ target: PackageTarget, in package: Package, at root: Path) throws -> String? { guard let directory = sourceDirectory(of: target, in: package) else { return nil } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift index 274d2b8..09ac19f 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift @@ -22,6 +22,7 @@ extension SwiftPM.Generator { _ target: SwiftPM.PackageTarget, in package: SwiftPM.Package, prefix: String, + generated: [String], builder: CodeBuilder) { builder.load(loadableRule: Rules.Swift.swift_compiler_plugin) @@ -31,7 +32,8 @@ extension SwiftPM.Generator { srcs: Starlark.glob( matching( sources(of: target, prefix: prefix, extensions: ["swift"]), - relativeFiles(of: target, in: package, prefix: prefix)), + relativeFiles(of: target, in: package, prefix: prefix)) + + generated, exclude: excluded(target, prefix: prefix)), copts: copts(of: target).nonEmpty, deps: deps(of: target, in: package).nonEmpty.map { labels in diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift index 12564fb..1ae1245 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift @@ -104,12 +104,14 @@ extension SwiftPM { } /// `.build/plugins/outputs/////…` + /// + /// Every file, not only the Swift ones: SwiftPM splits what a plugin produced + /// into the target's sources and its resources, and a `prebuildCommand` + /// writes a whole directory whose contents it never names. private static func outputs(of target: String, in package: Package) -> [Path] { let root = package.root + ".build/plugins/outputs" + package.identity + target guard root.isDirectory else { return [] } - return Generator.walk(root).filter { file in - file.extension == "swift" - }.sorted() + return Generator.walk(root).sorted() } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index d5c416d..d321af2 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -29,12 +29,17 @@ extension SwiftPM.Generator { /// SwiftPM puts a target's resources in a bundle named `_` /// and compiles an accessor that finds it at runtime; a package reaches its /// own resources only through that pair, so both are generated here. + /// + /// `generated` are the files a build tool plugin produced that the target + /// does not compile. SwiftPM bundles those the same way, so a target whose + /// only resources come from a plugin still gets a bundle. func buildResources( _ target: SwiftPM.PackageTarget, in package: SwiftPM.Package, prefix: String, root: Path, kind: TargetKind, + generated: [String], builder: CodeBuilder) throws -> ResourceBundle? { guard let directory = sourceDirectory(of: target, in: package) else { return nil } @@ -57,6 +62,9 @@ extension SwiftPM.Generator { } resources = matching(resources, files) + matching(Self.discoveredResources(prefix: prefix), files) + /// A plugin's output is named as it was found on disk, so it needs no + /// matching against the target's own files. + + generated let structured = copied .mapValues { matching($0, files) } .filter { !$0.value.isEmpty } From 3c44576b9755ecdf1591b2ac161f3cb94d798ffb Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 12:18:52 +0800 Subject: [PATCH 151/173] Generate more than Swift from the fixture's plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus's plugins are all linters and TbCodeGenerater's writes Swift, so nothing proved a plugin's other outputs reach the target that asked for it. The fixture's local package now has a build tool plugin whose tool — the package's own executable target — writes a Swift file and a resource for a Swift target, and a C source with its header for the C-family one. Both targets use what it wrote, so the app fails to build if any of the three kinds is dropped. The header is declared rather than included by the hand-written source: SwiftPM puts a generated header on no search path of one, which is what the rules mirror. --- .gitignore | 3 + fixture/iOS/Local1/Package.swift | 10 +++- .../iOS/Local1/Plugins/Local1Gen/Plugin.swift | 29 +++++++++ .../iOS/Local1/Sources/Local1Tool/main.swift | 60 ++++++++++++++++++- .../Sources/LocalTarget1/LocalTarget1.swift | 7 +++ .../LocalTarget3/include/LocalTarget3.h | 2 + .../Sources/LocalTarget3/src/LocalTarget3.m | 8 +++ 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift diff --git a/.gitignore b/.gitignore index dcec68e..473ab1c 100644 --- a/.gitignore +++ b/.gitignore @@ -195,3 +195,6 @@ fixture/iOS/MODULE.bazel.lock fixture/iOS/Package.swift fixture/iOS/Package.resolved fixture/iOS/config.bazelrc + +# running a local package's build tool plugins resolves that package +fixture/iOS/Local1/Package.resolved diff --git a/fixture/iOS/Local1/Package.swift b/fixture/iOS/Local1/Package.swift index 865c197..37cf3c4 100644 --- a/fixture/iOS/Local1/Package.swift +++ b/fixture/iOS/Local1/Package.swift @@ -34,14 +34,20 @@ let package = Package( ]), .target( name: "LocalTarget1", - dependencies: ["RxSwift", "Local1Macros"]), + dependencies: ["RxSwift", "Local1Macros"], + plugins: ["Local1Gen"]), .target( name: "LocalTarget2", dependencies: ["RxSwift"]), .target( - name: "LocalTarget3"), + name: "LocalTarget3", + plugins: ["Local1Gen"]), .executableTarget( name: "Local1Tool"), + .plugin( + name: "Local1Gen", + capability: .buildTool(), + dependencies: ["Local1Tool"]), .testTarget( name: "Local1Tests", dependencies: ["LocalTarget1"]), diff --git a/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift b/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift new file mode 100644 index 0000000..660f199 --- /dev/null +++ b/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift @@ -0,0 +1,29 @@ +import Foundation +import PackagePlugin + +/// A build tool plugin whose tool writes more than Swift: the sources a target +/// compiles, a header those sources include, and a resource it bundles. +/// +/// The package's own executable target is the tool, the way TbCodeGenerater's is. +@main +struct Local1Gen: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + let tool = try context.tool(named: "Local1Tool") + let directory = context.pluginWorkDirectory + + /// A C-family target compiles what the plugin writes as C; a Swift one + /// compiles the Swift and bundles the rest. + let kind = target.name == "LocalTarget3" ? "clang" : "swift" + let outputs = kind == "clang" + ? ["LocalTarget3Generated.c", "LocalTarget3Generated.h"] + : ["Local1Generated.swift", "local1-generated.json"] + + return [ + .buildCommand( + displayName: "Generate \(kind) files for \(target.name)", + executable: tool.path, + arguments: ["--kind", kind, "--output", directory.string], + outputFiles: outputs.map { directory.appending($0) }), + ] + } +} diff --git a/fixture/iOS/Local1/Sources/Local1Tool/main.swift b/fixture/iOS/Local1/Sources/Local1Tool/main.swift index 7e1c2d7..4008ab9 100644 --- a/fixture/iOS/Local1/Sources/Local1Tool/main.swift +++ b/fixture/iOS/Local1/Sources/Local1Tool/main.swift @@ -1,2 +1,58 @@ -/// A tool the package builds, which is what a build tool plugin would run. -print("Local1Tool") +import Foundation + +/// A tool the package builds, which is what a build tool plugin runs. +/// +/// Without arguments it is just a command line tool, which is what the binary +/// rule for it builds; with them it writes the files the plugin declared as its +/// outputs. +func argument(_ name: String) -> String? { + guard let index = CommandLine.arguments.firstIndex(of: name) else { return nil } + let value = CommandLine.arguments.index(after: index) + return value < CommandLine.arguments.endIndex ? CommandLine.arguments[value] : nil +} + +guard let output = argument("--output"), let kind = argument("--kind") else { + print("Local1Tool") + exit(0) +} + +let directory = URL(fileURLWithPath: output, isDirectory: true) + +func write(_ contents: String, to name: String) throws { + try contents.write( + to: directory.appendingPathComponent(name), + atomically: true, + encoding: .utf8) +} + +switch kind { +case "clang": + try write( + """ + int local1_plugin_value(void); + """, + to: "LocalTarget3Generated.h") + try write( + """ + #include "LocalTarget3Generated.h" + + int local1_plugin_value(void) { + return 42; + } + """, + to: "LocalTarget3Generated.c") +default: + try write( + """ + /// Written by the Local1Gen build tool plugin. + public enum Local1Generated { + public static let value = 42 + } + """, + to: "Local1Generated.swift") + try write( + """ + {"generatedBy": "Local1Gen"} + """, + to: "local1-generated.json") +} diff --git a/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift b/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift index 8840b19..ec6db63 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift +++ b/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift @@ -4,6 +4,8 @@ public macro stringify(_ value: T) -> (T, String) = #externalMacro( module: "Local1Macros", type: "StringifyMacro") +// MARK: - LocalTarget1 + public struct LocalTarget1 { public private(set) var text = "Hello, World!" @@ -13,4 +15,9 @@ public struct LocalTarget1 { public static var stringified: (Int, String) { #stringify(1 + 1) } + + /// What the package's build tool plugin generated. + public static var generated: Int { + Local1Generated.value + } } diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h index 220e5ae..bd6131e 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h +++ b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h @@ -12,6 +12,8 @@ NS_ASSUME_NONNULL_BEGIN @interface LocalTarget3 : NSObject + (int) test; - (int) test2; +/// What the package's build tool plugin generated. +- (int) generated; @end NS_ASSUME_NONNULL_END diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m index 4e62bd7..a844ef9 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m +++ b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m @@ -7,6 +7,11 @@ #import "LocalTarget3.h" +/// Written by the Local1Gen build tool plugin, which SwiftPM compiles into this +/// target: its header is on no search path of a hand-written source, so the +/// symbol is declared rather than included. +extern int local1_plugin_value(void); + @implementation LocalTarget3 + (int) test { return 1 << 4; @@ -14,4 +19,7 @@ + (int) test { - (int) test2 { return LocalTarget3.test; } +- (int) generated { + return local1_plugin_value(); +} @end From 97c6bdc8cb2288a2f2cf49eb74d44dbf9fe1063d Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 12:18:58 +0800 Subject: [PATCH 152/173] Describe how a plugin's output is split, and what stays in the output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwiftPM notes now say which of a plugin's files become sources, which is an include of the generated source beside it, and which are bundled, plus that a plugin which could not run is named. Two decisions are recorded rather than left open: `Package.swift` and `Package.resolved` stay because `.build/checkouts` is where a rule's sources live and only those two rebuild it, and every SwiftPM step stays a toolchain command instead of libSwiftPM — measured at 0.6s per manifest, and slower when run concurrently. --- docs/SPM.md | 57 ++++++++++++++++++++++++++++++++++++++++++-------- docs/SPM_ZH.md | 46 +++++++++++++++++++++++++++++++++------- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/docs/SPM.md b/docs/SPM.md index 5dcffbb..1c0d956 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -67,7 +67,7 @@ package is pointing it at that package's tests. ```text App/ ├── MODULE.bazel # no rspm -├── Package.swift # kept: SwiftPM still resolves the graph +├── Package.swift # kept: the only way to rebuild .build/checkouts ├── Package.resolved # kept: the only source of pins ├── config.bazelrc ├── BUILD @@ -114,6 +114,39 @@ The alternative — one `git_repository` per remote package, pinned to the revision in `Package.resolved` — is hermetic but reintroduces external repos and fetches sources Bazel already has on disk. +`Package.swift` and `Package.resolved` therefore stay in the output. The sources +a rule globs live in `.build/checkouts`, and `swift package resolve` in the +output directory is the only thing that can put them back — on a fresh clone, or +after `.build` is cleaned. They are not there for Bazel to read, which is what +rspm needed them for: a mandatory `swift = "//:Package.swift"` label whose +directory its module extension ran SwiftPM in, on every evaluation of the +extension. + +### Who runs SwiftPM + +Every SwiftPM step is the installed toolchain's `swift` command: `swift package +resolve` for the checkouts, `swift package dump-package` per checkout for the +manifests, and `swift build` to run a build tool plugin. Not libSwiftPM, even +though this package already links `SwiftPMDataModel` for the legacy `XCode` +target. + +- Plugins cannot move there. Running one needs a build system, and + `SwiftPMDataModel` is deliberately the data model alone — `Build`, + `SPMLLBuild` and SwiftDriver are only in the full `SwiftPM` product. Resolving + with a pinned library while plugins build with the installed toolchain would + put two versions of SwiftPM in one `.build`: the checkouts, the + `Package.resolved` format and the manifest cache would belong to whichever ran + last. One SwiftPM — the same one Xcode uses — is the property worth keeping. +- The dependency is a branch (`swift-6.4.0-RELEASE`, matching the toolchain), + and libSwiftPM says of itself that the API is unstable and may change at any + time. `dump-package`'s JSON spans every tools version in the graph, and it is + decoded into the few fields the generator reads. +- The cost is measured: 0.6s per manifest, so 10.8s for this repository's 18 + checkouts. Running them concurrently is slower, not faster — 14.5s with eight + at a time, consistent with contention on the shared manifest cache — so the + loop stays sequential. An app in the corpus has around ten packages, which is + the six seconds a single `loadPackageGraph` would save. + ### Label naming Every package product — remote or local — has one shape in `Targets/*/BUILD`: @@ -377,8 +410,16 @@ with every toolchain. So SwiftPM runs them. Building a target is what makes it run that target's plugins — there is no command that only runs them — and it leaves the result under `.build/plugins/outputs///`. Those files are linked into -`Generated/Plugin/` and compiled into the target that asked for the -plugin, the way everything else SwiftPM already produced is taken as it is. +`Generated/Plugin/` and handed to the target that asked for the plugin +the way SwiftPM splits them itself: + +- an extension the target compiles (`.swift` for a Swift target, `.c`/`.m`/… for + a C-family one) goes into its `srcs`; +- a header is neither compiled nor bundled: it is an input of the generated + source beside it, which includes it by name — and that is all SwiftPM offers + either, since a hand-written source cannot reach a generated header; +- everything else is a resource of that target, so a target whose only resources + come from a plugin gets a bundle, exactly as it does under SwiftPM. What that buys and costs: @@ -390,6 +431,9 @@ What that buys and costs: plugin costs a SwiftPM build of its package, and doing that for every dependency that merely lints would make generating a workspace unusable; a dependency's plugin is named at the end of the run instead. +- A plugin that could not run is named too: what the target loses is whatever + the plugin generates, and the compile error names those files rather than the + plugin. ## Stages and exit criteria @@ -412,10 +456,5 @@ app in the corpus. ## Open questions -1. Does `Package.swift` still need to be part of the output? Only - `swift package resolve` reads it, so it could be generated only when pins - are updated. -2. Which stage supports registry packages (`.package(id:)`)? Nothing in the +1. Which stage supports registry packages (`.package(id:)`)? Nothing in the corpus uses one. -3. Should the four rspm patches still go upstream? They are small and useful to - whoever still uses rspm. diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 10f7251..80828d8 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -60,7 +60,7 @@ package 的 BUILD 不在那裡,而在 ```text App/ ├── MODULE.bazel # 不再有 rspm -├── Package.swift # 保留:仍用 SwiftPM 解析依賴圖 +├── Package.swift # 保留:重建 .build/checkouts 的唯一途徑 ├── Package.resolved # 保留:pin 的唯一來源 ├── config.bazelrc ├── BUILD @@ -105,6 +105,32 @@ manifest 所在的位置,就地讀取。 的 revision 釘住:那是 hermetic 的,但又把 external repo 帶回來,還會重抓一份 Bazel 手上已經有的原始碼。 +所以 `Package.swift` 和 `Package.resolved` 留在產物裡。規則 glob 的原始碼位於 +`.build/checkouts`,而唯一能把它們放回去的就是在產物目錄裡跑 +`swift package resolve`——新 clone、或清掉 `.build` 之後都是。它們不是給 Bazel 讀的, +那是 rspm 需要它們的理由:`swift = "//:Package.swift"` 是 mandatory label,它的 +module extension 每次評估都在那個 label 所在目錄跑 SwiftPM。 + +### SwiftPM 由誰執行 + +每一步 SwiftPM 都是使用者安裝的 toolchain 的 `swift` 指令:`swift package resolve` +取得 checkouts、每個 checkout 一次 `swift package dump-package` 讀 manifest、 +`swift build` 讓 build tool plugin 跑起來。不是 libSwiftPM,即使本 package 已經為 +舊的 `XCode` target 連了 `SwiftPMDataModel`。 + +- plugin 這一步搬不過去:跑它需要 build system,而 `SwiftPMDataModel` 刻意只有 + data model——`Build`、`SPMLLBuild` 與 SwiftDriver 只在完整的 `SwiftPM` product 裡。 + 用釘住的 library 解析、卻用安裝的 toolchain 建 plugin,等於同一個 `.build` 被兩個 + 版本的 SwiftPM 寫:checkouts、`Package.resolved` 格式、manifest cache 都屬於最後 + 跑的那個。「只有一個 SwiftPM,而且和 Xcode 用的是同一個」是值得保留的性質。 +- 這個依賴釘的是 branch(`swift-6.4.0-RELEASE`,對上 toolchain),而 libSwiftPM 自己 + 聲明 API 不穩定、隨時可能改。`dump-package` 的 JSON 橫跨依賴圖裡所有 tools version, + 而且只被解碼成產生器真正要讀的那幾個欄位。 +- 成本量過了:一份 manifest 0.6 秒,本 repo 的 18 個 checkout 共 10.8 秒。改成併發 + 更慢而不是更快——同時跑八個是 14.5 秒,和共用 manifest cache 的競爭一致——所以迴圈 + 維持序列。語料裡一個 app 大約十個 package,那六秒就是換成一次 `loadPackageGraph` + 能省下的全部。 + ### Label 命名 所有 package product——遠端或本地——在 `Targets/*/BUILD` 裡都是同一個形狀: @@ -343,8 +369,15 @@ package graph,用 SwiftPM 自己的 `HostToPluginMessage` 格式,它內部 所以讓 SwiftPM 去跑。「建那個 target」就是讓它跑該 target 的 plugin 的唯一方式——沒有 只跑 plugin 的指令——跑完結果留在 `.build/plugins/outputs///`。那些 -檔案被連結到 `Generated/Plugin/`,並編進「要求該 plugin 的那個 target」, -和我們對待 SwiftPM 其他既有產物的方式一樣。 +檔案被連結到 `Generated/Plugin/`,並按 SwiftPM 自己的分法交給「要求該 plugin +的那個 target」: + +- target 自己編的副檔名(Swift target 的 `.swift`、C 系 target 的 `.c`/`.m`/…)進 + `srcs`。 +- header 既不編也不打包,它是「它旁邊那份產生原始碼」的輸入——那份原始碼用檔名 include + 它,而 SwiftPM 也只允許這樣:手寫的原始碼 include 不到產生的 header。 +- 其餘一切都是 resource,進該 target 的 resource bundle。只有 plugin 產生 resource 的 + target 也因此會有 bundle,和 SwiftPM 一樣。 換到什麼、付出什麼: @@ -355,6 +388,8 @@ package graph,用 SwiftPM 自己的 `HostToPluginMessage` 格式,它內部 - 只對「專案自己 repository 裡的 package」這樣做。跑一次 plugin 等於用 SwiftPM 建一次 它的 package;對每個只做 lint 的依賴都建一次會讓產生工作癱掉,所以依賴的 plugin 是 在結束時具名告知。 +- 跑不起來也會具名告知:那個 target 少掉的是 plugin 該產生的檔案,而 Bazel 端的編譯 + 錯誤只會提到那些檔案,不會提到 plugin。 ## 分階段與通過條件 @@ -375,7 +410,4 @@ package graph,用 SwiftPM 自己的 `HostToPluginMessage` 格式,它內部 ## 待決事項 -1. `Package.swift` 是否還需要出現在產物裡?只有 `swift package resolve` 需要它, - 可以改成只在更新 pin 時才產生。 -2. registry package(`.package(id:)`)階段幾支援?目前語料沒有。 -3. 上游 rspm PR 還要不要送?那 4 個 patch 很小,對還在用 rspm 的人也有用。 +1. registry package(`.package(id:)`)階段幾支援?目前語料沒有。 From e92e9a6c61b86e3170445f997ad520da3cabb211 Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 17:12:20 +0800 Subject: [PATCH 153/173] Fill in the build settings Xcode answers for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project references Xcode's own settings as freely as its own, and nobody can type them in: iina writes `$(SDK_VERSION)` and `$(XCODE_VERSION_ACTUAL)` into its `Info.plist`, and neither appears anywhere in the project file, so both keys were dropped for carrying a reference that could not be resolved. They are read from the installed toolchain instead, as defaults a project that states one of them itself still wins over: `xcodebuild -showsdks` answers for every platform's SDK name and version at once, and `xcodebuild -version` for the four digit number Xcode spells itself as. `CONFIGURATION` needs no asking — it is the name of the configuration being generated. `xcodebuild -showBuildSettings` knows all of them without a table, but it resolves the package graph and takes some ten seconds per target; these two commands take under a second for a whole run. The platform is the one a target resolves to rather than whatever `SDKROOT` says, because that setting is optional and `auto` names no SDK. --- .../Xcode2/Loader/XCode+TargetLoader.swift | 24 +++- Sources/Xcode2/Loader/XCode+Toolchain.swift | 127 ++++++++++++++++++ .../Model/Config/XCode+BuildSettings.swift | 9 ++ Tests/XCode2Tests/ToolchainTests.swift | 37 +++++ 4 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 Sources/Xcode2/Loader/XCode+Toolchain.swift create mode 100644 Tests/XCode2Tests/ToolchainTests.swift diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode2/Loader/XCode+TargetLoader.swift index 8ee07db..15c3ec7 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode2/Loader/XCode+TargetLoader.swift @@ -20,13 +20,23 @@ struct TargetLoader { /// settings reference them freely (`INFOPLIST_FILE = $(SRCROOT)/...`). let workspace = project.workspacePath.string mergedConfig = configList.merge(defaultConfigList).mapValues { settings in - settings.with(overrides: [ - "TARGET_NAME": native.name, - "PROJECT_NAME": project.name, - "SRCROOT": workspace, - "SOURCE_ROOT": workspace, - "PROJECT_DIR": workspace, - ]) + settings + .with(overrides: [ + "TARGET_NAME": native.name, + "PROJECT_NAME": project.name, + "SRCROOT": workspace, + "SOURCE_ROOT": workspace, + "PROJECT_DIR": workspace, + ]) + /// What the toolchain answers for, and the configuration being + /// built — defaults, because a project that states one of them + /// itself means it: iina writes `CONFIGURATION` into an xcconfig. + /// + /// The platform is resolved rather than read: `SDKROOT` is + /// optional, and `auto` names no SDK at all. + .with(defaults: Toolchain + .settings(sdk: settings.platform.resolvedSDK?.rawValue) + .merging(["CONFIGURATION": settings.name]) { _, new in new }) } } diff --git a/Sources/Xcode2/Loader/XCode+Toolchain.swift b/Sources/Xcode2/Loader/XCode+Toolchain.swift new file mode 100644 index 0000000..09fbd1c --- /dev/null +++ b/Sources/Xcode2/Loader/XCode+Toolchain.swift @@ -0,0 +1,127 @@ +import Foundation + +// MARK: - Toolchain + +/// The build settings Xcode fills in from the installed toolchain rather than +/// from the project. +/// +/// A project references them as freely as its own: iina writes +/// `$(SDK_VERSION)` and `$(XCODE_VERSION_ACTUAL)` into its `Info.plist`, and +/// neither appears anywhere in the project file — Xcode is the one that knows +/// them, so nobody can be asked to type them in. +/// +/// `xcodebuild -showBuildSettings` knows every one of them, but it resolves the +/// package graph and takes some ten seconds per target, so the values are read +/// from the toolchain directly: two commands for a whole run, both of which +/// answer for every platform at once. +enum Toolchain { + /// What Xcode would set for a target built against `sdk`, which is the + /// project's `SDKROOT`. + static func settings(sdk: String?) -> [String: String] { + guard let platform = canonical(sdk), let sdk = sdks[platform] else { return xcode } + + return xcode.merging([ + "PLATFORM_NAME": platform, + "SDK_NAME": sdk.name, + "SDK_VERSION": sdk.version, + ]) { _, new in new } + } + + // MARK: Private + + private struct SDK: Decodable { + let canonicalName: String + let sdkVersion: String + let platform: String + } + + /// `SDKROOT` is a platform name (`macosx`), a canonical SDK name + /// (`macosx26.0`), `auto`, or a path. Only a name identifies a platform + /// without building the target first. + private static func canonical(_ sdk: String?) -> String? { + guard + let sdk = sdk?.lowercased(), + !sdk.isEmpty, + sdk != "auto", + !sdk.contains("/") + else { + return nil + } + + return sdks[sdk] != nil ? sdk : sdks.first { _, value in value.name == sdk }?.key + } + + /// Every installed SDK, by platform: one `xcodebuild -showsdks` answers for + /// all of them, and the newest of a platform is the one Xcode builds with. + private static let sdks: [String: (name: String, version: String)] = { + guard + let output = run("xcodebuild", ["-showsdks", "-json"]), + let sdks = try? JSONDecoder().decode([SDK].self, from: Data(output.utf8)) + else { + return [:] + } + + var result: [String: (name: String, version: String)] = [:] + for sdk in sdks { + let current = result[sdk.platform] + if let current, current.version.compare(sdk.sdkVersion, options: .numeric) != .orderedAscending { + continue + } + result[sdk.platform] = (name: sdk.canonicalName, version: sdk.sdkVersion) + } + + return result + }() + + /// How Xcode spells its own version: a four digit number, so 27.0 is `2700` + /// and 14.3.1 is `1431`. `MAJOR` keeps only the major, `MINOR` drops the + /// patch. + static func settings(xcodeVersion version: String) -> [String: String] { + let components = version.split(separator: ".").compactMap { Int($0) } + guard let major = components.first else { return [:] } + let minor = components.count > 1 ? components[1] : 0 + let patch = components.count > 2 ? components[2] : 0 + + return [ + "XCODE_VERSION_ACTUAL": "\(major * 100 + minor * 10 + patch)", + "XCODE_VERSION_MAJOR": "\(major * 100)", + "XCODE_VERSION_MINOR": "\(major * 100 + minor * 10)", + ] + } + + /// `xcodebuild -version` says `Xcode 27.0` on its first line. + private static let xcode: [String: String] = { + guard + let output = run("xcodebuild", ["-version"]), + let version = output.split(separator: "\n").first?.split(separator: " ").last + else { + return [:] + } + + return settings(xcodeVersion: String(version)) + }() + + /// A toolchain that cannot be asked leaves the settings unset, which is what + /// they already were. + private static func run(_ executable: String, _ arguments: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/\(executable)") + process.arguments = arguments + + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + return String(data: data, encoding: .utf8) + } +} diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift index 3737b19..3d83611 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift @@ -50,6 +50,15 @@ extension XCode { }) } + /// Values for the settings this configuration does not state itself. + func with(defaults: [String: String]) -> BuildSettings { + .init( + name: name, + setting: setting.merging(defaults) { current, _ in + current + }) + } + public subscript(key: String) -> String? { resolved(setting[key], visited: [key]) } diff --git a/Tests/XCode2Tests/ToolchainTests.swift b/Tests/XCode2Tests/ToolchainTests.swift new file mode 100644 index 0000000..1254ad7 --- /dev/null +++ b/Tests/XCode2Tests/ToolchainTests.swift @@ -0,0 +1,37 @@ +import Testing +@testable import XCode2 + +/// The values Xcode fills in from the toolchain, which a project writes into its +/// `Info.plist` and expects to read back in Xcode's own spelling. +struct ToolchainTests { + @Test + func xcodeVersionIsSpelledAsFourDigits() { + #expect(Toolchain.settings(xcodeVersion: "27.0") == [ + "XCODE_VERSION_ACTUAL": "2700", + "XCODE_VERSION_MAJOR": "2700", + "XCODE_VERSION_MINOR": "2700", + ]) + + #expect(Toolchain.settings(xcodeVersion: "14.3") == [ + "XCODE_VERSION_ACTUAL": "1430", + "XCODE_VERSION_MAJOR": "1400", + "XCODE_VERSION_MINOR": "1430", + ]) + + /// A patch release counts, and only `ACTUAL` carries it. + #expect(Toolchain.settings(xcodeVersion: "14.3.1") == [ + "XCODE_VERSION_ACTUAL": "1431", + "XCODE_VERSION_MAJOR": "1400", + "XCODE_VERSION_MINOR": "1430", + ]) + } + + @Test + func aVersionThatIsNoVersionSetsNothing() { + /// A toolchain that cannot be asked leaves the settings unset, so the + /// reference stays unresolved and the key that carries it is dropped — + /// rather than reaching a bundle as `0`. + #expect(Toolchain.settings(xcodeVersion: "").isEmpty) + #expect(Toolchain.settings(xcodeVersion: "Xcode").isEmpty) + } +} From 0a4e628e2956846303507d7b5e1519680345a020 Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 17:12:25 +0800 Subject: [PATCH 154/173] Record that registry packages are not implemented No stage implements `.package(id:)`: nothing in the corpus uses one, and the work is recognising one more kind of dependency rather than changing the shape of the output, so the open question becomes a stated non-goal with what happens until then. The note about substituting `$(SETTING)` in a plist says where that landed instead. --- Notes.md | 7 ++++--- docs/SPM.md | 12 ++++++++---- docs/SPM_ZH.md | 7 +++++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Notes.md b/Notes.md index 5248434..8aa9612 100644 --- a/Notes.md +++ b/Notes.md @@ -12,6 +12,7 @@ Targets/iina/Sources/iina/MPVController.swift:152:29: error: cannot find 'MPV_FO 154 | MPVProperty.mediaTitle: MPV_FORMAT_STRING, ``` -plist 有一些 $(xxx) 需要一些取代 -可以在 bazel 或者 swift 層處理 - +plist 的 `$(xxx)`:專案自己宣告的(pbxproj/xcconfig)與 Xcode 從 toolchain 帶入的 +(`SDK_VERSION`、`XCODE_VERSION_*`、`SDK_NAME`、`PLATFORM_NAME`、`CONFIGURATION`) +都在 Swift 層取代掉了,剩下 `plisttool` 自己認的那幾個原樣交給 rules_apple。 +只存在於 CI 環境或 secret 的設定仍然解不出來——那種 key 會被丟掉並具名回報。 diff --git a/docs/SPM.md b/docs/SPM.md index 1c0d956..4c51d19 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -454,7 +454,11 @@ Stage 4 removed the alternative rather than keeping a flag: two paths would mean two dependency graphs, and the generated one is at least as good on every app in the corpus. -## Open questions - -1. Which stage supports registry packages (`.package(id:)`)? Nothing in the - corpus uses one. +## Not done + +- **Registry packages (`.package(id:)`)**: no stage implements them. Nothing in + the corpus uses one, and SwiftPM resolves them into checkouts itself, so the + work is recognising one more kind of dependency rather than changing the shape + of the output. Until then a registry package's targets are skipped as + unresolvable and named at the end of the run, like every other unsupported + kind. diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 80828d8..6168775 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -408,6 +408,9 @@ package graph,用 SwiftPM 自己的 `HostToPluginMessage` 格式,它內部 階段 4 是把另一條路整個移除,而不是留一個 flag:兩條路就是兩張依賴圖,而語料裡 每個 app 用自製產生器的結果都不比 rspm 差。 -## 待決事項 +## 不做的事 -1. registry package(`.package(id:)`)階段幾支援?目前語料沒有。 +- **registry package(`.package(id:)`)**:目前的階段都不實作。語料裡沒有任何一個, + 而 SwiftPM 自己會把它解析進 checkouts,所以要做的時候是「多認一種 dependency 種類」, + 不是改產出的形狀。撞到的時候:該 package 的 target 會被當成解不到而略過並具名回報, + 這和其他不支援的種類一樣。 From dbcd5834618de40ba611f9e75310f1ed3ae58aeb Mon Sep 17 00:00:00 2001 From: yume190 Date: Fri, 18 Sep 2026 21:10:13 +0800 Subject: [PATCH 155/173] Keep a plugin's output where the plugin wrote it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every file found under a target's plugin output directory was linked into one flat directory by its name alone, and everything found there was taken as the target's own. Neither holds: a plugin has a directory of its own under that root and writes a tree inside it if it likes, and SwiftPM leaves the files a previous build declared in place rather than deleting them — so a run picked up a file no plugin produces any more and bundled it. The output directory of a target is therefore removed before SwiftPM is asked to build it, and what it then contains is linked keeping its path. The fixture's plugin writes its resource into a subdirectory to hold that. --- .../SwiftPM/SwiftPM+Generator.swift | 22 ++++++-- .../BazelizeKit/SwiftPM/SwiftPM+Plugin.swift | 56 ++++++++++++------- .../iOS/Local1/Plugins/Local1Gen/Plugin.swift | 2 +- .../iOS/Local1/Sources/Local1Tool/main.swift | 13 +++-- 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index 181dd5f..ba49c3d 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -273,8 +273,9 @@ extension SwiftPM { at root: Path, kind: TargetKind) throws -> PluginGenerated { - let files = workspace.pluginOutputs.files(of: target.name, in: package) - guard !files.isEmpty else { return .none } + guard let output = workspace.pluginOutputs.output(of: target.name, in: package) else { + return .none + } let directory = "Generated/\(target.name)Plugin" let generated = root + directory @@ -294,11 +295,22 @@ extension SwiftPM { var headers: [String] = [] var resources: [String] = [] - for file in files { - let link = generated + file.lastComponent + let base = output.root.normalize().string + for file in output.files { + /// Where the file sits under the directory the plugins wrote into, + /// kept as it is: a plugin of the target has a directory of its + /// own there and writes a tree inside it if it likes, and renaming + /// that into one flat directory is a rename nothing asked for. + let relative = file.normalize().string + .delete(prefix: base) + .trimmingCharacters(in: ["/"]) + guard !relative.isEmpty else { continue } + + let link = generated + relative + try link.parent().mkpath() try link.symlink(file) - let path = "\(directory)/\(file.lastComponent)" + let path = "\(directory)/\(relative)" let `extension` = file.extension ?? "" if compiled.contains(`extension`) { sources.append(path) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift index 1ae1245..126d22a 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift @@ -24,28 +24,37 @@ extension SwiftPM { /// building its package with SwiftPM, and doing that for every dependency that /// merely lints would make generating a workspace cost a full SwiftPM build. struct PluginOutputs: Sendable { + /// What one target's plugins wrote, and the directory they wrote it into: + /// two plugins of the same target write into one directory each, and a + /// `prebuildCommand` writes a tree, so a file is only named by where it + /// sits under that root. + struct Output: Sendable { + let root: Path + let files: [Path] + } + /// What kept a plugin from producing what a target expects, for the run to /// say out loud: the compile error a missing generated file causes names /// the file, never the plugin. let notes: [String] /// Keyed `/`. - private let files: [String: [Path]] + private let outputs: [String: Output] - init(files: [String: [Path]] = [:], notes: [String] = []) { - self.files = files + init(outputs: [String: Output] = [:], notes: [String] = []) { + self.outputs = outputs self.notes = notes } - func files(of target: String, in package: Package) -> [Path] { - files["\(package.directory)/\(target)"] ?? [] + func output(of target: String, in package: Package) -> Output? { + outputs["\(package.directory)/\(target)"] } } /// Runs the plugins of the packages this project owns, and collects what they /// wrote. static func runPlugins(of packages: [Package]) async -> PluginOutputs { - var files: [String: [Path]] = [:] + var outputs: [String: PluginOutputs.Output] = [:] var notes: [String] = [] for package in packages where package.isRoot || package.isLocal { @@ -55,18 +64,23 @@ extension SwiftPM { guard !targets.isEmpty else { continue } for target in targets { + /// What a previous run left there is not what the plugins produce + /// now: SwiftPM names the files it declared and leaves the rest, + /// while everything found here is taken as the target's own. + try? outputsRoot(of: target, in: package).delete() + if let failure = await build(target: target, of: package) { notes.append(failure) continue } - let produced = outputs(of: target, in: package) - guard !produced.isEmpty else { continue } - files["\(package.directory)/\(target)"] = produced + let produced = self.outputs(of: target, in: package) + guard !produced.files.isEmpty else { continue } + outputs["\(package.directory)/\(target)"] = produced } } - return .init(files: files, notes: notes) + return .init(outputs: outputs, notes: notes) } // MARK: Private @@ -104,14 +118,18 @@ extension SwiftPM { } /// `.build/plugins/outputs/////…` - /// - /// Every file, not only the Swift ones: SwiftPM splits what a plugin produced - /// into the target's sources and its resources, and a `prebuildCommand` - /// writes a whole directory whose contents it never names. - private static func outputs(of target: String, in package: Package) -> [Path] { - let root = package.root + ".build/plugins/outputs" + package.identity + target - guard root.isDirectory else { return [] } - - return Generator.walk(root).sorted() + private static func outputsRoot(of target: String, in package: Package) -> Path { + package.root + ".build/plugins/outputs" + package.identity + target + } + + /// Every file a target's plugins wrote, not only the Swift ones: SwiftPM + /// splits what a plugin produced into the target's sources and its resources, + /// and a `prebuildCommand` writes a whole directory whose contents it never + /// names. + private static func outputs(of target: String, in package: Package) -> PluginOutputs.Output { + let root = outputsRoot(of: target, in: package) + guard root.isDirectory else { return .init(root: root, files: []) } + + return .init(root: root, files: Generator.walk(root).sorted()) } } diff --git a/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift b/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift index 660f199..3a69ac9 100644 --- a/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift +++ b/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift @@ -16,7 +16,7 @@ struct Local1Gen: BuildToolPlugin { let kind = target.name == "LocalTarget3" ? "clang" : "swift" let outputs = kind == "clang" ? ["LocalTarget3Generated.c", "LocalTarget3Generated.h"] - : ["Local1Generated.swift", "local1-generated.json"] + : ["Local1Generated.swift", "assets/local1-generated.json"] return [ .buildCommand( diff --git a/fixture/iOS/Local1/Sources/Local1Tool/main.swift b/fixture/iOS/Local1/Sources/Local1Tool/main.swift index 4008ab9..58dae91 100644 --- a/fixture/iOS/Local1/Sources/Local1Tool/main.swift +++ b/fixture/iOS/Local1/Sources/Local1Tool/main.swift @@ -19,10 +19,11 @@ guard let output = argument("--output"), let kind = argument("--kind") else { let directory = URL(fileURLWithPath: output, isDirectory: true) func write(_ contents: String, to name: String) throws { - try contents.write( - to: directory.appendingPathComponent(name), - atomically: true, - encoding: .utf8) + let file = directory.appendingPathComponent(name) + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), + withIntermediateDirectories: true) + try contents.write(to: file, atomically: true, encoding: .utf8) } switch kind { @@ -50,9 +51,11 @@ default: } """, to: "Local1Generated.swift") + /// In a directory of its own, because a plugin writes wherever it likes under + /// the output directory and what it wrote has to keep its place. try write( """ {"generatedBy": "Local1Gen"} """, - to: "local1-generated.json") + to: "assets/local1-generated.json") } From 466ef29f1ef2f31dcf9824f3e0c49d0275011d60 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 08:48:44 +0800 Subject: [PATCH 156/173] Retire the first Xcode parser and give its name to the second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two modules read the same `.xcodeproj`: `XCode`, which nothing but the plugin loader still imported, and `XCode2`, which everything that generates a workspace is written against. The first one is gone, and the second holds the name — spelled the way Apple spells the product, so the module, its types, its files and the `bazelize xcode` subcommand all read `Xcode`. Removing it takes libSwiftPM with it: `SwiftPMDataModel` was linked for one function in the old parser, and it pulled SwiftDriver, llbuild, swift-crypto and the rest behind it. The package resolves 9 dependencies now instead of 18, and no longer pins a branch to match the toolchain — which is what the SwiftPM notes said the alternative to the `swift` command would cost. `XCodeBazelize` and the `XCodeProj` repo enum keep their spelling: one is the organisation, the other names generated files. --- Package.resolved | 87 +---- Package.swift | 34 +- Sources/Bazelize/Command.swift | 14 +- .../Bazel/Bazel+PrebuiltBUILD.swift | 10 +- .../Codegen/Codegen+Autolink.swift | 2 +- .../Codegen/Codegen+CopyFiles.swift | 4 +- .../BazelizeKit/Codegen/Codegen+Headers.swift | 2 +- .../Codegen/Codegen+Platform.swift | 4 +- .../BazelizeKit/Codegen/Codegen+Plist.swift | 4 +- .../Codegen/Codegen+StaticFramework.swift | 4 +- .../Codegen/Codegen+TestHost.swift | 2 +- .../Resource/Codegen+AssetSymbols.swift | 2 +- .../Resource/Codegen+Entitlements.swift | 2 +- .../Codegen/Resource/Codegen+Resources.swift | 2 +- Sources/BazelizeKit/Module.swift | 2 +- .../Roadmap/BazelizeKit+Roadmap.swift | 12 +- .../SwiftPM/SwiftPM+Deployment.swift | 2 +- .../{XCode2Compat.swift => XcodeCompat.swift} | 16 +- Sources/Cocoapod/Pod.swift | 2 +- Sources/PluginLoader/Core.swift | 4 +- Sources/PluginLoader/Plugin.swift | 6 +- Sources/PluginLoader/PluginLoader.swift | 6 +- Sources/XCode/Model/BuildSetting+PList.swift | 312 ------------------ Sources/XCode/Model/BuildSettings.swift | 238 ------------- Sources/XCode/Model/ConfigList.swift | 69 ---- Sources/XCode/Model/SPMParser.swift | 48 --- Sources/XCode/Model/XCode+File.swift | 92 ------ Sources/XCode/Model/XCode+Preffer.swift | 46 --- Sources/XCode/Model/XCode+Project.swift | 160 --------- .../XCode/Model/XCode+RemoteSPMPackage.swift | 36 -- Sources/XCode/Model/XCode+SPM.swift | 249 -------------- Sources/XCode/Model/XCode+Select.swift | 51 --- Sources/XCode/Model/XCode+Target.swift | 302 ----------------- Sources/XCode/Model/XCodeSPM.swift | 84 ----- Sources/XCode/Setting/DeviceFamily.swift | 22 -- Sources/XCode/Setting/ExplicitFileType.swift | 16 - Sources/XCode/Setting/LastKnownFileType.swift | 37 --- .../Loader/Xcode+ConfigListLoader.swift} | 4 +- .../Loader/Xcode+FileLoader.swift} | 4 +- .../Loader/Xcode+ProjectLoader.swift} | 32 +- .../Loader/Xcode+TargetLoader.swift} | 28 +- .../Loader/Xcode+Toolchain.swift} | 0 .../Xcode+BuildSettings+AssetCatalog.swift} | 4 +- .../Xcode+BuildSettings+Metadata.swift} | 4 +- .../Config/Xcode+BuildSettings+PList.swift} | 6 +- .../Xcode+BuildSettings+Platform.swift} | 8 +- .../Model/Config/Xcode+BuildSettings.swift} | 8 +- .../Model/Config/Xcode+DeviceFamily.swift} | 2 +- .../Model/File/Xcode+File.swift} | 2 +- .../Model/File/Xcode+Files.swift} | 6 +- .../Model/Phase/Xcode+BuildPhase.swift} | 6 +- .../Model/Phase/Xcode+BuildPhaseFile.swift} | 2 +- .../Phase/Xcode+CopyFilesDestination.swift} | 2 +- .../Model/Project/Xcode+Project.swift} | 8 +- .../Model/SwiftPM/Xcode+LocalPackage.swift} | 2 +- .../Xcode+PackageProductDependency.swift} | 2 +- .../Model/SwiftPM/Xcode+Packages.swift} | 2 +- .../Model/SwiftPM/Xcode+RemotePackage.swift} | 2 +- .../Model/Target/Xcode+CodeSign.swift} | 2 +- .../Model/Target/Xcode+Dependencies.swift} | 6 +- .../Model/Target/Xcode+Target.swift} | 18 +- .../Model/Target/Xcode+TargetMetadata.swift} | 2 +- .../TargetSummaryFormatter.swift | 12 +- Sources/Xcode/Xcode.swift | 1 + Sources/Xcode2/XCode.swift | 1 - Tests/XCodeTests/PropertyTests.swift | 134 -------- .../BuildSettingsTests.swift | 16 +- .../EncodingTests.swift | 4 +- .../PackageDeploymentTests.swift | 0 .../ProjectLoaderTests.swift | 12 +- .../RoadmapTreeBuilderTests.swift | 2 +- .../TargetSummaryFormatterTests.swift | 8 +- .../ToolchainTests.swift | 2 +- docs/SPM.md | 21 +- docs/SPM_ZH.md | 12 +- 75 files changed, 187 insertions(+), 2185 deletions(-) rename Sources/BazelizeKit/{XCode2Compat.swift => XcodeCompat.swift} (95%) delete mode 100644 Sources/XCode/Model/BuildSetting+PList.swift delete mode 100644 Sources/XCode/Model/BuildSettings.swift delete mode 100644 Sources/XCode/Model/ConfigList.swift delete mode 100644 Sources/XCode/Model/SPMParser.swift delete mode 100644 Sources/XCode/Model/XCode+File.swift delete mode 100644 Sources/XCode/Model/XCode+Preffer.swift delete mode 100644 Sources/XCode/Model/XCode+Project.swift delete mode 100644 Sources/XCode/Model/XCode+RemoteSPMPackage.swift delete mode 100644 Sources/XCode/Model/XCode+SPM.swift delete mode 100644 Sources/XCode/Model/XCode+Select.swift delete mode 100644 Sources/XCode/Model/XCode+Target.swift delete mode 100644 Sources/XCode/Model/XCodeSPM.swift delete mode 100644 Sources/XCode/Setting/DeviceFamily.swift delete mode 100644 Sources/XCode/Setting/ExplicitFileType.swift delete mode 100644 Sources/XCode/Setting/LastKnownFileType.swift rename Sources/{Xcode2/Loader/XCode+ConfigListLoader.swift => Xcode/Loader/Xcode+ConfigListLoader.swift} (97%) rename Sources/{Xcode2/Loader/XCode+FileLoader.swift => Xcode/Loader/Xcode+FileLoader.swift} (99%) rename Sources/{Xcode2/Loader/XCode+ProjectLoader.swift => Xcode/Loader/Xcode+ProjectLoader.swift} (92%) rename Sources/{Xcode2/Loader/XCode+TargetLoader.swift => Xcode/Loader/Xcode+TargetLoader.swift} (96%) rename Sources/{Xcode2/Loader/XCode+Toolchain.swift => Xcode/Loader/Xcode+Toolchain.swift} (100%) rename Sources/{Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift => Xcode/Model/Config/Xcode+BuildSettings+AssetCatalog.swift} (89%) rename Sources/{Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift => Xcode/Model/Config/Xcode+BuildSettings+Metadata.swift} (90%) rename Sources/{Xcode2/Model/Config/XCode+BuildSettings+PList.swift => Xcode/Model/Config/Xcode+BuildSettings+PList.swift} (97%) rename Sources/{Xcode2/Model/Config/XCode+BuildSettings+Platform.swift => Xcode/Model/Config/Xcode+BuildSettings+Platform.swift} (92%) rename Sources/{Xcode2/Model/Config/XCode+BuildSettings.swift => Xcode/Model/Config/Xcode+BuildSettings.swift} (98%) rename Sources/{Xcode2/Model/Config/XCode+DeviceFamily.swift => Xcode/Model/Config/Xcode+DeviceFamily.swift} (97%) rename Sources/{Xcode2/Model/File/XCode+File.swift => Xcode/Model/File/Xcode+File.swift} (95%) rename Sources/{Xcode2/Model/File/XCode+Files.swift => Xcode/Model/File/Xcode+Files.swift} (94%) rename Sources/{Xcode2/Model/Phase/XCode+BuildPhase.swift => Xcode/Model/Phase/Xcode+BuildPhase.swift} (95%) rename Sources/{Xcode2/Model/Phase/XCode+BuildPhaseFile.swift => Xcode/Model/Phase/Xcode+BuildPhaseFile.swift} (92%) rename Sources/{Xcode2/Model/Phase/XCode+CopyFilesDestination.swift => Xcode/Model/Phase/Xcode+CopyFilesDestination.swift} (90%) rename Sources/{Xcode2/Model/Project/XCode+Project.swift => Xcode/Model/Project/Xcode+Project.swift} (97%) rename Sources/{Xcode2/Model/SwiftPM/XCode+LocalPackage.swift => Xcode/Model/SwiftPM/Xcode+LocalPackage.swift} (87%) rename Sources/{Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift => Xcode/Model/SwiftPM/Xcode+PackageProductDependency.swift} (90%) rename Sources/{Xcode2/Model/SwiftPM/XCode+Packages.swift => Xcode/Model/SwiftPM/Xcode+Packages.swift} (87%) rename Sources/{Xcode2/Model/SwiftPM/XCode+RemotePackage.swift => Xcode/Model/SwiftPM/Xcode+RemotePackage.swift} (96%) rename Sources/{Xcode2/Model/Target/XCode+CodeSign.swift => Xcode/Model/Target/Xcode+CodeSign.swift} (90%) rename Sources/{Xcode2/Model/Target/XCode+Dependencies.swift => Xcode/Model/Target/Xcode+Dependencies.swift} (95%) rename Sources/{Xcode2/Model/Target/XCode+Target.swift => Xcode/Model/Target/Xcode+Target.swift} (94%) rename Sources/{Xcode2/Model/Target/XCode+TargetMetadata.swift => Xcode/Model/Target/Xcode+TargetMetadata.swift} (94%) rename Sources/{Xcode2 => Xcode}/TargetSummaryFormatter.swift (93%) create mode 100644 Sources/Xcode/Xcode.swift delete mode 100644 Sources/Xcode2/XCode.swift delete mode 100644 Tests/XCodeTests/PropertyTests.swift rename Tests/{XCode2Tests => XcodeTests}/BuildSettingsTests.swift (93%) rename Tests/{XCode2Tests => XcodeTests}/EncodingTests.swift (90%) rename Tests/{XCode2Tests => XcodeTests}/PackageDeploymentTests.swift (100%) rename Tests/{XCode2Tests => XcodeTests}/ProjectLoaderTests.swift (90%) rename Tests/{XCode2Tests => XcodeTests}/RoadmapTreeBuilderTests.swift (99%) rename Tests/{XCode2Tests => XcodeTests}/TargetSummaryFormatterTests.swift (96%) rename Tests/{XCode2Tests => XcodeTests}/ToolchainTests.swift (98%) diff --git a/Package.resolved b/Package.resolved index 228516d..3b0c719 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3dd5a307c2bc9d60c7468d94a1bce6cef8c59cfc0a2c274a756a49f39eb215b1", + "originHash" : "367c470b5fb9aa556b189bf5dff00798443731c0091aef8a9932c4496586252c", "pins" : [ { "identity" : "aexml", @@ -46,69 +46,6 @@ "version" : "1.8.2" } }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "d9a5b37470adc940d22c3bcd5ca6953a516b727f", - "version" : "1.7.2" - } - }, - { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "branch" : "1.10.1", - "revision" : "386001a92200c70fd06217b3ccad58d7226edb84" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "branch" : "1.1.6", - "revision" : "c11818f3cae0780656baa430b49e7f163f08dffd" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "branch" : "3.12.5", - "revision" : "d79c573e1b400d670ed12c0cb29d33f2c0f5ab70" - } - }, - { - "identity" : "swift-driver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-driver.git", - "state" : { - "branch" : "release/6.4.x", - "revision" : "174567a5681a9a949bcd52f821deb8fa65105434" - } - }, - { - "identity" : "swift-llbuild", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-llbuild.git", - "state" : { - "branch" : "release/6.4.x", - "revision" : "ab6421207b9e4971c94e97c5832a3d8a4cae9092" - } - }, - { - "identity" : "swift-package-manager", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-package-manager", - "state" : { - "branch" : "swift-6.4.0-RELEASE", - "revision" : "18da3eb1e770679f6910890fbd52e95af53f67d2" - } - }, { "identity" : "swift-subprocess", "kind" : "remoteSourceControl", @@ -123,26 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "branch" : "1.5.0", - "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6" - } - }, - { - "identity" : "swift-toolchain-sqlite", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-toolchain-sqlite.git", - "state" : { - "branch" : "1.0.9", - "revision" : "c0ecc1e0fd1b4fbc38db1efa6113bc12c2a4e559" - } - }, - { - "identity" : "swift-tools-support-core", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-tools-support-core.git", - "state" : { - "branch" : "release/6.4.x", - "revision" : "d45c8b38d2824498b7863d3d5f0227937a53c177" + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } }, { diff --git a/Package.swift b/Package.swift index 57a662e..80ac670 100644 --- a/Package.swift +++ b/Package.swift @@ -22,12 +22,6 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-subprocess", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), - - /// SwiftPMDataModel for the legacy `XCode` target, pinned to the release - /// that matches the toolchain; it is what sets this package's macOS floor. - .package( - url: "https://github.com/swiftlang/swift-package-manager", - branch: "swift-6.4.0-RELEASE"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -38,7 +32,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), "PathKit", "BazelizeKit", - "XCode2", + "Xcode", ]), .executableTarget( name: "RepoEnumGenerator", @@ -71,7 +65,7 @@ let package = Package( "PathKit", "BazelRules", - "XCode2", + "Xcode", "Util", "Starlark", "PluginLoader", @@ -112,40 +106,26 @@ let package = Package( dependencies: ["Util"]), .target( - name: "XCode", - dependencies: [ - "Util", - "Starlark", - "AnyCodable", - - .product(name: "XcodeProj", package: "XcodeProj"), - .product(name: "SwiftPMDataModel-auto", package: "swift-package-manager"), - ]), - .target( - name: "XCode2", + name: "Xcode", dependencies: [ "PathKit", "AnyCodable", .product(name: "XcodeProj", package: "XcodeProj"), - ], - path: "Sources/XCode2"), + ]), .testTarget( - name: "XCode2Tests", - dependencies: ["XCode2", "BazelizeKit"]), + name: "XcodeTests", + dependencies: ["Xcode", "BazelizeKit"]), .testTarget( name: "RepoEnumCoreTests", dependencies: ["RepoEnumCore"]), - .testTarget( - name: "XCodeTests", - dependencies: ["XCode"]), .target( name: "PluginLoader", dependencies: [ "PathKit", "Util", - "XCode", + "Xcode", .product(name: "Subprocess", package: "swift-subprocess"), ]), ]) diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index 748d7cf..a8ca249 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -9,7 +9,7 @@ import ArgumentParser import BazelizeKit import Foundation import PathKit -import XCode2 +import Xcode // MARK: - Command @@ -21,7 +21,7 @@ struct Command: AsyncParsableCommand { version: version, subcommands: [ GenerateCommand.self, - XCode2Command.self, + XcodeCommand.self, // RoadmapCommand.self, ], defaultSubcommand: GenerateCommand.self) @@ -73,11 +73,11 @@ struct GenerateCommand: AsyncParsableCommand { } } -// MARK: - XCode2Command +// MARK: - XcodeCommand -struct XCode2Command: AsyncParsableCommand { +struct XcodeCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "xcode2", + commandName: "xcode", abstract: "Dump an Xcode project structure as JSON or print one target summary.") @Option(name: [.customLong("project", withSingleDash: false)], help: "PATH/TO/YOUR.xcodeproj") @@ -93,14 +93,14 @@ struct XCode2Command: AsyncParsableCommand { func run() async throws { let path = Path.current + project - let dump = try XCode.Project.load(path: path, preferConfig: config) + let dump = try Xcode.Project.load(path: path, preferConfig: config) if let printTarget { guard let target = dump.targets.first(where: { $0.name == printTarget }) else { throw ValidationError("Target '\(printTarget)' not found.") } - print(XCode.TargetSummaryFormatter.format(project: dump, target: target)) + print(Xcode.TargetSummaryFormatter.format(project: dump, target: target)) return } diff --git a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift index e61702d..efb0980 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift @@ -1,7 +1,7 @@ import BazelRules import PathKit import Starlark -import XCode2 +import Xcode extension Bazel { struct PrebuiltBuild: BazelFile { @@ -50,7 +50,7 @@ extension Bazel { /// Checked-in `.a`/`.dylib` binaries; `cc_import` is the only rule that takes /// a bare library and still exposes it to Swift and Objective-C targets. - private func buildLibraries(_ staticLibraries: [XCode.File], _ dynamicLibraries: [XCode.File]) { + private func buildLibraries(_ staticLibraries: [Xcode.File], _ dynamicLibraries: [Xcode.File]) { guard !staticLibraries.isEmpty || !dynamicLibraries.isEmpty else { return } builder.load(.cc_import) @@ -73,7 +73,7 @@ extension Bazel { } } - private func buildXCFrameworks(_ files: [XCode.File]) { + private func buildXCFrameworks(_ files: [Xcode.File]) { guard !files.isEmpty else { return } builder.load(.apple_dynamic_xcframework_import) @@ -90,7 +90,7 @@ extension Bazel { } } - private func buildFrameworks(_ files: [XCode.File]) { + private func buildFrameworks(_ files: [Xcode.File]) { guard !files.isEmpty else { return } builder.load(.apple_dynamic_framework_import) @@ -107,7 +107,7 @@ extension Bazel { } } - private func unique(_ files: [XCode.File]) -> [XCode.File] { + private func unique(_ files: [Xcode.File]) -> [Xcode.File] { var seen = Set() return files.filter { file in guard let path = file.path, !path.isEmpty else { return false } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift b/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift index 54d0ee3..d2e29d5 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift @@ -1,7 +1,7 @@ import Foundation import PathKit import Util -import XCode2 +import Xcode extension Target { /// What the rule declares: the frameworks the project links plus the ones its diff --git a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift index 738368b..42f1528 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift @@ -2,7 +2,7 @@ import BazelRules import Foundation import PathKit import Starlark -import XCode2 +import Xcode extension Target { /// Products Xcode copies into the bundle outside the framework and extension @@ -234,7 +234,7 @@ extension Project { } } -extension XCode2.XCode.BuildPhase { +extension Xcode.BuildPhase { /// Where a copy phase lands, relative to `Contents`. /// /// `nil` for a destination another rule attribute owns — a framework or an diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift index da010cb..78c310c 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -1,6 +1,6 @@ import Foundation import PathKit -import XCode2 +import Xcode /// Headers Xcode resolves through its implicit header map. /// diff --git a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift index 6ad1fca..85d64fc 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift @@ -1,4 +1,4 @@ -import XCode2 +import Xcode extension Target { /// The platform the target builds for. @@ -20,6 +20,6 @@ extension Target { return declared.map(\.code) } - return platformSDK == .iOS ? [XCode.DeviceFamily.iphone.code, XCode.DeviceFamily.ipad.code] : nil + return platformSDK == .iOS ? [Xcode.DeviceFamily.iphone.code, Xcode.DeviceFamily.ipad.code] : nil } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index f0023bc..a3b8308 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -285,7 +285,7 @@ extension String { /// plist_auto /// -/// plist properties written in XCode config with prefix `INFOPLIST_KEY_` +/// plist properties written in Xcode config with prefix `INFOPLIST_KEY_` extension Target { // MARK: Internal @@ -327,7 +327,7 @@ extension Target { /// plist_default /// -/// Needed plist properties written in XCode config +/// Needed plist properties written in Xcode config extension Target { // MARK: Internal diff --git a/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift b/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift index 4c391d0..202555f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift @@ -31,7 +31,7 @@ // # Cocoapod Deps // \(podDeps.indent(2)) // -// # XCode SPM Deps +// # Xcode SPM Deps // \(xcodeSPMDeps.indent(2)) // ], // ) @@ -47,7 +47,7 @@ // infoplists = [":Info.plist"], // deps = [":_\(name)"], // frameworks = [ -// # XCode Target Deps +// # Xcode Target Deps // \(xcodeDeps) // ], // ) diff --git a/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift b/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift index d2a9969..1286ecf 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift @@ -1,7 +1,7 @@ import Foundation import PathKit import Starlark -import XCode2 +import Xcode extension Target { /// The application a unit-test bundle is loaded into, from `TEST_HOST` or diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift index 9cc1e43..9121116 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift @@ -2,7 +2,7 @@ import BazelRules import Foundation import PathKit import Starlark -import XCode2 +import Xcode /// `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` /// diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift index c62df0b..fbae587 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift @@ -2,7 +2,7 @@ import Foundation import PathKit import Starlark import Util -import XCode2 +import Xcode extension Target { /// The entitlements Xcode signs with, rewritten into the generated tree. diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift index 3ed955a..eb32f36 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift @@ -2,7 +2,7 @@ import BazelRules import Foundation import PathKit import Starlark -import XCode2 +import Xcode extension Target { static let resourceGroupName = "Resources" diff --git a/Sources/BazelizeKit/Module.swift b/Sources/BazelizeKit/Module.swift index 5022501..8941268 100644 --- a/Sources/BazelizeKit/Module.swift +++ b/Sources/BazelizeKit/Module.swift @@ -2,4 +2,4 @@ @_exported import Foundation @_exported import PathKit @_exported import Starlark -@_exported import XCode2 +@_exported import Xcode diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift index 3d90609..3f03ffd 100644 --- a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -166,7 +166,7 @@ extension Bazel { } } - private func preparePrebuiltFiles(project: XCode2.XCode.Project) throws { + private func preparePrebuiltFiles(project: Xcode.Project) throws { let prebuiltRoot = output + "Prebuilt" try prebuiltRoot.mkpath() @@ -182,7 +182,7 @@ extension Bazel { } } - private func linkPackageResolvedIfPresent(project: XCode2.XCode.Project) throws { + private func linkPackageResolvedIfPresent(project: Xcode.Project) throws { let source = Path(project.workspacePath) + "Package.resolved" guard source.exists else { return } @@ -220,7 +220,7 @@ extension Bazel { } } -extension XCode2.XCode.Target { +extension Xcode.Target { fileprivate func pathsForRoadmapTree(project: Project) -> [String] { let allFiles = files.sources + files.headers + files.resources + files.copyFiles + files.others let candidates = (allFiles.compactMap(\.roadmapRelativePath) + settingReferencedPaths + headerSearchPaths(project: project)).sorted { @@ -261,8 +261,8 @@ extension XCode2.XCode.Target { } } -extension XCode2.XCode.Project { - fileprivate var prebuiltFiles: [XCode2.XCode.File] { +extension Xcode.Project { + fileprivate var prebuiltFiles: [Xcode.File] { let all = targets.flatMap { target in target.files.frameworks.filter { $0.label?.hasPrefix("//Prebuilt:") == true } } @@ -275,7 +275,7 @@ extension XCode2.XCode.Project { } } -extension XCode2.XCode.File { +extension Xcode.File { fileprivate var roadmapRelativePath: String? { if let path, !path.isEmpty { return path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift index 1de7222..c7a8840 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift @@ -7,7 +7,7 @@ import Foundation @preconcurrency import PathKit -import XCode2 +import Xcode import Subprocess import Util diff --git a/Sources/BazelizeKit/XCode2Compat.swift b/Sources/BazelizeKit/XcodeCompat.swift similarity index 95% rename from Sources/BazelizeKit/XCode2Compat.swift rename to Sources/BazelizeKit/XcodeCompat.swift index 9bf1ba4..0b800e7 100644 --- a/Sources/BazelizeKit/XCode2Compat.swift +++ b/Sources/BazelizeKit/XcodeCompat.swift @@ -2,14 +2,14 @@ import Foundation import PathKit import Starlark -typealias Project = XCode2.XCode.Project -typealias Target = XCode2.XCode.Target -typealias BuildSettings = XCode2.XCode.BuildSettings -typealias File = XCode2.XCode.File -typealias RemotePackage = XCode2.XCode.RemotePackage -typealias LocalPackage = XCode2.XCode.LocalPackage -typealias PackageProductDependency = XCode2.XCode.PackageProductDependency -typealias DeviceFamily = XCode2.XCode.DeviceFamily +typealias Project = Xcode.Project +typealias Target = Xcode.Target +typealias BuildSettings = Xcode.BuildSettings +typealias File = Xcode.File +typealias RemotePackage = Xcode.RemotePackage +typealias LocalPackage = Xcode.LocalPackage +typealias PackageProductDependency = Xcode.PackageProductDependency +typealias DeviceFamily = Xcode.DeviceFamily extension Dictionary where Key == String, Value == BuildSettings { func select(_ keypath: KeyPath) -> Starlark.Select { diff --git a/Sources/Cocoapod/Pod.swift b/Sources/Cocoapod/Pod.swift index dbae020..50027bd 100644 --- a/Sources/Cocoapod/Pod.swift +++ b/Sources/Cocoapod/Pod.swift @@ -9,7 +9,7 @@ import Foundation import PathKit import PluginLoader import Util -import XCode +import Xcode @_cdecl("createPlugin") diff --git a/Sources/PluginLoader/Core.swift b/Sources/PluginLoader/Core.swift index a631bef..67e9982 100644 --- a/Sources/PluginLoader/Core.swift +++ b/Sources/PluginLoader/Core.swift @@ -8,14 +8,14 @@ // import Foundation // import PathKit // import Util -// import XCode +// import Xcode // ///// start -> load xcode ///// start -> load plugin list ///// load plugin list -> build plugin ///// build plugin -> load plugin ///// load xcode -> load plugin -// public func load(manifest: Path, _ proj: Project) async throws -> [Plugin] { +// public func load(manifest: Path, _ proj: Xcode.Project) async throws -> [Plugin] { // guard manifest.exists else { // return [] // } diff --git a/Sources/PluginLoader/Plugin.swift b/Sources/PluginLoader/Plugin.swift index 42891a4..98e9ad5 100644 --- a/Sources/PluginLoader/Plugin.swift +++ b/Sources/PluginLoader/Plugin.swift @@ -7,14 +7,14 @@ import Foundation import PathKit -import XCode +import Xcode // MARK: - PluginBuilder open class PluginBuilder { public init() { } - open func build(_: Project) async throws -> Plugin? { + open func build(_: Xcode.Project) async throws -> Plugin? { fatalError("You have to override this method.") } } @@ -27,7 +27,7 @@ public protocol Plugin: AnyObject, Sendable { var version: String { get } var url: String { get } - static func load(_ proj: Project) async throws -> Self? + static func load(_ proj: Xcode.Project) async throws -> Self? subscript(_: String) -> PluginTarget? { get } diff --git a/Sources/PluginLoader/PluginLoader.swift b/Sources/PluginLoader/PluginLoader.swift index 7172b20..be2dfd5 100644 --- a/Sources/PluginLoader/PluginLoader.swift +++ b/Sources/PluginLoader/PluginLoader.swift @@ -6,7 +6,7 @@ // import Foundation -import XCode +import Xcode private typealias InitFunction = @convention(c) () -> UnsafeMutableRawPointer @@ -36,12 +36,12 @@ enum PluginLoader { /// } /// /// final class YourPluginBuilder: PluginBuilder { - /// override final func build(_ proj: Project) async throws -> Plugin? { + /// override final func build(_ proj: Xcode.Project) async throws -> Plugin? { /// try await Pod.load(proj) /// } /// } /// ``` - static func load(at path: String, proj: Project) async throws -> Plugin? { + static func load(at path: String, proj: Xcode.Project) async throws -> Plugin? { let openRes = dlopen(path, RTLD_NOW|RTLD_LOCAL) if openRes != nil { defer { diff --git a/Sources/XCode/Model/BuildSetting+PList.swift b/Sources/XCode/Model/BuildSetting+PList.swift deleted file mode 100644 index 6d6681c..0000000 --- a/Sources/XCode/Model/BuildSetting+PList.swift +++ /dev/null @@ -1,312 +0,0 @@ -// -// BuildSetting+PList.swift -// -// -// Created by Yume on 2022/8/9. -// - -import Foundation -import PathKit -import Util - -// UISceneDelegateClassName -// $(PRODUCT_NAME).SceneDelegate -// - -// load("//build-system/bazel-utils:plist_fragment.bzl", -// "plist_fragment", -// ) - -// plist_fragment( -// name = "BuildNumberInfoPlist", -// extension = "plist", -// template = -// """ -// CFBundleVersion -// {buildNumber} -// """ -// ) - -private let PLIST_PREFIX = "INFOPLIST_KEY_" - -// MARK: - PLIST Key - - -// TODO: https://github.com/XCodeBazelize/Bazelize/issues/5 -extension BuildSettings { - // MARK: Public - - /// "YES" - public var generateInfoPlist: Bool { - let result: String? = self["GENERATE_INFOPLIST_FILE"] - return result == "YES" - } - - public var plistKeys: [String] { - setting.keys.filter { - $0.hasPrefix(PLIST_PREFIX) - } - } - - /// "ABCDEF/Info.plist" - public var infoPlist: String? { - self["INFOPLIST_FILE"] - } - - /// "LaunchScreen" - public var launch: String? { - self[plist: "UILaunchStoryboardName"] - } - - /// "Main" - public var storyboard: String? { - self[plist: "UIMainStoryboardFile"] - } - - // MARK: Private - - /// INFOPLIST_KEY_ - /// INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad - private subscript(plist key: String) -> String? { - self["\(PLIST_PREFIX)\(key)"] - } -} - -extension BuildSettings { - // MARK: Public - - public var defaultPlist: [String] { - let content: String? - if let plistPath = infoPlist { - let path: Path = project.workspacePath + plistPath - content = try? path.read() - } else { - content = nil - } - - let xmls = DefaultPlist.allCases.filter { (key: DefaultPlist) in - self[key.rawValue] == nil && - !(content?.contains(key.rawValue) ?? false) - }.map(\.xml).sorted() - return fillShortVersion(fillVersion(xmls)) - } - - // MARK: Fileprivate - - fileprivate enum DefaultPlist: String, CaseIterable { - case CFBundleName - case CFBundleIdentifier - case CFBundleVersion - case CFBundleExecutable - case CFBundlePackageType - case CFBundleDevelopmentRegion - case CFBundleShortVersionString - - // MARK: Fileprivate - - fileprivate var xml: String { - """ - \(rawValue) - \(value) - """ - } - - // MARK: Private - - private var value: String { - switch self { - case .CFBundleName: return "$(PRODUCT_NAME)" - case .CFBundleIdentifier: return "$(PRODUCT_BUNDLE_IDENTIFIER)" - case .CFBundleVersion: return "$(CURRENT_PROJECT_VERSION)" - case .CFBundleExecutable: return "$(EXECUTABLE_NAME)" - case .CFBundlePackageType: return "$(PRODUCT_BUNDLE_PACKAGE_TYPE)" - case .CFBundleDevelopmentRegion: return "$(DEVELOPMENT_LANGUAGE)" - case .CFBundleShortVersionString: return "$(MARKETING_VERSION)" - } - } - } - - // MARK: Private - - /// CFBundleVersion - CURRENT_PROJECT_VERSION - private var CURRENT_PROJECT_VERSION: String? { - self[#function] - } - - /// CFBundleShortVersionString - MARKETING_VERSION - private var MARKETING_VERSION: String? { - self[#function] - } - - private func fillVersion(_ xmls: [String]) -> [String] { - guard let version = CURRENT_PROJECT_VERSION else { return xmls } - return xmls.map { xml in - xml.replacingOccurrences(of: "$(CURRENT_PROJECT_VERSION)", with: version) - } - } - - private func fillShortVersion(_ xmls: [String]) -> [String] { - guard let version = MARKETING_VERSION else { return xmls } - return xmls.map { xml in - xml.replacingOccurrences(of: "$(MARKETING_VERSION)", with: version) - } - } -} - -/// GENERATE_INFOPLIST_FILE -extension BuildSettings { - // MARK: Public - - public var plist: [String] { - guard generateInfoPlist else { return [] } - - let xmls = plistKeys.sorted().flatMap { key -> [String?] in - let newKey = Self.key(key) - - guard let value = self[key] else { - return [Self.comment(newKey), nil] - } - - switch Decision(key) { - case .string: - return [newKey, Self.string(value)] - case .stringArray: - return [newKey, Self.stringArray(value)] - case .bool: - return [newKey, Self.bool(value)] - case .custom: - guard Self.isTrue(self[key] ?? "") else { return [] } - switch key { - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone": - return [ - Self.key("INFOPLIST_KEY_UISupportedInterfaceOrientations~iPhone"), - Self.stringArray(value), - ] - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad": - return [ - Self.key("INFOPLIST_KEY_UISupportedInterfaceOrientations~iPad"), - Self.stringArray(value), - ] - case "INFOPLIST_KEY_UIApplicationSceneManifest_Generation": - guard Self.isTrue(value) else { return [] } - return [ - Self.key("UIApplicationSceneManifest"), - """ - - UIApplicationSupportsMultipleScenes - - - """, - ] - case "INFOPLIST_KEY_UILaunchScreen_Generation": - guard Self.isTrue(value) else { return [] } - return [ - Self.key("UILaunchScreen"), - """ - - UILaunchScreen - - - """, - ] - default: return [] - } - case .unknown: - return [Self.comment(newKey), Self.comment(value)] - case .empty: - return [] - } - }.compactMap { $0 } - - return xmls - } - - // MARK: Fileprivate - - fileprivate enum Decision { - case string - case stringArray - case bool - case custom - case unknown - case empty - - // MARK: Lifecycle - - fileprivate init(_ key: String) { - switch key { - /// String - case "INFOPLIST_KEY_UIMainStoryboardFile": fallthrough - case "INFOPLIST_KEY_UILaunchStoryboardName": - self = .string - /// StringArray - case "INFOPLIST_KEY_UISupportedInterfaceOrientations": - self = .stringArray - /// Bool - case "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": - self = .bool - /// Custom - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone": fallthrough - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad": fallthrough - case "INFOPLIST_KEY_UIApplicationSceneManifest_Generation": fallthrough - case "INFOPLIST_KEY_UILaunchScreen_Generation": - self = .custom - default: - self = .unknown - } - } - } - - /// NSAccentColorName - /// AccentColor -// private var ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: String? { -// self[#function] -// } -} - -extension BuildSettings { - // MARK: Internal - - static func isTrue(_ value: String) -> Bool { - value == "YES" - } - - static func bool(_ value: String) -> String { - isTrue(value) ? "" : "" - } - - static func string(_ value: String) -> String { - "\(value)" - } - - static func stringArray(_ value: String) -> String { - let strings = value - .split(separator: " ") - .map(String.init) - .compactMap(Self.string) - .withNewLine - .indent(1) - - return [ - "", - strings, - "", - ].withNewLine - } - - static func comment(_ value: String) -> String { - "" - } - - // MARK: Private - - /// transform - /// `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad` - /// to - /// `UISupportedInterfaceOrientations~iPad` - private static func key(_ key: String) -> String { - let newKey = key - .delete(prefix: PLIST_PREFIX) - - return "\(newKey)" - } -} diff --git a/Sources/XCode/Model/BuildSettings.swift b/Sources/XCode/Model/BuildSettings.swift deleted file mode 100644 index ff4f2c5..0000000 --- a/Sources/XCode/Model/BuildSettings.swift +++ /dev/null @@ -1,238 +0,0 @@ -// -// BuildSetting.swift -// -// -// Created by Yume on 2022/4/29. -// - -import AnyCodable -import Foundation -import XcodeProj - -// MARK: - BuildSettings + Encodable - -extension BuildSettings: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(AnyCodable(setting)) - } -} - -// MARK: - BuildSettings - -public struct BuildSettings { - // MARK: Lifecycle - - init(_ project: Project, _ name: String, _ setting: [String: Any]) { - self.project = project - self.name = name - self.setting = setting - } - - init(_ project: Project, _ config: XCBuildConfiguration) { - self.init(project, config.name, config.buildSettings) - } - - // MARK: Public - - public unowned let project: Project - - /// Release / Debug / More... - public let name: String - public let setting: [String: Any] - - // MARK: Internal - - func merge(_ input: BuildSettings?) -> BuildSettings { - guard let input = input else { - return self - } - - let newSetting = setting.merging(input.setting) { first, _ in - first - } - - return .init(project, name, newSetting) - } - - internal subscript(key: String) -> String? { - let value = setting[key] - if let value = value as? String { - return value - } - - if let value = value as? BuildSetting { - switch value { - case .string(let string): - return string - case .array(let array): - return array.joined(separator: " ") - } - } - - return nil - } -} - -// MARK: - SDK - -public enum SDK: String, Encodable { - case iOS = "iphoneos" - case macOS = "macosx" - case tvOS = "appletvos" - case watchOS = "watchos" - case driverKit = "driverkit" - case auto -} - -// MARK: - DeviceFamily - -public enum DeviceFamily: String { - case iphone = "1" - case ipad = "2" - case appletv = "3" - case applewatch = "4" - case homepod = "5" - case mac = "6" - - public var code: String { - switch self { - case .iphone: return "iphone" - case .ipad: return "ipad" - case .appletv: return "appletv" - case .applewatch: return "applewatch" - case .homepod: return "homepod" - case .mac: return "mac" - } - } - - public static func parse(_ code: String?) -> [DeviceFamily] { - code?.split(separator: ",") - .map(String.init) - .compactMap(DeviceFamily.init(rawValue:)) ?? [] - } -} - -extension BuildSettings { - /// com.xxx.ABCDEF - public var bundleID: String? { - self["PRODUCT_BUNDLE_IDENTIFIER"] - } - - /// "37MR9UKGT3" - public var team: String? { - self["DEVELOPMENT_TEAM"] - } - - /// "5.0" - public var swiftVersion: String? { - self["SWIFT_VERSION"] - } - - // SUPPORTED_PLATFORMS - public var deviceFamily: [DeviceFamily] { - DeviceFamily.parse(self["TARGETED_DEVICE_FAMILY"]) - } - - /// SDKROOT - public var sdk: SDK? { - SDK(rawValue: self["SDKROOT"] ?? "") - } - - public var iOS: String? { - self["IPHONEOS_DEPLOYMENT_TARGET"] - } - - public var macOS: String? { - self["MACOSX_DEPLOYMENT_TARGET"] - } - - public var tvOS: String? { - self["TVOS_DEPLOYMENT_TARGET"] - } - - public var watchOS: String? { - self["WATCHOS_DEPLOYMENT_TARGET"] - } - - public var driverKit: String? { - self["DRIVERKIT_DEPLOYMENT_TARGET"] - } - - - - public var swiftDefine: String? { - self["OTHER_SWIFT_FLAGS"] - } - - /// ios application uitest `Target Application` - /// TEST_TARGET_NAME - public var testTargetName: String? { - self["TEST_TARGET_NAME"] - } - - /// ios application unittest `Host Application` - /// TEST_HOST - public var testHost: String? { - self["TEST_HOST"] - } - - /// ios application unittest `Allow testing Host Application APIs` - /// BUNDLE_LOADER - public var bundleLoader: String? { - self["BUNDLE_LOADER"] - } - - /// CLANG_ENABLE_MODULES - public var enableModules: Bool { - self["CLANG_ENABLE_MODULES"] == "YES" - } -} - -// MARK: - PLIST Value - -extension BuildSettings { - /// CFBundleName $(PRODUCT_NAME) - /// CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) - /// CFBundleExecutable $(EXECUTABLE_NAME) - /// CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) - /// CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) - /// CFBundleVersion $(CURRENT_PROJECT_VERSION) - /// CFBundleShortVersionString $(MARKETING_VERSION) 1.0 - // - key: "MARKETING_VERSION" - // - value: "1.0" - // - key: "CURRENT_PROJECT_VERSION" - // - value: "1" - -// UIApplicationSceneManifest....UISceneDelegateClassName -// $(PRODUCT_MODULE_NAME).SceneDelegate -// PRODUCT_MODULE_NAME -// $(PRODUCT_NAME:c99extidentifier) -// PRODUCT_NAME -// $(TARGET_NAME) -} - -// ▿ (2 elements) -// - key: "LD_RUNPATH_SEARCH_PATHS" -// ▿ value: 2 elements -// - "$(inherited)" -// - "@executable_path/Frameworks" -// ▿ (2 elements) -// - key: "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone" -// - value: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" -// ▿ (2 elements) -// - key: "CODE_SIGN_STYLE" -// - value: "Automatic" - - -// - key: "ASSETCATALOG_COMPILER_APPICON_NAME" -// - value: "AppIcon" -// - key: "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME" -// - value: "AccentColor" - -// - key: "SWIFT_EMIT_LOC_STRINGS" -// - value: "YES" - -// UILaunchStoryboardName -// LaunchScreen -// UIMainStoryboardFile -// Main diff --git a/Sources/XCode/Model/ConfigList.swift b/Sources/XCode/Model/ConfigList.swift deleted file mode 100644 index 3374e26..0000000 --- a/Sources/XCode/Model/ConfigList.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// ConfigList.swift -// -// -// Created by Yume on 2022/7/1. -// - -import Foundation -import XcodeProj - -// MARK: - ConfigList - -struct ConfigList { - // MARK: Lifecycle - - init(_ project: Project, _ target: PBXNativeTarget) { - self.init(project, target.buildConfigurationList) - } - - init(_ project: Project, _ list: XCConfigurationList?) { - self.project = project - native = list - } - - // MARK: Public - - public unowned let project: Project - - // MARK: Internal - - var buildSettings: [String: BuildSettings] { - let pair: [(String, BuildSettings)] = native?.buildConfigurations - .map { - BuildSettings(project, $0) - }.map { - ($0.name, $0) - } ?? [] - - return pair.toDictionary() - } - - /// For XCode Target ConfigList merge default ConfigList - func merge(_ config: ConfigList?) -> [String: BuildSettings] { - guard let config = config else { - return buildSettings - } - - let `default` = config.buildSettings - return buildSettings.mapValues { setting in - setting.merge(`default`[setting.name]) - } - } - - // MARK: Private - - private let native: XCConfigurationList? -} - -// MARK: Hashable - -extension ConfigList: Hashable { - public static func == (lhs: ConfigList, rhs: ConfigList) -> Bool { - lhs.native?.uuid == rhs.native?.uuid - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(native?.uuid) - } -} diff --git a/Sources/XCode/Model/SPMParser.swift b/Sources/XCode/Model/SPMParser.swift deleted file mode 100644 index 596af36..0000000 --- a/Sources/XCode/Model/SPMParser.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// SPMParser.swift -// -// -// Created by Yume on 2023/1/19. -// - -import Foundation - -import Basics -import PathKit -import TSCBasic -import Workspace - -public enum SPMParser { - public static func parse(path: String) throws -> (products: [String: [String]], targets: [String]) { - let packagePath = try Basics.AbsolutePath(validating: path) - let observability = ObservabilitySystem { _,_ in } - - let workspace = try Workspace(forRootPackage: packagePath) - let manifest = try tsc_await { - workspace.loadRootManifest( - at: packagePath, - observabilityScope: observability.topScope, - completion: $0) - } - - let pair = manifest.products.map { ($0.name, $0.targets) } - let products = pair.toDictionary() - let targets = manifest.targets.map { $0.name } - - let productsDetail = products.map { key, value in - """ - \(key): - \(value.withNewLine.indent(1)) - """.indent(2) - }.sorted().withNewLine - - print(""" - Find Local SPM - at: \(path) - products: - \(productsDetail) - """) - - return (products, targets) - } -} diff --git a/Sources/XCode/Model/XCode+File.swift b/Sources/XCode/Model/XCode+File.swift deleted file mode 100644 index 5bb4740..0000000 --- a/Sources/XCode/Model/XCode+File.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// RelativePath.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import PathKit -import XcodeProj - -// MARK: - File - -public final class File { - private unowned let project: Project - let native: PBXFileElement - - init(native: PBXFileElement, project: Project) { - self.native = native - self.project = project - } - - /// root: /Users/xxx/git/ABCDEF - /// - /// fullPath: /Users/xxx/git/ABCDEF/DEF/Base.lproj/LaunchScreen.storyboard - /// package: DEF - public var label: String? { - guard let path = relativePath else { - return nil - } - return project.transformToLabel(path) - } - - public var relativePath: String? { - let root = project.workspacePath.string - let fullPath = fullPath ?? "" - guard fullPath.hasPrefix(root + "/") else { - return nil - } - return fullPath.delete(prefix: root + "/") - } - - public var fullPath: String? { - let root = project.workspacePath.string - return try? native.fullPath(sourceRoot: root) - } - - private var ref: PBXFileReference? { - native as? PBXFileReference - } - - /// File type start with `sourcecode.` - public var isSource: Bool { - guard let ref = ref else { return false } - return ref.lastKnownFileType?.hasPrefix("sourcecode.") ?? false - } - - /// File is PBXFileReference - public var isFile: Bool { - native is PBXFileReference - } - - public var lastKnownFileType: LastKnownFileType? { - .init(rawValue: ref?.lastKnownFileType ?? "") - } - - public var explicitFileType: ExplicitFileType? { - .init(rawValue: ref?.explicitFileType ?? "") - } -} - -extension PBXFileElement { - func flatten() -> [PBXFileElement] { - if let group = self as? PBXGroup { - return group.children.flatMap { file in - file.flatten() - } - } - - if let ref = self as? PBXFileReference { - return [ref] - } - - return [] - } -} - -extension Array where Element == File { - var labels: [String] { - compactMap(\.label) - } -} diff --git a/Sources/XCode/Model/XCode+Preffer.swift b/Sources/XCode/Model/XCode+Preffer.swift deleted file mode 100644 index c8885ab..0000000 --- a/Sources/XCode/Model/XCode+Preffer.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// XCode+Prefer.swift -// -// -// Created by Yume on 2022/8/23. -// - -import Foundation -import Starlark -import XcodeProj - -extension Dictionary where Key == String { - fileprivate var sortedByKey: [(key: Key, value: Value)] { - sorted { lhs, rhs in - lhs.key < rhs.key - } - } -} - -extension Dictionary where Key == String { - public func prefer(config: String?, _ keyPath: KeyPath) -> T? { - let firstValue = sortedByKey.first?.value[keyPath: keyPath] - guard let key = config else { return firstValue } - let preferValue = self[key]?[keyPath: keyPath] - return preferValue ?? firstValue - } - - // prevent T?? - public func prefer(config: String?, _ keyPath: KeyPath) -> T? { - let firstValue = sortedByKey.first?.value[keyPath: keyPath] - guard let key = config else { return firstValue } - let preferValue = self[key]?[keyPath: keyPath] - return preferValue ?? firstValue - } -} - -extension Target { - public func prefer(_ keyPath: KeyPath) -> T? { - config.prefer(config: project.preferConfig, keyPath) - } - - // prevent T?? - public func prefer(_ keyPath: KeyPath) -> T? { - config.prefer(config: project.preferConfig, keyPath) - } -} diff --git a/Sources/XCode/Model/XCode+Project.swift b/Sources/XCode/Model/XCode+Project.swift deleted file mode 100644 index 04a87cd..0000000 --- a/Sources/XCode/Model/XCode+Project.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// Project.swift -// -// -// Created by Yume on 2022/4/21. -// - -import AnyCodable -import Foundation -import PathKit -import XcodeProj - -// MARK: - Project + Encodable - -extension Project: Encodable { - // MARK: Public - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: Keys.self) - try container.encode(workspacePath.string, forKey: .workspacePath) - try container.encode(projectPath.string, forKey: .projectPath) - - try container.encode(localSPM, forKey: .localSPM) -// try container.encode(_spm, forKey: .spm) - - try container.encode(_targets, forKey: .targets) - try container.encode(AnyCodable(config), forKey: .config) - } - - // MARK: Internal - - enum Keys: String, CodingKey { - case workspacePath - case projectPath - case localSPM - case spm - case targets - case config - } -} - -// MARK: - Project - -public final class Project { - private let project: XcodeProj - private let native: PBXProj - - public let workspacePath: Path - public let projectPath: Path - public let preferConfig: String? - - private var packages: [String] = [] - - public init(_ projectPath: Path, _ preferConfig: String?) async throws { - let path = projectPath.parent() - workspacePath = path - self.projectPath = projectPath - project = try XcodeProj(path: projectPath) - native = project.pbxproj - self.preferConfig = preferConfig - } - - public var all: [File] { - let all = try? native.rootGroup()?.flatten().map { file in - File(native: file, project: self) - } - - return all ?? [] - } - - public func files(_ type: LastKnownFileType) -> [File] { - all.filter { file in - file.lastKnownFileType == type - } - } - - public lazy var remoteSPM: [XCodeRemoteSPM] = native.frameworksBuildPhases - .compactMap(\.files) - .flatMap { $0 } - .compactMap(\.product) - .compactMap(XCodeRemoteSPM.parse) - - public lazy var localSPM: [XCodeLocalSPM] = (files(.wrapper) + files(.folder)).compactMap { file in - guard let path = file.relativePath else { return nil } - guard let fullPath = file.fullPath else { return nil } - guard let (products, targets) = try? SPMParser.parse(path: fullPath) else { return nil } - return XCodeLocalSPM(path: path, products: products, targets: targets) - } - - public lazy var frameworks: [File] = native.frameworksBuildPhases - .compactMap(\.files) - .flatMap { $0 } - .compactMap(\.file) - .map { file in - File(native: file, project: self) - } - - private lazy var _targets: [Target] = { - let list = defaultConfigList - return native.nativeTargets.map { - Target(native: $0, defaultConfigList: list, project: self) - } - }() - - public var targets: [Target] { - _targets - } - - public var config: [String: BuildSettings]? { - defaultConfigList?.buildSettings - } -} - -extension Project { - public func transformToLabel(_ relativePath: String?) -> String? { - guard let path = relativePath else { return nil } - - let commentedLabel = """ - # \(path) - """ - guard let _package = path.split(separator: "/").first else { - return commentedLabel - } - let package = String(_package) - guard let restPath = path.delete(prefix: package + "/") else { - return commentedLabel - } - - if check(package) { - return """ - //\(package):\(restPath) - """ - } else { - return """ - //:\(package)/\(restPath) - """ - } - } - - private var defaultConfigList: ConfigList? { - let all = Set(native.configurationLists.map { ConfigList(self, $0) }) - let targets = native.nativeTargets - .compactMap { ConfigList(self, $0.buildConfigurationList) } - - return all.subtracting(targets).first - } - - private typealias Package = String - - private func check(_ package: Package) -> Bool { - targets.map(\.name).contains(package) - } -} - -extension String { - fileprivate func delete(prefix: String) -> String? { - guard hasPrefix(prefix) else { return nil } - return String(dropFirst(prefix.count)) - } -} diff --git a/Sources/XCode/Model/XCode+RemoteSPMPackage.swift b/Sources/XCode/Model/XCode+RemoteSPMPackage.swift deleted file mode 100644 index 65b9954..0000000 --- a/Sources/XCode/Model/XCode+RemoteSPMPackage.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// XCodeSPM.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import Util -import XcodeProj - -extension XCodeRemoteSPM { - public static func parse(_ native: XCSwiftPackageProductDependency) -> XCodeRemoteSPM? { - let package = native.package - guard - let url = package?.repositoryURL, - let requirement = package?.versionRequirement - else { - return nil - } - return XCodeRemoteSPM(url: url, version: requirement.version) - } -} - -extension XCRemoteSwiftPackageReference.VersionRequirement { - var version: XCodeRemoteSPM.Version { - switch self { - case .upToNextMajorVersion(let version): return .upToNextMajorVersion(version) - case .upToNextMinorVersion(let version): return .upToNextMinorVersion(version) - case .range(let from, let to): return .range(from: from, to: to) - case .exact(let version): return .exact(version) - case .branch(let branch): return .branch(branch) - case .revision(let commit): return .revision(commit) - } - } -} diff --git a/Sources/XCode/Model/XCode+SPM.swift b/Sources/XCode/Model/XCode+SPM.swift deleted file mode 100644 index 10b36f8..0000000 --- a/Sources/XCode/Model/XCode+SPM.swift +++ /dev/null @@ -1,249 +0,0 @@ -// -// XCodeSPM.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import PathKit -import Util -import XcodeProj - -extension Array where Element == Target { - // MARK: Public - - public var isHaveSPM: Bool { - !flatPackages.isEmpty - } - - - public func spm_pkgs(_ path: Path? = nil) -> String { - flatPackages.spm_pkgs(path) - } - - public func spm_repositories(_ path: Path? = nil) -> String { - """ - load("@cgrindel_rules_spm//spm:defs.bzl", "spm_pkg", "spm_repositories") - - spm_repositories( - name = "swift_pkgs", - dependencies = [ - \(spm_pkgs(path).indent(2)) - ], - ) - """ - } - - // MARK: Private - - private var flatPackages: [Package] { - flatMap(\.native.spm) - } -} - -extension Array where Element == Package { - /// Target -> Target.Name - /// Package -> Target - /// Set -> deps - private var mapping: [String: (Package, Set)] { - var dict: [String: (Package, Set)] = [:] - for package in self { - guard let url = package.product.package?.repositoryURL else { continue } - if var (_, set) = dict[url] { - set.insert(package.product.productName) - dict[url] = (package, set) - } else { - let set = Set(arrayLiteral: package.product.productName) - dict[url] = (package, set) - } - } - - return dict - } - - private func spm_pkgs(_ path: Path? = nil) -> String { - let resolved: Package.Resolved? - if let projRoot = path { - let resolvedPath = projRoot + "project.xcworkspace/xcshareddata/swiftpm/Package.resolved" - resolved = try? .parse(resolvedPath) - } else { - resolved = nil - } - - return mapping.compactMap { _, value -> String? in - let (package, set) = value - return package.spm_pkg(set, resolved) - }.withNewLine - } -} - -// MARK: - Package - -// path A local path string to the package repository. None -// name Optional. The name (string) to be used for the package in Package.swift. None -private struct Package { - /// xxx.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved - /// { - /// "pins" : [ - /// { - /// "identity" : "alertkit", - /// "kind" : "remoteSourceControl", - /// "location" : "https://github.com/EhPanda-Team/AlertKit.git", - /// "state" : { - /// "branch" : "custom", - /// "revision" : "39b01c53ffadf3dab9871dd4c960cd81af5246b6" - /// } - /// }, - /// "version" : 2 - /// } - /// { - /// "object": { - /// "pins": [ - /// { - /// "package": "Rainbow", - /// "repositoryURL": "https://github.com/onevcat/Rainbow", - /// "state": { - /// "branch": null, - /// "revision": "626c3d4b6b55354b4af3aa309f998fae9b31a3d9", - /// "version": "3.2.0" - /// } - /// }, - /// "version" : 1 - /// } - struct Resolved: JSONParsable { - struct Object: Codable { - let pins: [Pin1] - } - - struct Pin1: Codable { - /// Rainbow - let package: String - let state: State - } - - struct Pin2: Codable { - /// alertkit - let identity: String - let state: State - } - - struct State: Codable { - let revision: String - } - - let version: Int - let object: Object? - let pins: [Pin2]? - - - subscript(_ name: String) -> String? { - switch version { - case 1: - return object?.pins.first { $0.package == name }?.state.revision - case 2: - return pins?.first { $0.identity == name.lowercased() }?.state.revision - default: - return nil - } - } - } - - let product: XCSwiftPackageProductDependency - - /// "@swift_pkgs//swift-log:Logging", - var dep: String? { - /// https://github.com/apple/swift-log.git - guard let url = product.package?.repositoryURL else { return nil } - let path = Path(url) - /// swift-log - let repo = path.lastComponentWithoutExtension - - /// Logging - let product = product.productName - - return """ - "@swift_pkgs//\(repo):\(product)", - """ - } - - /// exact_version Optional. A string representing a valid "exact" SPM version. None - /// from_version Optional. A string representing a valid "from" SPM version. None - /// revision Optional. A commit hash (string). None - func version(_ resolved: Resolved?) -> String? { - switch product.package?.versionRequirement { - case .exact(let ver): - return """ - exact_version = "\(ver)" - """ - case .range(let from, _): fallthrough - case .upToNextMajorVersion(let from): fallthrough - case .upToNextMinorVersion(let from): - return """ - from_version = "\(from)" - """ - case .revision(let commit): - return """ - revision = "\(commit)" - """ - case .branch(let branch): - guard let commit = resolved?[product.package?.name ?? ""] else { return nil } - return """ - # branch `\(branch)` - revision = "\(commit)" - """ - default: return nil - } - } - - - /// spm_pkg( - /// "https://github.com/apple/swift-log.git", - /// exact_version = "1.4.2", - /// products = ["Logging"], - /// ), - /// - /// url A string representing the URL for the package repository. None - /// - /// products A list of string values representing the names of the products to be used. [] - func spm_pkg(_ set: Set, _ resolved: Package.Resolved?) -> String? { - guard let url = product.package?.repositoryURL else { return nil } - guard let version = version(resolved) else { return nil } - let products = set.map { product in - """ - "\(product)" - """ - }.joined(separator: " ,") - - return """ - spm_pkg( - "\(url)", - \(version), - products = [\(products)], - ), - """ - } -} - -extension PBXNativeTarget { - // MARK: Public - - /// use for `deps` - public var spm_deps: String { - spm - .compactMap(\.dep) - .withNewLine - } - - // MARK: Fileprivate - - fileprivate var spm: [Package] { - _spm.map(Package.init) - } - - // MARK: Private - - private var _spm: [XCSwiftPackageProductDependency] { - (try? frameworksBuildPhase()?.files?.compactMap(\.product)) ?? [] - } -} diff --git a/Sources/XCode/Model/XCode+Select.swift b/Sources/XCode/Model/XCode+Select.swift deleted file mode 100644 index da3eb44..0000000 --- a/Sources/XCode/Model/XCode+Select.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Target+Select.swift -// -// -// Created by Yume on 2022/8/17. -// - -import Foundation -import Starlark -import XcodeProj - -extension Dictionary where Key == String { - // MARK: Public - - public func select(_ keypath: KeyPath) -> Starlark.Select { - if checkSame(keypath) { - return selectSame(keypath) - } - return selectVarious(keypath) - } - - // MARK: Private - - private func checkSame(_ keypath: KeyPath) -> Bool { - let values: [T] = map { _, setting in - setting[keyPath: keypath] - } - - return Set(values).count == 1 - } - - private func selectSame(_ keypath: KeyPath) -> Starlark.Select { - guard let value = first?.value[keyPath: keypath] else { - return selectVarious(keypath) - } - return .same(value) - } - - private func selectVarious(_ keypath: KeyPath) -> Starlark.Select { - let result: [Starlark.Label: T] = reduce(into: [:]) { partialResult, entry in - partialResult[.config(entry.key)] = entry.value[keyPath: keypath] - } - return .various(result) - } -} - -extension Target { - public func select(_ keypath: KeyPath) -> Starlark.Select { - config.select(keypath) - } -} diff --git a/Sources/XCode/Model/XCode+Target.swift b/Sources/XCode/Model/XCode+Target.swift deleted file mode 100644 index de819b7..0000000 --- a/Sources/XCode/Model/XCode+Target.swift +++ /dev/null @@ -1,302 +0,0 @@ -// -// Target.swift -// -// -// Created by Yume on 2022/4/25. -// - -import AnyCodable -import Foundation -import Starlark -import XcodeProj - -// MARK: - Target + Encodable - -extension Target: Encodable { - // MARK: Public - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: Keys.self) - try container.encode(name, forKey: .name) - try container.encode(AnyCodable(originConfig), forKey: .config) - try container.encode(headers, forKey: .headers) - try container.encode(srcs, forKey: .srcs) - try container.encode(resources, forKey: .resources) - try container.encode(importFrameworks, forKey: .importFrameworks) - // try container.encode(frameworks, forKey: .frameworks) - // try container.encode(frameworks_library, forKey: .frameworks_library) - // try container.encode(sdkFrameworks, forKey: .sdkFrameworks) - } - - // MARK: Internal - - enum Keys: String, CodingKey { - case name - case config - case headers - case srcs - case resources - case importFrameworks - case frameworks - case frameworks_library - case sdkFrameworks - } -} - -// MARK: - Target - -public final class Target { - // MARK: Lifecycle - - init(native: PBXNativeTarget, defaultConfigList: ConfigList?, project: Project) { - self.native = native - let configList: ConfigList = .init(project, native.buildConfigurationList) - let defaultConfigList = defaultConfigList - originConfig = configList.buildSettings - config = configList.merge(defaultConfigList) - self.project = project - } - - // MARK: Public - - public let native: PBXNativeTarget - public let config: [String: BuildSettings] - - public unowned let project: Project - - public var configs: [String] { - config.keys.sorted { lhs, rhs in - lhs < rhs - } - } - - public var name: String { native.name } - - - public subscript(config: String) -> BuildSettings? { - self.config[config] - } - - // MARK: Private - - private let originConfig: [String: BuildSettings] -} - -extension Target { - // MARK: Internal - - func isInPackage(_ label: String) -> Bool { - label.hasPrefix(""" - //\(name): - """) - } - - // MARK: Private - - private func files(_ files: [PBXBuildFile]?) -> [File] { - files?.flatMap { build -> [File] in - guard let files = build.file?.flatten() else { return [] } - return files.map { native -> File in - File(native: native, project: project) - } - } ?? [] - } -} - -/// srcs -extension Target { - // MARK: Public - - /// `.h` & `.pch` - public var headers: [String] { - project - .files(.h) - .labels - .filter(isInPackage) - } - - public var hpps: [String] { - project - .files(.hpp) - .labels - .filter(isInPackage) - } - - public var srcFiles: [File] { - files(try? native.sourcesBuildPhase()?.files) - } - - public var srcs: [String] { - srcFiles.labels - } - - public var srcs_c: [String] { - srcs(.c) - } - - public var srcs_objc: [String] { - srcs(.objc) - } - - public var srcs_cpp: [String] { - srcs(.cpp) - } - - public var srcs_objcpp: [String] { - srcs(.objcpp) - } - - public var srcs_swift: [String] { - srcs(.swift) - } - - public var srcs_metal: [String] { - srcs(.metal) - } - - // MARK: Internal - - func srcs(_ type: LastKnownFileType) -> [String] { - srcFiles.filter { file in - file.lastKnownFileType == type - }.labels - } -} - -extension Target { - // MARK: Public - - public var resourceFiles: [File] { - files(try? native.resourcesBuildPhase()?.files) - } - - public var xibs: [String] { - resources(.xib) - } - - public var storyboards: [String] { - resources(.storyboard) - } - - public var assets: [String] { - resources(.asset) - } - - public var strings: [String] { - resources(.strings) - } - - public var stringsdict: [String] { - resources(.stringsdict) - } - - public var allStrings: [String] { - strings + stringsdict - } - - public var resources: [String] { - resourceFiles.labels - } - - // MARK: Internal - - func resources(_ type: LastKnownFileType) -> [String] { - resourceFiles.filter { file in - file.lastKnownFileType == type - }.labels - } -} - -extension Target { - // MARK: Public - - /// https://github.com/XCodeBazelize/Bazelize/issues/8 - /// use for `frameworks` - public var importFrameworks: [String] { - _frameworks.compactMap(\.relativePath) - } - - public var frameworksLibrary: [Starlark.Label] { - _frameworksTarget - .map { target -> String in - let name = target.name - return """ - //\(name):\(name)_library - """ - } - .sorted() - .map { - Starlark.Label.named($0) - } - } - - public var frameworks: [Starlark.Label] { - _frameworksTarget - .map { target -> String in - let name = target.name - return """ - //\(name):\(name) - """ - } - .sorted() - .map { - Starlark.Label.named($0) - } - } - - /// use for `sdk_frameworks` - /// - /// name - /// nil // XCode Target - /// AVFoundation.framework // SDK - /// path - /// Framework2.framework - /// Platforms/MacOSX.platform/Developer/SDKs/ - /// MacOSX13.1.sdk/System/Library/Frameworks/AVFoundation.framework - /// - /// Target(SDK) - /// AVFoundation.framework -> AVFoundation - public var frameworksSDK: [String] { - _frameworks - .compactMap(\.native.name) - .filter { name in - name.hasSuffix(".framework") - } - .map { (name: String) in - name.replacingOccurrences(of: ".framework", with: "") - } - } - - // MARK: Private - - // use for `frameworks` - private var _frameworks: [File] { - let builds = (try? native.frameworksBuildPhase()?.files?.compactMap(\.file)) ?? [] - return builds.map { - File(native: $0, project: project) - } - } - - private var _frameworksTarget: [Target] { - let frameworks = _frameworks.map(\.native) - let targets = project.targets - - return targets.filter { target in - guard let product = target.native.product else { - return false - } - return frameworks.contains(product) - } - } -} - -extension String { - /// .swift - /// .m - /// .mm - func hasExtension(_ type: String) -> Bool { - hasSuffix(""" - \(type) - """) - } -} diff --git a/Sources/XCode/Model/XCodeSPM.swift b/Sources/XCode/Model/XCodeSPM.swift deleted file mode 100644 index 009d489..0000000 --- a/Sources/XCode/Model/XCodeSPM.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// XCodeSPM.swift -// -// -// Created by Yume on 2022/7/29. -// - -import Foundation - -// MARK: - XCodeLocalSPM - -public struct XCodeLocalSPM: Encodable { - public let path: String - public let products: [String: [String]] - public let targets: [String] - - public init(path: String, products: [String: [String]], targets: [String]) { - self.path = path - self.products = products - self.targets = targets - } - - public var package: String { - """ - .package(path: "\(path)"), - """ - } -} - -// MARK: - XCodeRemoteSPM - -public struct XCodeRemoteSPM: Encodable { - public let url: String - public let version: Version - - public init(url: String, version: XCodeRemoteSPM.Version) { - self.url = url - self.version = version - } - - public enum Version: Encodable { - case upToNextMajorVersion(String) - case upToNextMinorVersion(String) - case range(from: String, to: String) - case exact(String) - case branch(String) - case revision(String) - - var version: String { - switch self { - case .upToNextMajorVersion(let version): - return """ - from: "\(version)" - """ - case .upToNextMinorVersion(let version): - return """ - from: "\(version)" - """ - case .range(let from, let to): - return """ - "\(from)"..."\(to)" - """ - case .exact(let version): - return """ - exact: "\(version)" - """ - case .branch(let branch): - return """ - branch: "\(branch)" - """ - case .revision(let revision): - return """ - revision: \(revision) - """ - } - } - } - - public var package: String { - """ - .package(url: "\(url)", \(version.version)), - """ - } -} diff --git a/Sources/XCode/Setting/DeviceFamily.swift b/Sources/XCode/Setting/DeviceFamily.swift deleted file mode 100644 index ef11214..0000000 --- a/Sources/XCode/Setting/DeviceFamily.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -// MARK: - SupportedPlatform - -/// SUPPORTED_PLATFORMS -enum SupportedPlatform: String { - case iphonesimulator - case iphoneos - case driverkit - case macosx - case appletvsimulator - case appletvos - case watchsimulator - case watchos -} - - -/// catalyst -/// SUPPORTS_MACCATALYST - -/// design for ipad -/// SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD diff --git a/Sources/XCode/Setting/ExplicitFileType.swift b/Sources/XCode/Setting/ExplicitFileType.swift deleted file mode 100644 index 5c57235..0000000 --- a/Sources/XCode/Setting/ExplicitFileType.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ExplicitFileType.swift -// -// -// Created by Yume on 2023/1/18. -// - -import Foundation - -/// `PBXFileReference.explicitFileType` -public enum ExplicitFileType: String, Equatable { - /// libXXX.a - case archive = "archive.ar" - - case framework = "wrapper.framework" -} diff --git a/Sources/XCode/Setting/LastKnownFileType.swift b/Sources/XCode/Setting/LastKnownFileType.swift deleted file mode 100644 index d9cdb68..0000000 --- a/Sources/XCode/Setting/LastKnownFileType.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// LastKnownFileType.swift -// -// -// Created by Yume on 2022/8/4. -// - -import Foundation - -/// `PBXFileReference.lastKnownFileType` -public enum LastKnownFileType: String { - case h = "sourcecode.c.h" - case c = "sourcecode.c.c" - case objc = "sourcecode.c.objc" - case hpp = "sourcecode.cpp.h" - case cpp = "sourcecode.cpp.cpp" - case objcpp = "sourcecode.cpp.objcpp" - case swift = "sourcecode.swift" - case metal = "sourcecode.metal" - - case strings = "text.plist.strings" - case stringsdict = "text.plist.stringsdict" - case plist = "text.plist.xml" - case entitlements = "text.plist.entitlements" - case xcconfig = "text.xcconfig" - - case asset = "folder.assetcatalog" - - case xib = "file.xib" - case storyboard = "file.storyboard" - - case wrapper - case folder - - case framework = "wrapper.framework" - case xcframework = "wrapper.xcframework" -} diff --git a/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift b/Sources/Xcode/Loader/Xcode+ConfigListLoader.swift similarity index 97% rename from Sources/Xcode2/Loader/XCode+ConfigListLoader.swift rename to Sources/Xcode/Loader/Xcode+ConfigListLoader.swift index 22d377c..2a5c938 100644 --- a/Sources/Xcode2/Loader/XCode+ConfigListLoader.swift +++ b/Sources/Xcode/Loader/Xcode+ConfigListLoader.swift @@ -6,7 +6,7 @@ struct ConfigListLoader: Hashable { let native: XCConfigurationList? let sourceRoot: Path - var configs: [String: XCode.BuildSettings] { + var configs: [String: Xcode.BuildSettings] { (native?.buildConfigurations ?? []).map { config in ( config.name, @@ -16,7 +16,7 @@ struct ConfigListLoader: Hashable { }.toDictionary() } - func merge(_ defaultConfig: ConfigListLoader?) -> [String: XCode.BuildSettings] { + func merge(_ defaultConfig: ConfigListLoader?) -> [String: Xcode.BuildSettings] { guard let defaultConfig else { return configs } diff --git a/Sources/Xcode2/Loader/XCode+FileLoader.swift b/Sources/Xcode/Loader/Xcode+FileLoader.swift similarity index 99% rename from Sources/Xcode2/Loader/XCode+FileLoader.swift rename to Sources/Xcode/Loader/Xcode+FileLoader.swift index 7ff2884..85541dd 100644 --- a/Sources/Xcode2/Loader/XCode+FileLoader.swift +++ b/Sources/Xcode/Loader/Xcode+FileLoader.swift @@ -159,7 +159,7 @@ struct FileLoader { native as? PBXFileReference } - func file(buildPhase: String?, compilerFlags: String?, attributes: [String]) -> XCode.File { + func file(buildPhase: String?, compilerFlags: String?, attributes: [String]) -> Xcode.File { .init( name: name, path: relativePath ?? native.path, @@ -233,7 +233,7 @@ struct SynchronizedFile { return typedFileType?.category ?? .other } - var file: XCode.File { + var file: Xcode.File { .init( name: name, path: path, diff --git a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift b/Sources/Xcode/Loader/Xcode+ProjectLoader.swift similarity index 92% rename from Sources/Xcode2/Loader/XCode+ProjectLoader.swift rename to Sources/Xcode/Loader/Xcode+ProjectLoader.swift index 462cc82..2a8c7ea 100644 --- a/Sources/Xcode2/Loader/XCode+ProjectLoader.swift +++ b/Sources/Xcode/Loader/Xcode+ProjectLoader.swift @@ -1,5 +1,5 @@ // -// XCode+ProjectLoader.swift +// Xcode+ProjectLoader.swift // // // Created by Yume on 2026/3/29. @@ -37,8 +37,8 @@ final class ProjectLoader { rootProject?.name ?? path.lastComponentWithoutExtension } - func model() throws -> XCode.Project { - XCode.Project( + func model() throws -> Xcode.Project { + Xcode.Project( name: name, workspacePath: workspacePath.string, projectPath: path.string, @@ -75,7 +75,7 @@ final class ProjectLoader { // MARK: - SwiftPM extension ProjectLoader { - private var remotePackages: [XCode.RemotePackage] { + private var remotePackages: [Xcode.RemotePackage] { (rootProject?.remotePackages ?? []).map { package in .init( name: package.name, @@ -84,9 +84,9 @@ extension ProjectLoader { } } - private var localPackages: [XCode.LocalPackage] { + private var localPackages: [Xcode.LocalPackage] { let explicit = (rootProject?.localPackages ?? []).map { package in - XCode.LocalPackage( + Xcode.LocalPackage( name: package.name, relativePath: package.relativePath) } @@ -95,7 +95,7 @@ extension ProjectLoader { discovered: discoveredLocalPackages + synchronizedLocalPackages) } - private var discoveredLocalPackages: [XCode.LocalPackage] { + private var discoveredLocalPackages: [Xcode.LocalPackage] { allFiles .compactMap { FileLoader(native: $0, project: self) } .compactMap { file in @@ -106,7 +106,7 @@ extension ProjectLoader { guard packageRoot.isDirectory else { return nil } guard (packageRoot + "Package.swift").exists else { return nil } - return XCode.LocalPackage( + return Xcode.LocalPackage( name: file.name ?? packageRoot.lastComponent, relativePath: relativePath) } @@ -115,8 +115,8 @@ extension ProjectLoader { /// Local packages Xcode picks up from a synchronized group instead of an /// explicit package reference, e.g. a `Packages/` directory holding one /// package per subdirectory. - private var synchronizedLocalPackages: [XCode.LocalPackage] { - native.fileSystemSynchronizedRootGroups.flatMap { group -> [XCode.LocalPackage] in + private var synchronizedLocalPackages: [Xcode.LocalPackage] { + native.fileSystemSynchronizedRootGroups.flatMap { group -> [Xcode.LocalPackage] in guard let relativeRoot = group.path else { return [] } let root = workspacePath + relativeRoot @@ -128,7 +128,7 @@ extension ProjectLoader { return (try? root.children())?.compactMap { child in guard child.isDirectory, (child + "Package.swift").exists else { return nil } - return XCode.LocalPackage( + return Xcode.LocalPackage( name: child.lastComponent, relativePath: "\(relativeRoot)/\(child.lastComponent)") } ?? [] @@ -202,11 +202,11 @@ extension ProjectLoader { } static func mergeLocalPackages( - explicit: [XCode.LocalPackage], - discovered: [XCode.LocalPackage]) - -> [XCode.LocalPackage] + explicit: [Xcode.LocalPackage], + discovered: [Xcode.LocalPackage]) + -> [Xcode.LocalPackage] { - var result: [XCode.LocalPackage] = [] + var result: [Xcode.LocalPackage] = [] var seen = Set() for package in explicit + discovered { @@ -260,7 +260,7 @@ extension ProjectLoader { } extension XCRemoteSwiftPackageReference.VersionRequirement { - fileprivate var requirementValue: XCode.RemotePackage.Requirement { + fileprivate var requirementValue: Xcode.RemotePackage.Requirement { switch self { case .upToNextMajorVersion(let version): return .upToNextMajorVersion(version) diff --git a/Sources/Xcode2/Loader/XCode+TargetLoader.swift b/Sources/Xcode/Loader/Xcode+TargetLoader.swift similarity index 96% rename from Sources/Xcode2/Loader/XCode+TargetLoader.swift rename to Sources/Xcode/Loader/Xcode+TargetLoader.swift index 15c3ec7..cb8d487 100644 --- a/Sources/Xcode2/Loader/XCode+TargetLoader.swift +++ b/Sources/Xcode/Loader/Xcode+TargetLoader.swift @@ -9,7 +9,7 @@ struct TargetLoader { unowned let project: ProjectLoader let preferConfig: String? let configList: ConfigListLoader - let mergedConfig: [String: XCode.BuildSettings] + let mergedConfig: [String: Xcode.BuildSettings] init(native: PBXNativeTarget, project: ProjectLoader, defaultConfigList: ConfigListLoader?) { self.native = native @@ -42,8 +42,8 @@ struct TargetLoader { var name: String { native.name } - var model: XCode.Target { - let buildPhases = native.buildPhases.map(XCode.BuildPhase.init) + var model: Xcode.Target { + let buildPhases = native.buildPhases.map(Xcode.BuildPhase.init) let synchronizedFiles = synchronizedGroupFiles let sourceFiles = unique( @@ -79,7 +79,7 @@ struct TargetLoader { file.category == .other && !knownPaths.contains(file.file.path ?? "") }.map(\.file) - return XCode.Target( + return Xcode.Target( name: name, productName: native.productName, productType: native.productType?.rawValue, @@ -97,7 +97,7 @@ struct TargetLoader { dependencies: dependencies) } - private var metadata: XCode.TargetMetadata { + private var metadata: Xcode.TargetMetadata { let settings = selectedConfig ?? .init(name: "", setting: [:]) return .init( @@ -112,7 +112,7 @@ struct TargetLoader { codeSignIdentity: settings.metadata.codeSignIdentity)) } - private var dependencies: XCode.Dependencies { + private var dependencies: Xcode.Dependencies { let declaredDependencies = native.dependencies.compactMap { dependency in dependency.target?.name ?? dependency.name } @@ -202,7 +202,7 @@ struct TargetLoader { } let packageProducts = unique(productDependencies) { $0.productName }.map { dependency in - XCode.PackageProductDependency( + Xcode.PackageProductDependency( productName: dependency.productName, package: dependency.package?.repositoryURL, packagePath: project.localPackagePathByProduct[dependency.productName]) @@ -218,7 +218,7 @@ struct TargetLoader { weakSDKFrameworks: Set(weakSDKFrameworks.compactMap { $0 }).sorted()) } - private var selectedConfig: XCode.BuildSettings? { + private var selectedConfig: Xcode.BuildSettings? { if let prefer = project.preferConfig, let hit = mergedConfig[prefer] { return hit } @@ -228,7 +228,7 @@ struct TargetLoader { .first } - private var packageHeaders: [XCode.File] { + private var packageHeaders: [Xcode.File] { project.packageFiles(targetName: name) .filter { file in guard let type = file.fileType else { return false } @@ -396,8 +396,8 @@ struct TargetLoader { case includeListed } - private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [XCode.File] { - buildFiles.flatMap { buildFile -> [XCode.File] in + private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [Xcode.File] { + buildFiles.flatMap { buildFile -> [Xcode.File] in guard let file = buildFile.file else { return [] } /// A localized resource is one build file referencing a variant group; @@ -430,9 +430,9 @@ struct TargetLoader { } } -extension XCode.BuildPhase { +extension Xcode.BuildPhase { fileprivate init(phase: PBXBuildPhase) { - let destination: XCode.CopyFilesDestination? + let destination: Xcode.CopyFilesDestination? if let copyPhase = phase as? PBXCopyFilesBuildPhase { destination = .init( path: copyPhase.dstPath, @@ -446,7 +446,7 @@ extension XCode.BuildPhase { type: phase.buildPhase.rawValue, name: phase.name(), files: (phase.files ?? []).compactMap { buildFile in - XCode.BuildPhaseFile( + Xcode.BuildPhaseFile( name: (buildFile.file as? PBXFileReference)?.name ?? (buildFile.file as? PBXFileReference)?.path ?? buildFile.product?.productName, diff --git a/Sources/Xcode2/Loader/XCode+Toolchain.swift b/Sources/Xcode/Loader/Xcode+Toolchain.swift similarity index 100% rename from Sources/Xcode2/Loader/XCode+Toolchain.swift rename to Sources/Xcode/Loader/Xcode+Toolchain.swift diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+AssetCatalog.swift similarity index 89% rename from Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift rename to Sources/Xcode/Model/Config/Xcode+BuildSettings+AssetCatalog.swift index 0dbc30b..a66f879 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+AssetCatalog.swift +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+AssetCatalog.swift @@ -1,12 +1,12 @@ import Foundation -extension XCode.BuildSettings { +extension Xcode.BuildSettings { public var assetCatalog: AssetCatalog { .init(settings: self) } public struct AssetCatalog { - fileprivate let settings: XCode.BuildSettings + fileprivate let settings: Xcode.BuildSettings public var appIconName: String? { settings["ASSETCATALOG_COMPILER_APPICON_NAME"] diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Metadata.swift similarity index 90% rename from Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift rename to Sources/Xcode/Model/Config/Xcode+BuildSettings+Metadata.swift index cb0eb86..ac2eb1a 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Metadata.swift +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Metadata.swift @@ -1,12 +1,12 @@ import Foundation -extension XCode.BuildSettings { +extension Xcode.BuildSettings { public var metadata: Metadata { .init(settings: self) } public struct Metadata { - fileprivate let settings: XCode.BuildSettings + fileprivate let settings: Xcode.BuildSettings public var bundleID: String? { settings["PRODUCT_BUNDLE_IDENTIFIER"] diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+PList.swift similarity index 97% rename from Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift rename to Sources/Xcode/Model/Config/Xcode+BuildSettings+PList.swift index 5d5585b..68d2c0b 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+PList.swift +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+PList.swift @@ -2,7 +2,7 @@ import Foundation private let plistPrefix = "INFOPLIST_KEY_" -extension XCode.BuildSettings { +extension Xcode.BuildSettings { // MARK: Info.plist public var plist: Plist { @@ -14,7 +14,7 @@ extension XCode.BuildSettings { } public struct Plist { - fileprivate let settings: XCode.BuildSettings + fileprivate let settings: Xcode.BuildSettings /// "ABCDEF/Info.plist" public var infoPlist: String? { @@ -46,7 +46,7 @@ extension XCode.BuildSettings { } public struct GeneratedPlist { - fileprivate let settings: XCode.BuildSettings + fileprivate let settings: Xcode.BuildSettings /// "YES" public var enabled: Bool { diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Platform.swift similarity index 92% rename from Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift rename to Sources/Xcode/Model/Config/Xcode+BuildSettings+Platform.swift index 0fc1261..dfaf43f 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings+Platform.swift +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Platform.swift @@ -11,13 +11,13 @@ public enum SDK: String, Hashable { case auto } -extension XCode.BuildSettings { +extension Xcode.BuildSettings { public var platform: Platform { .init(settings: self) } public struct Platform { - fileprivate let settings: XCode.BuildSettings + fileprivate let settings: Xcode.BuildSettings public var sdk: SDK? { SDK(rawValue: settings["SDKROOT"] ?? "") @@ -91,8 +91,8 @@ extension XCode.BuildSettings { return sdk } - public var deviceFamily: [XCode.DeviceFamily] { - XCode.DeviceFamily.parse(settings["TARGETED_DEVICE_FAMILY"]) + public var deviceFamily: [Xcode.DeviceFamily] { + Xcode.DeviceFamily.parse(settings["TARGETED_DEVICE_FAMILY"]) } public var appleFamiliesLiteral: String? { diff --git a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings.swift similarity index 98% rename from Sources/Xcode2/Model/Config/XCode+BuildSettings.swift rename to Sources/Xcode/Model/Config/Xcode+BuildSettings.swift index 3d83611..79b9d3d 100644 --- a/Sources/Xcode2/Model/Config/XCode+BuildSettings.swift +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings.swift @@ -12,9 +12,9 @@ extension BuildSetting { } } -// MARK: - XCode.BuildSettings +// MARK: - Xcode.BuildSettings -extension XCode { +extension Xcode { public struct BuildSettings: Encodable { public let name: String private let setting: [String: String] @@ -69,7 +69,7 @@ extension XCode { } } -extension XCode.BuildSettings { +extension Xcode.BuildSettings { public var swiftVersion: String? { self["SWIFT_VERSION"] } /// `SWIFT_DEFAULT_ACTOR_ISOLATION`: the module-wide default Xcode compiles with @@ -189,7 +189,7 @@ extension StringProtocol { } } -extension XCode.BuildSettings { +extension Xcode.BuildSettings { /// Xcode spells a reference `$(NAME)` or `${NAME}` and allows a modifier: /// `$(PRODUCT_NAME:rfc1034identifier)`. private static let referencePattern = #"\$[({]([A-Za-z0-9_]+)(?::([A-Za-z0-9_]+))?[)}]"# diff --git a/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift b/Sources/Xcode/Model/Config/Xcode+DeviceFamily.swift similarity index 97% rename from Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift rename to Sources/Xcode/Model/Config/Xcode+DeviceFamily.swift index 80b38eb..27ac084 100644 --- a/Sources/Xcode2/Model/Config/XCode+DeviceFamily.swift +++ b/Sources/Xcode/Model/Config/Xcode+DeviceFamily.swift @@ -1,6 +1,6 @@ import Foundation -extension XCode { +extension Xcode { public enum DeviceFamily: String { case iphone = "1" case ipad = "2" diff --git a/Sources/Xcode2/Model/File/XCode+File.swift b/Sources/Xcode/Model/File/Xcode+File.swift similarity index 95% rename from Sources/Xcode2/Model/File/XCode+File.swift rename to Sources/Xcode/Model/File/Xcode+File.swift index a68a1d6..932e1cb 100644 --- a/Sources/Xcode2/Model/File/XCode+File.swift +++ b/Sources/Xcode/Model/File/Xcode+File.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct File: Codable { public let name: String? public let path: String? diff --git a/Sources/Xcode2/Model/File/XCode+Files.swift b/Sources/Xcode/Model/File/Xcode+Files.swift similarity index 94% rename from Sources/Xcode2/Model/File/XCode+Files.swift rename to Sources/Xcode/Model/File/Xcode+Files.swift index d700684..e19b030 100644 --- a/Sources/Xcode2/Model/File/XCode+Files.swift +++ b/Sources/Xcode/Model/File/Xcode+Files.swift @@ -1,6 +1,6 @@ -// MARK: - XCode.Files +// MARK: - Xcode.Files -extension XCode { +extension Xcode { public struct Files: Codable { public let sources: [File] public let headers: [File] @@ -11,7 +11,7 @@ extension XCode { } } -extension XCode.Files { +extension Xcode.Files { enum CodingKeys: String, CodingKey { case sources case headers diff --git a/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift b/Sources/Xcode/Model/Phase/Xcode+BuildPhase.swift similarity index 95% rename from Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift rename to Sources/Xcode/Model/Phase/Xcode+BuildPhase.swift index 083f8cb..8825a79 100644 --- a/Sources/Xcode2/Model/Phase/XCode+BuildPhase.swift +++ b/Sources/Xcode/Model/Phase/Xcode+BuildPhase.swift @@ -1,6 +1,6 @@ -// MARK: - XCode.BuildPhase +// MARK: - Xcode.BuildPhase -extension XCode { +extension Xcode { public struct BuildPhase: Codable { public let type: String public let name: String? @@ -14,7 +14,7 @@ extension XCode { } } -extension XCode.BuildPhase { +extension Xcode.BuildPhase { enum CodingKeys: String, CodingKey { case type case name diff --git a/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift b/Sources/Xcode/Model/Phase/Xcode+BuildPhaseFile.swift similarity index 92% rename from Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift rename to Sources/Xcode/Model/Phase/Xcode+BuildPhaseFile.swift index 0f090ce..80c6d4a 100644 --- a/Sources/Xcode2/Model/Phase/XCode+BuildPhaseFile.swift +++ b/Sources/Xcode/Model/Phase/Xcode+BuildPhaseFile.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct BuildPhaseFile: Codable { public let name: String? public let path: String? diff --git a/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift b/Sources/Xcode/Model/Phase/Xcode+CopyFilesDestination.swift similarity index 90% rename from Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift rename to Sources/Xcode/Model/Phase/Xcode+CopyFilesDestination.swift index 06cc849..0d8940b 100644 --- a/Sources/Xcode2/Model/Phase/XCode+CopyFilesDestination.swift +++ b/Sources/Xcode/Model/Phase/Xcode+CopyFilesDestination.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct CopyFilesDestination: Codable { public let path: String? public let subfolder: String? diff --git a/Sources/Xcode2/Model/Project/XCode+Project.swift b/Sources/Xcode/Model/Project/Xcode+Project.swift similarity index 97% rename from Sources/Xcode2/Model/Project/XCode+Project.swift rename to Sources/Xcode/Model/Project/Xcode+Project.swift index 2b36cbd..87dd0b6 100644 --- a/Sources/Xcode2/Model/Project/XCode+Project.swift +++ b/Sources/Xcode/Model/Project/Xcode+Project.swift @@ -1,9 +1,9 @@ import Foundation import PathKit -// MARK: - XCode.Project +// MARK: - Xcode.Project -extension XCode { +extension Xcode { public struct Project: Encodable { public let name: String public let workspacePath: String @@ -54,7 +54,7 @@ extension XCode { } } -extension XCode.Project { +extension Xcode.Project { /// The package that was handed to bazelize, when the input was a manifest /// rather than an `.xcodeproj`. public var packageRoot: Path? { @@ -63,7 +63,7 @@ extension XCode.Project { return path.parent() } - public var config: [String: XCode.BuildSettings]? { + public var config: [String: Xcode.BuildSettings]? { configs } diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift b/Sources/Xcode/Model/SwiftPM/Xcode+LocalPackage.swift similarity index 87% rename from Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift rename to Sources/Xcode/Model/SwiftPM/Xcode+LocalPackage.swift index c3b0ba0..aa5d704 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+LocalPackage.swift +++ b/Sources/Xcode/Model/SwiftPM/Xcode+LocalPackage.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct LocalPackage: Codable { public let name: String? public let relativePath: String diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift b/Sources/Xcode/Model/SwiftPM/Xcode+PackageProductDependency.swift similarity index 90% rename from Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift rename to Sources/Xcode/Model/SwiftPM/Xcode+PackageProductDependency.swift index 6088042..4ec5e08 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+PackageProductDependency.swift +++ b/Sources/Xcode/Model/SwiftPM/Xcode+PackageProductDependency.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct PackageProductDependency: Codable { public let productName: String public let package: String? diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift b/Sources/Xcode/Model/SwiftPM/Xcode+Packages.swift similarity index 87% rename from Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift rename to Sources/Xcode/Model/SwiftPM/Xcode+Packages.swift index fecbac1..2e6f745 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+Packages.swift +++ b/Sources/Xcode/Model/SwiftPM/Xcode+Packages.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct Packages: Codable { public let remote: [RemotePackage] public let local: [LocalPackage] diff --git a/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift b/Sources/Xcode/Model/SwiftPM/Xcode+RemotePackage.swift similarity index 96% rename from Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift rename to Sources/Xcode/Model/SwiftPM/Xcode+RemotePackage.swift index 1d7df5f..0f4e085 100644 --- a/Sources/Xcode2/Model/SwiftPM/XCode+RemotePackage.swift +++ b/Sources/Xcode/Model/SwiftPM/Xcode+RemotePackage.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct RemotePackage: Codable { public enum Requirement: Codable, Equatable { case upToNextMajorVersion(String) diff --git a/Sources/Xcode2/Model/Target/XCode+CodeSign.swift b/Sources/Xcode/Model/Target/Xcode+CodeSign.swift similarity index 90% rename from Sources/Xcode2/Model/Target/XCode+CodeSign.swift rename to Sources/Xcode/Model/Target/Xcode+CodeSign.swift index 27b0fce..2e9999e 100644 --- a/Sources/Xcode2/Model/Target/XCode+CodeSign.swift +++ b/Sources/Xcode/Model/Target/Xcode+CodeSign.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct CodeSign: Codable { public let developmentTeam: String? public let codeSignStyle: String? diff --git a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift b/Sources/Xcode/Model/Target/Xcode+Dependencies.swift similarity index 95% rename from Sources/Xcode2/Model/Target/XCode+Dependencies.swift rename to Sources/Xcode/Model/Target/Xcode+Dependencies.swift index 20d52ce..40fa4cf 100644 --- a/Sources/Xcode2/Model/Target/XCode+Dependencies.swift +++ b/Sources/Xcode/Model/Target/Xcode+Dependencies.swift @@ -1,6 +1,6 @@ -// MARK: - XCode.Dependencies +// MARK: - Xcode.Dependencies -extension XCode { +extension Xcode { public struct Dependencies: Codable { public let targets: [String] public let packageProducts: [PackageProductDependency] @@ -15,7 +15,7 @@ extension XCode { } } -extension XCode.Dependencies { +extension Xcode.Dependencies { enum CodingKeys: String, CodingKey { case targets case packageProducts diff --git a/Sources/Xcode2/Model/Target/XCode+Target.swift b/Sources/Xcode/Model/Target/Xcode+Target.swift similarity index 94% rename from Sources/Xcode2/Model/Target/XCode+Target.swift rename to Sources/Xcode/Model/Target/Xcode+Target.swift index a497a99..6e689d2 100644 --- a/Sources/Xcode2/Model/Target/XCode+Target.swift +++ b/Sources/Xcode/Model/Target/Xcode+Target.swift @@ -1,8 +1,8 @@ import Foundation -// MARK: - XCode.Target +// MARK: - Xcode.Target -extension XCode { +extension Xcode { public struct Target: Encodable { public let name: String public let productName: String? @@ -16,7 +16,7 @@ extension XCode { } } -extension Dictionary where Key == String, Value == XCode.BuildSettings { +extension Dictionary where Key == String, Value == Xcode.BuildSettings { fileprivate var sortedByKey: [(key: Key, value: Value)] { sorted { lhs, rhs in lhs.key < rhs.key @@ -36,12 +36,12 @@ extension Dictionary where Key == String, Value == XCode.BuildSettings { } } -extension XCode.Target { - public func prefer(_ keyPath: KeyPath) -> T? { +extension Xcode.Target { + public func prefer(_ keyPath: KeyPath) -> T? { configs.prefer(config: preferConfig, keyPath) } - public func prefer(_ keyPath: KeyPath) -> T? { + public func prefer(_ keyPath: KeyPath) -> T? { configs.prefer(config: preferConfig, keyPath) } @@ -183,7 +183,7 @@ extension XCode.Target { dependencies.sdkFrameworkSearchPaths } - public var selectedSettings: XCode.BuildSettings { + public var selectedSettings: Xcode.BuildSettings { if let preferConfig, let settings = configs[preferConfig] { return settings } @@ -196,14 +196,14 @@ extension XCode.Target { return .init(name: "", setting: [:]) } - private func filePath(_ file: XCode.File) -> String? { + private func filePath(_ file: Xcode.File) -> String? { guard let path = file.path?.trimmingCharacters(in: CharacterSet(charactersIn: "/")), !path.isEmpty else { return nil } return "Sources/\(path)" } - private func filePaths(_ files: [XCode.File]) -> [String] { + private func filePaths(_ files: [Xcode.File]) -> [String] { files.compactMap(filePath) } } diff --git a/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift b/Sources/Xcode/Model/Target/Xcode+TargetMetadata.swift similarity index 94% rename from Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift rename to Sources/Xcode/Model/Target/Xcode+TargetMetadata.swift index add0ec3..94dd278 100644 --- a/Sources/Xcode2/Model/Target/XCode+TargetMetadata.swift +++ b/Sources/Xcode/Model/Target/Xcode+TargetMetadata.swift @@ -1,4 +1,4 @@ -extension XCode { +extension Xcode { public struct TargetMetadata: Codable { public let bundleID: String? public let moduleName: String? diff --git a/Sources/Xcode2/TargetSummaryFormatter.swift b/Sources/Xcode/TargetSummaryFormatter.swift similarity index 93% rename from Sources/Xcode2/TargetSummaryFormatter.swift rename to Sources/Xcode/TargetSummaryFormatter.swift index 7fcd1fc..931e127 100644 --- a/Sources/Xcode2/TargetSummaryFormatter.swift +++ b/Sources/Xcode/TargetSummaryFormatter.swift @@ -1,10 +1,10 @@ import Foundation -// MARK: - XCode.TargetSummaryFormatter +// MARK: - Xcode.TargetSummaryFormatter -extension XCode { +extension Xcode { public enum TargetSummaryFormatter { - public static func format(project: XCode.Project, target: XCode.Target) -> String { + public static func format(project: Xcode.Project, target: Xcode.Target) -> String { var lines: [String] = [] lines.append("Target: \(target.name)") @@ -66,7 +66,7 @@ extension XCode { lines.append(" \(label): \(value)") } - private static func appendFiles(_ files: [XCode.File], title: String, to lines: inout [String]) { + private static func appendFiles(_ files: [Xcode.File], title: String, to lines: inout [String]) { guard !files.isEmpty else { return } lines.append(" \(title):") @@ -86,13 +86,13 @@ extension XCode { } } -extension XCode.File { +extension Xcode.File { fileprivate var summaryPath: String { path ?? name ?? fullPath ?? label ?? "" } } -extension XCode.PackageProductDependency { +extension Xcode.PackageProductDependency { fileprivate var summaryText: String { if let package, !package.isEmpty { return "\(package) / \(productName)" diff --git a/Sources/Xcode/Xcode.swift b/Sources/Xcode/Xcode.swift new file mode 100644 index 0000000..e300f9d --- /dev/null +++ b/Sources/Xcode/Xcode.swift @@ -0,0 +1 @@ +public enum Xcode { } diff --git a/Sources/Xcode2/XCode.swift b/Sources/Xcode2/XCode.swift deleted file mode 100644 index a6adabd..0000000 --- a/Sources/Xcode2/XCode.swift +++ /dev/null @@ -1 +0,0 @@ -public enum XCode { } diff --git a/Tests/XCodeTests/PropertyTests.swift b/Tests/XCodeTests/PropertyTests.swift deleted file mode 100644 index b7faca9..0000000 --- a/Tests/XCodeTests/PropertyTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// PropertyTests.swift -// -// -// Created by Yume on 2022/8/3. -// - -import Testing -import XcodeProj -@testable import XCode - -// MARK: - Setting - -private struct Setting: @unchecked Sendable { - // MARK: Lifecycle - - init(_ setting: [String: Any]) { - self.setting = setting - } - - // MARK: Internal - - let setting: [String: Any] - - var plist: [String] { [] } - - var name: String { "" } - - var bundleID: String? { self[#function] } - - var team: String? { self[#function] } - - var swiftVersion: String? { self[#function] } - - var deviceFamily: [DeviceFamily] { [] } - - var sdk: SDK? { nil } - - var iOS: String? { self[#function] } - - var macOS: String? { self[#function] } - - var tvOS: String? { self[#function] } - - var watchOS: String? { self[#function] } - - var driverKit: String? { self[#function] } - - var generateInfoPlist: Bool { false } - - var plistKeys: [String] { [] } - - var infoPlist: String? { nil } - - var defaultPlist: [String] { [] } - - var launch: String? { self[#function] } - - var storyboard: String? { self[#function] } - - var testHost: String? { nil } - var testTargetName: String? { nil } - var bundleLoader: String? { nil } - - var swiftDefine: String? { nil } - var enableModules: Bool { false } - - // MARK: Private - - private subscript(key: String) -> T? { - setting[key] as? T - } -} - -// MARK: - XCodeTests - -struct XCodeTests { - private static let release = Setting([ - "iOS": "9.0", - "macOS": "10.15", - ]) - private static let debug = Setting([ - "iOS": "10.0", - "macOS": "10.15", - ]) - private static let config: [String: Setting] = [ - "Release": release, - "Debug": debug, - ] -} - -// MARK: Test Select -extension XCodeTests { - @Test - func testSelectSame() { - let code = Self.config.select(\.macOS).starlark.text - #expect(code == """ - "10.15" - """) - } - - @Test - func testSelectVarious() { - let code = Self.config.select(\.iOS).starlark.text - #expect(code == """ - select({ - "//:Debug": "10.0", - "//:Release": "9.0" - }) - """) - } -} - -// MARK: Test Prefer -extension XCodeTests { - @Test - func testPreferHit() { - let code = Self.config.prefer(config: "Release", \.iOS) - #expect(code == "9.0") - } - - @Test - func testPreferNotHit() { - let code = Self.config.prefer(config: "Release2", \.iOS) - #expect(code == "10.0") - } - - @Test - func testPreferNoValue() { - let config: [String: Setting] = [:] - let code = config.prefer(config: "Release", \.iOS) - #expect(code == nil) - } -} diff --git a/Tests/XCode2Tests/BuildSettingsTests.swift b/Tests/XcodeTests/BuildSettingsTests.swift similarity index 93% rename from Tests/XCode2Tests/BuildSettingsTests.swift rename to Tests/XcodeTests/BuildSettingsTests.swift index 93908b1..7b1ffc1 100644 --- a/Tests/XCode2Tests/BuildSettingsTests.swift +++ b/Tests/XcodeTests/BuildSettingsTests.swift @@ -1,10 +1,10 @@ import Testing -@testable import XCode2 +@testable import Xcode struct BuildSettingsTests { @Test func buildSettingsHelpersExposeSemanticValues() { - let settings = XCode.BuildSettings( + let settings = Xcode.BuildSettings( name: "Debug", setting: [ "PRODUCT_BUNDLE_IDENTIFIER": "com.example.app", @@ -17,7 +17,7 @@ struct BuildSettingsTests { @Test func buildSettingsPlistHelpersReadExpectedKeys() { - let settings = XCode.BuildSettings( + let settings = Xcode.BuildSettings( name: "Release", setting: [ "GENERATE_INFOPLIST_FILE": "YES", @@ -39,7 +39,7 @@ struct BuildSettingsTests { @Test func buildSettingsPlatformHelpersReadDeploymentTargets() { - let settings = XCode.BuildSettings( + let settings = Xcode.BuildSettings( name: "Release", setting: [ "IPHONEOS_DEPLOYMENT_TARGET": "16.0", @@ -57,7 +57,7 @@ struct BuildSettingsTests { @Test func buildSettingsPlatformHelpersReadAppleFamiliesLiteral() { - let settings = XCode.BuildSettings( + let settings = Xcode.BuildSettings( name: "Release", setting: [ "TARGETED_DEVICE_FAMILY": "1 2", @@ -69,7 +69,7 @@ struct BuildSettingsTests { @Test func buildSettingsMetadataHelpersReadExpectedKeys() { - let settings = XCode.BuildSettings( + let settings = Xcode.BuildSettings( name: "Release", setting: [ "PRODUCT_BUNDLE_IDENTIFIER": "com.example.app", @@ -90,7 +90,7 @@ struct BuildSettingsTests { @Test func buildSettingsResolveNestedVariables() { - let settings = XCode.BuildSettings( + let settings = Xcode.BuildSettings( name: "Release", setting: [ "TARGET_NAME": "iina", @@ -102,7 +102,7 @@ struct BuildSettingsTests { @Test func buildSettingsPlistEntriesRenderCommonInfoPlistKeys() { - let settings = XCode.BuildSettings( + let settings = Xcode.BuildSettings( name: "Release", setting: [ "GENERATE_INFOPLIST_FILE": "YES", diff --git a/Tests/XCode2Tests/EncodingTests.swift b/Tests/XcodeTests/EncodingTests.swift similarity index 90% rename from Tests/XCode2Tests/EncodingTests.swift rename to Tests/XcodeTests/EncodingTests.swift index 47a8768..2ebff23 100644 --- a/Tests/XCode2Tests/EncodingTests.swift +++ b/Tests/XcodeTests/EncodingTests.swift @@ -1,11 +1,11 @@ import Foundation import Testing -@testable import XCode2 +@testable import Xcode struct EncodingTests { @Test func filesEncodingOmitsEmptyCopyFiles() throws { - let value = XCode.Files( + let value = Xcode.Files( sources: [], headers: [], resources: [], diff --git a/Tests/XCode2Tests/PackageDeploymentTests.swift b/Tests/XcodeTests/PackageDeploymentTests.swift similarity index 100% rename from Tests/XCode2Tests/PackageDeploymentTests.swift rename to Tests/XcodeTests/PackageDeploymentTests.swift diff --git a/Tests/XCode2Tests/ProjectLoaderTests.swift b/Tests/XcodeTests/ProjectLoaderTests.swift similarity index 90% rename from Tests/XCode2Tests/ProjectLoaderTests.swift rename to Tests/XcodeTests/ProjectLoaderTests.swift index 1b4195b..a7702cb 100644 --- a/Tests/XCode2Tests/ProjectLoaderTests.swift +++ b/Tests/XcodeTests/ProjectLoaderTests.swift @@ -1,15 +1,15 @@ import PathKit import Testing -@testable import XCode2 +@testable import Xcode struct ProjectLoaderTests { @Test func mergeLocalPackagesKeepsExplicitEntriesFirstAndDeduplicatesByPath() { - let explicit: [XCode.LocalPackage] = [ + let explicit: [Xcode.LocalPackage] = [ .init(name: "Local1", relativePath: "Local1"), .init(name: "Local2", relativePath: "Local2"), ] - let discovered: [XCode.LocalPackage] = [ + let discovered: [Xcode.LocalPackage] = [ .init(name: "Local1 (Scanned)", relativePath: "Local1"), .init(name: "Local3", relativePath: "Local3"), ] @@ -31,7 +31,7 @@ struct ProjectLoaderTests { .parent() let projectPath = current + "app/IceCubesApp/IceCubesApp.xcodeproj" - let project = try XCode.Project.load(path: projectPath, preferConfig: nil) + let project = try Xcode.Project.load(path: projectPath, preferConfig: nil) let target = try #require(project.targets.first { $0.name == "IceCubesShareExtension" }) #expect(target.files.sources.contains { $0.path == "IceCubesShareExtension/ShareViewController.swift" }) @@ -46,7 +46,7 @@ struct ProjectLoaderTests { .parent() let projectPath = current + "app/iina/IINA.xcodeproj" - let project = try XCode.Project.load(path: projectPath, preferConfig: "Release") + let project = try Xcode.Project.load(path: projectPath, preferConfig: "Release") let target = try #require(project.targets.first { $0.name == "iina-cli" }) #expect(target.prefer(\.platform.sdk) == .macOS) @@ -61,7 +61,7 @@ struct ProjectLoaderTests { .parent() let projectPath = current + "app/iina/IINA.xcodeproj" - let project = try XCode.Project.load(path: projectPath, preferConfig: "Release") + let project = try Xcode.Project.load(path: projectPath, preferConfig: "Release") let target = try #require(project.targets.first { $0.name == "iina" }) #expect(target.dependencies.sdkFrameworks.contains("CoreDisplay")) diff --git a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift b/Tests/XcodeTests/RoadmapTreeBuilderTests.swift similarity index 99% rename from Tests/XCode2Tests/RoadmapTreeBuilderTests.swift rename to Tests/XcodeTests/RoadmapTreeBuilderTests.swift index 385fb10..1891624 100644 --- a/Tests/XCode2Tests/RoadmapTreeBuilderTests.swift +++ b/Tests/XcodeTests/RoadmapTreeBuilderTests.swift @@ -2,7 +2,7 @@ import BazelizeKit import Foundation import PathKit import Testing -@testable import XCode2 +@testable import Xcode struct RoadmapTreeBuilderTests { @Test diff --git a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift b/Tests/XcodeTests/TargetSummaryFormatterTests.swift similarity index 96% rename from Tests/XCode2Tests/TargetSummaryFormatterTests.swift rename to Tests/XcodeTests/TargetSummaryFormatterTests.swift index 4d1cc7f..efb847d 100644 --- a/Tests/XCode2Tests/TargetSummaryFormatterTests.swift +++ b/Tests/XcodeTests/TargetSummaryFormatterTests.swift @@ -1,10 +1,10 @@ import Testing -@testable import XCode2 +@testable import Xcode struct TargetSummaryFormatterTests { @Test func formatTargetSummary() throws { - let target = XCode.Target( + let target = Xcode.Target( name: "Example", productName: "Example", productType: "com.apple.product-type.application", @@ -90,7 +90,7 @@ struct TargetSummaryFormatterTests { sdkFrameworkSearchPaths: [], weakSDKFrameworks: [])) - let project = XCode.Project( + let project = Xcode.Project( name: "Example", workspacePath: "/tmp", projectPath: "/tmp/Example.xcodeproj", @@ -99,7 +99,7 @@ struct TargetSummaryFormatterTests { packages: .init(remote: [], local: []), targets: [target]) - let summary = XCode.TargetSummaryFormatter.format(project: project, target: target) + let summary = Xcode.TargetSummaryFormatter.format(project: project, target: target) #expect(summary.contains("Target: Example")) #expect(summary.contains("Type: com.apple.product-type.application")) diff --git a/Tests/XCode2Tests/ToolchainTests.swift b/Tests/XcodeTests/ToolchainTests.swift similarity index 98% rename from Tests/XCode2Tests/ToolchainTests.swift rename to Tests/XcodeTests/ToolchainTests.swift index 1254ad7..fdc425c 100644 --- a/Tests/XCode2Tests/ToolchainTests.swift +++ b/Tests/XcodeTests/ToolchainTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import XCode2 +@testable import Xcode /// The values Xcode fills in from the toolchain, which a project writes into its /// `Info.plist` and expects to read back in Xcode's own spelling. diff --git a/docs/SPM.md b/docs/SPM.md index 4c51d19..d285984 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -126,9 +126,8 @@ extension. Every SwiftPM step is the installed toolchain's `swift` command: `swift package resolve` for the checkouts, `swift package dump-package` per checkout for the -manifests, and `swift build` to run a build tool plugin. Not libSwiftPM, even -though this package already links `SwiftPMDataModel` for the legacy `XCode` -target. +manifests, and `swift build` to run a build tool plugin. Not libSwiftPM, which +this package no longer depends on at all. - Plugins cannot move there. Running one needs a build system, and `SwiftPMDataModel` is deliberately the data model alone — `Build`, @@ -137,15 +136,15 @@ target. put two versions of SwiftPM in one `.build`: the checkouts, the `Package.resolved` format and the manifest cache would belong to whichever ran last. One SwiftPM — the same one Xcode uses — is the property worth keeping. -- The dependency is a branch (`swift-6.4.0-RELEASE`, matching the toolchain), - and libSwiftPM says of itself that the API is unstable and may change at any - time. `dump-package`'s JSON spans every tools version in the graph, and it is +- Depending on it means pinning a branch to match the toolchain, and libSwiftPM + says of itself that the API is unstable and may change at any time. + `dump-package`'s JSON spans every tools version in the graph, and it is decoded into the few fields the generator reads. -- The cost is measured: 0.6s per manifest, so 10.8s for this repository's 18 - checkouts. Running them concurrently is slower, not faster — 14.5s with eight - at a time, consistent with contention on the shared manifest cache — so the - loop stays sequential. An app in the corpus has around ten packages, which is - the six seconds a single `loadPackageGraph` would save. +- The cost is measured: 0.6s per manifest, so 10.8s for eighteen checkouts. + Running them concurrently is slower, not faster — 14.5s with eight at a time, + consistent with contention on the shared manifest cache — so the loop stays + sequential. An app in the corpus has around ten packages, which is the six + seconds a single `loadPackageGraph` would save. ### Label naming diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 6168775..1271374 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -115,18 +115,18 @@ module extension 每次評估都在那個 label 所在目錄跑 SwiftPM。 每一步 SwiftPM 都是使用者安裝的 toolchain 的 `swift` 指令:`swift package resolve` 取得 checkouts、每個 checkout 一次 `swift package dump-package` 讀 manifest、 -`swift build` 讓 build tool plugin 跑起來。不是 libSwiftPM,即使本 package 已經為 -舊的 `XCode` target 連了 `SwiftPMDataModel`。 +`swift build` 讓 build tool plugin 跑起來。不是 libSwiftPM——本 package 現在完全 +不依賴它。 - plugin 這一步搬不過去:跑它需要 build system,而 `SwiftPMDataModel` 刻意只有 data model——`Build`、`SPMLLBuild` 與 SwiftDriver 只在完整的 `SwiftPM` product 裡。 用釘住的 library 解析、卻用安裝的 toolchain 建 plugin,等於同一個 `.build` 被兩個 版本的 SwiftPM 寫:checkouts、`Package.resolved` 格式、manifest cache 都屬於最後 跑的那個。「只有一個 SwiftPM,而且和 Xcode 用的是同一個」是值得保留的性質。 -- 這個依賴釘的是 branch(`swift-6.4.0-RELEASE`,對上 toolchain),而 libSwiftPM 自己 - 聲明 API 不穩定、隨時可能改。`dump-package` 的 JSON 橫跨依賴圖裡所有 tools version, - 而且只被解碼成產生器真正要讀的那幾個欄位。 -- 成本量過了:一份 manifest 0.6 秒,本 repo 的 18 個 checkout 共 10.8 秒。改成併發 +- 要依賴它就得釘一個對上 toolchain 的 branch,而 libSwiftPM 自己聲明 API 不穩定、 + 隨時可能改。`dump-package` 的 JSON 橫跨依賴圖裡所有 tools version,而且只被解碼成 + 產生器真正要讀的那幾個欄位。 +- 成本量過了:一份 manifest 0.6 秒,18 個 checkout 共 10.8 秒。改成併發 更慢而不是更快——同時跑八個是 14.5 秒,和共用 manifest cache 的競爭一致——所以迴圈 維持序列。語料裡一個 app 大約十個 package,那六秒就是換成一次 `loadPackageGraph` 能省下的全部。 From d5029fc509c3d7f3340deeebd368ce8420ac0146 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 09:25:12 +0800 Subject: [PATCH 157/173] Remove the CocoaPods module and finish spelling Xcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Cocoapod` was not a target of this package, so nothing built it and nothing could: it read the Xcode parser that is gone. Its tests, the `--skip CocoapodTests` the test target still carried, the three `pod` steps in CI and `Env.pod` — which no code read — go with it. Everything that spelled the product `XCode` now spells it `Xcode`: the `XcodeProj` repo enum and the plugin named after it, the file they live in, and the prose in the docs. `XCodeBazelize` keeps its capitals, being the name of the organisation this repository lives in rather than a word about Xcode. --- .github/workflows/swift.yml | 12 - Makefile | 3 +- README.md | 2 +- RepoSources.yml | 2 +- ...odeProj.swift => BazelDep+XcodeProj.swift} | 4 +- Sources/BazelizeKit/Kit.swift | 2 +- ...XCodeProj.swift => Plugin+XcodeProj.swift} | 8 +- Sources/Cocoapod/CodeGen/NewPod.swift | 34 --- Sources/Cocoapod/Model/PodSpec.swift | 37 --- Sources/Cocoapod/Model/Podfile.swift | 139 ---------- Sources/Cocoapod/Model/PodfileLock.swift | 259 ------------------ Sources/Cocoapod/Pod.swift | 144 ---------- Sources/Cocoapod/PodError.swift | 12 - Sources/Cocoapod/Util.swift | 28 -- Sources/PluginLoader/PluginLoader.swift | 4 +- Sources/Util/Env.swift | 15 - Tests/CocoapodTests/Resource/Podfile | 69 ----- Tests/CocoapodTests/Resource/Podfile.lock | 90 ------ Tests/CocoapodTests/SPMTests.swift | 100 ------- .../RepoEnumCoreTests/RepoEnumCoreTests.swift | 6 +- docs/Dependecy_ZH.md | 8 +- docs/Design.md | 18 +- docs/Design_ZH.md | 24 +- .../plans/2026-04-09-xcode2-print-target.md | 16 +- .../plans/2026-04-11-roadmap-command.md | 16 +- .../plans/2026-04-15-roadmap-bazelfile.md | 6 +- .../2026-04-11-roadmap-command-design.md | 6 +- .../2026-04-15-roadmap-bazelfile-design.md | 2 +- 28 files changed, 63 insertions(+), 1003 deletions(-) rename Sources/BazelizeKit/BazelDep/{BazelDep+XCodeProj.swift => BazelDep+XcodeProj.swift} (96%) rename Sources/BazelizeKit/Plugin/{Plugin+XCodeProj.swift => Plugin+XcodeProj.swift} (90%) delete mode 100644 Sources/Cocoapod/CodeGen/NewPod.swift delete mode 100644 Sources/Cocoapod/Model/PodSpec.swift delete mode 100644 Sources/Cocoapod/Model/Podfile.swift delete mode 100644 Sources/Cocoapod/Model/PodfileLock.swift delete mode 100644 Sources/Cocoapod/Pod.swift delete mode 100644 Sources/Cocoapod/PodError.swift delete mode 100644 Sources/Cocoapod/Util.swift delete mode 100644 Sources/Util/Env.swift delete mode 100644 Tests/CocoapodTests/Resource/Podfile delete mode 100644 Tests/CocoapodTests/Resource/Podfile.lock delete mode 100644 Tests/CocoapodTests/SPMTests.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index cf8336f..5cc4c47 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -27,18 +27,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Check Cocoapod Version - run: pod --version - - - name: Check Cocoapod Path - run: which pod - - - name: Update Cocoapod Repo - run: pod repo update - - # - name: Get Pod Spec - # run: pod spec cat --regex Bagel --version=1.4.0 - - name: Check Xcode Version run: | xcodebuild -version diff --git a/Makefile b/Makefile index ecae22c..6bd70c6 100644 --- a/Makefile +++ b/Makefile @@ -30,8 +30,7 @@ build: format .PHONY: test test: - swift test -v --skip CocoapodTests 2>&1 | xcpretty -# COCOAPOD=$(shell which pod) swift test -v 2>&1 | xcbeautify + swift test -v 2>&1 | xcpretty .PHONY: bazelize bazelize: install diff --git a/README.md b/README.md index 3dfded3..719ebb1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ bazelize --project path/to/Package.swift ### Config -All `XCode configs` is stored in `BUILD` file. +All `Xcode configs` is stored in `BUILD` file. You can build debug version with following code. diff --git a/RepoSources.yml b/RepoSources.yml index 6dc0976..f27b4e6 100644 --- a/RepoSources.yml +++ b/RepoSources.yml @@ -4,7 +4,7 @@ - name: Swift url: https://github.com/bazelbuild/rules_swift module: rules_swift -- name: XCodeProj +- name: XcodeProj url: https://github.com/MobileNativeFoundation/rules_xcodeproj module: rules_xcodeproj - name: SwiftPM diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift b/Sources/BazelizeKit/BazelDep/BazelDep+XcodeProj.swift similarity index 96% rename from Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift rename to Sources/BazelizeKit/BazelDep/BazelDep+XcodeProj.swift index 658ee57..7306c9a 100644 --- a/Sources/BazelizeKit/BazelDep/BazelDep+XCodeProj.swift +++ b/Sources/BazelizeKit/BazelDep/BazelDep+XcodeProj.swift @@ -1,7 +1,7 @@ extension BazelDep { /// https://github.com/MobileNativeFoundation/rules_xcodeproj - enum XCodeProj: String { - static let latest: XCodeProj = .v4_1_0 + enum XcodeProj: String { + static let latest: XcodeProj = .v4_1_0 case v4_1_0 = "4.1.0" case v4_0_1 = "4.0.1" diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index 2c4b576..d9837f2 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -38,7 +38,7 @@ public final class Kit { pluginSPM, PluginApple(self), PluginSwift(self), - PluginXCodeProj(self), + PluginXcodeProj(self), PluginPlistFragment(self), PluginLinker(self), ] diff --git a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift b/Sources/BazelizeKit/Plugin/Plugin+XcodeProj.swift similarity index 90% rename from Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift rename to Sources/BazelizeKit/Plugin/Plugin+XcodeProj.swift index 4f4fa99..5c360a3 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+XcodeProj.swift @@ -1,5 +1,5 @@ // -// PluginXCodeProj.swift +// PluginXcodeProj.swift // // // Created by Yume on 2023/2/3. @@ -8,11 +8,11 @@ import Foundation import XcodeProj -// MARK: - PluginXCodeProj +// MARK: - PluginXcodeProj /// https://github.com/MobileNativeFoundation/rules_xcodeproj -final class PluginXCodeProj: PluginBuiltin { - let dep: BazelDep.XCodeProj = .latest +final class PluginXcodeProj: PluginBuiltin { + let dep: BazelDep.XcodeProj = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_xcodeproj", diff --git a/Sources/Cocoapod/CodeGen/NewPod.swift b/Sources/Cocoapod/CodeGen/NewPod.swift deleted file mode 100644 index d5bf6a3..0000000 --- a/Sources/Cocoapod/CodeGen/NewPod.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// NewPodRepository.swift -// -// -// Created by Yume on 2022/5/11. -// - -import Foundation - -// MARK: - NewPodRepository - -/// https://github.com/${organization,user}/${repo}/archive/${commit,branch,tag}.zip -/// -/// new_pod_repository( -/// name = "PINOperation", -/// url = "https://github.com/pinterest/PINOperation/archive/1.2.1.zip", -/// ) -protocol NewPodRepository { - var name: String { get } - var url: String { get } - - var code: String { get } -} - -extension NewPodRepository { - var code: String { - """ - new_pod_repository( - name = "\(name)", - url = "\(url)", - ) - """ - } -} diff --git a/Sources/Cocoapod/Model/PodSpec.swift b/Sources/Cocoapod/Model/PodSpec.swift deleted file mode 100644 index afe6c78..0000000 --- a/Sources/Cocoapod/Model/PodSpec.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// PodSpec.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import Util - -// MARK: - PodSpec - -struct PodSpec: Codable, JSONParsable, NewPodRepository { - let name: String - let source: PodSpecSource - - var url: String { - source.url - } -} - -// MARK: - PodSpecSource - -// "source": { -// "git": "https://github.com/yagiz/Bagel.git", -// "tag": "1.4.0" -// }, -struct PodSpecSource: Codable { - let git: String - let tag: String - - /// https://github.com/yagiz/Bagel/archive/1.4.0.zip - var url: String { - let base = git.replacingOccurrences(of: ".git", with: "") - return "\(base)/archive/\(tag).zip" - } -} diff --git a/Sources/Cocoapod/Model/Podfile.swift b/Sources/Cocoapod/Model/Podfile.swift deleted file mode 100644 index 0544be5..0000000 --- a/Sources/Cocoapod/Model/Podfile.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// Podfile.swift -// -// -// Created by Yume on 2022/4/25. -// - -import AnyCodable -import Foundation -import PathKit -import Util - -// MARK: - Podfile - -struct Podfile: Codable, JSONParsable { - // MARK: Internal - - static func process(_ path: Path) async throws -> Podfile { - /// pod ipc podfile-json Podfile - let data = try await Process.execute( - Env.pod, - arguments: "ipc", "podfile-json", path.string) - - return try Podfile.parse(data) - } - - - subscript(targetName: String) -> [String] { - self[target: targetName]?.depsCode ?? [] - } - - // MARK: Fileprivate - - fileprivate let target_definitions: [PodDefinition] - - fileprivate var flatTarget: [PodChildren] { - target_definitions.flatMap(\.flatTarget) - } - - - fileprivate subscript(target targetName: String) -> PodChildren? { - flatTarget.first { _target in - _target.name == targetName - } - } -} - -// MARK: - PodDefinition - -private struct PodDefinition: Codable { - fileprivate let children: [PodChildren] - fileprivate var flatTarget: [PodChildren] { - children.flatMap(\.flatTarget) - } -} - -// MARK: - PodChildren - -private struct PodChildren: Codable { - // MARK: Internal - - var depsCode: [String] { - dependencies? - .sorted { lhs, rhs in - lhs.code < rhs.code - } - .map(\.code) ?? [] - } - - // MARK: Fileprivate - - /// Target - fileprivate let name: String - fileprivate let dependencies: [PodDependency]? - - - fileprivate var flatTarget: [PodChildren] { - if let child = children { - return [self] + child.flatMap(\.flatTarget) - } else { - return [self] - } - } - - // MARK: Private - -// "configuration_pod_whitelist": { -// "Debug": [ -// "Peek", -// "Bagel" -// ] -// }, -// let configuration_pod_whitelist - private let children: [PodChildren]? -} - -// MARK: - PodDependency - -private struct PodDependency: Codable { - // MARK: Lifecycle - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - do { - let name = try container.decode(String.self) - (package, target) = Util.parse(name: name) - return - } catch { - let podGraph = try container.decode([String: AnyCodable].self) - guard let name = podGraph.keys.first else { - throw PodError.reason(""" - Parse Podfile fail - Pod: \(podGraph) - """) - } - - (package, target) = Util.parse(name: name) - return - } - } - - // MARK: Internal - - let package: String - let target: String - - - /// //Vendor/__PACKAGE__:__TARGET__ - /// - /// subpsec `Core` in `PINCache` - /// //Vendor/PINCache:Core - /// - /// "//Vendor/RxSwift:RxSwift", - var code: String { - """ - //Vendor/\(package):\(target) - """ - } -} diff --git a/Sources/Cocoapod/Model/PodfileLock.swift b/Sources/Cocoapod/Model/PodfileLock.swift deleted file mode 100644 index ed5904c..0000000 --- a/Sources/Cocoapod/Model/PodfileLock.swift +++ /dev/null @@ -1,259 +0,0 @@ -// -// PodfileLock.swift -// -// -// Created by Yume on 2022/4/26. -// - -import Foundation -import Util - -// MARK: - PodfileLock - -struct PodfileLock: Codable, YamlParsable { - // MARK: Internal - - enum CodingKeys: String, CodingKey { - case pods = "PODS" - case externals = "EXTERNAL SOURCES" - case checkouts = "CHECKOUT OPTIONS" - case spec = "SPEC CHECKSUMS" - } - - - var repos: [NewPodRepository] { - get async throws { - try await withThrowingTaskGroup(of: NewPodRepository.self) { group -> [NewPodRepository] in - for spec in specs { - group.addTask { - try await spec.repo(lock: self) - } - } - - return try await group.all - }.sorted { lhs, rhs in - lhs.name < rhs.name - } - } - } - - var repoCodes: [String] { - get async throws { - try await repos.map(\.code) - } - } - - // MARK: Private - - private let pods: [PodfileLock.Pod] - private let externals: [String: ExternalSource]? - private let checkouts: [String: CheckoutOption]? - private let spec: [String: String] - - - private var specs: [PodfileLock.Pod] { - let set = Set(pods) - return set.map { $0 } - } -} - -// MARK: PodfileLock.Pod - -extension PodfileLock { - /// //Vendor/__PACKAGE__:__TARGET__ - fileprivate struct Pod: Codable, Hashable, Equatable { - // MARK: Lifecycle - - init(package: String, target: String, tag: String) { - self.package = package - self.target = target - self.tag = tag - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - do { - let pod = try container.decode(String.self) - self = try Self.parse(pod: pod) - } catch { - let podGraph = try container.decode([String: [String]].self) - guard let pod = podGraph.keys.first else { - throw PodError.reason(""" - Parse Podfile.lock fail - Pod: \(podGraph) - """) - } - self = try Self.parse(pod: pod) - } - } - - // MARK: Internal - - let package: String - let target: String - let tag: String - - - static func == (lhs: Pod, rhs: Pod) -> Bool { - lhs.package == rhs.package - } - - - func hash(into hasher: inout Hasher) { - hasher.combine(package) - } - - // MARK: Fileprivate - - fileprivate var isDefaultSubSpec: Bool { - package == target - } - - - fileprivate func repo(lock: PodfileLock) async throws -> NewPodRepository { - guard - let external = lock.externals?[package], - let checkout = lock.checkouts?[package] - else { - return try await podSpec - } - return try PodRepository(pod: self, external: external, checkout: checkout) - } - - // MARK: Private - - /// pod spec cat Bagel --version=1.4.0 - private var podSpec: PodSpec { - get async throws { - let query = """ - ^\(package)$ - """ - .replacingOccurrences(of: "+", with: "\\+") - .replacingOccurrences(of: ".", with: "\\.") - let arg = "spec cat --regex \(query) --version=\(tag)" - - let data = try await Process.execute( - Env.pod, -// arguments: arg - arguments: "spec", "cat", "--regex", query, "--version=\(tag)") - do { - return try PodSpec.parse(data) - } catch { - print(""" - Parse Podspec Fail - `\(Env.pod) spec cat --regex \(query) --version=\(tag)` - \(arg) - string: \(String(data: data, encoding: .utf8) ?? "") - \(error) - - """) - throw error - } - } - } - - - /// AFNetworking (4.0.1) - /// AFNetworking/NSURLSession (4.0.1) - private static func parse(pod: String) throws -> PodfileLock.Pod { - let parts = pod.split(separator: " ").map(String.init) - guard parts.count == 2 else { - throw PodError.reason(""" - Parse Podfile.lock fail - version - Pod: \(pod) - """) - } - - let name = parts[0] - let tag = parts[1] - .replacingOccurrences(of: "(", with: "") - .replacingOccurrences(of: ")", with: "") - - let (package, target) = Util.parse(name: name) - - return .init(package: package, target: target, tag: tag) - } - } -} - -// MARK: PodfileLock.ExternalSource - -/// EXTERNAL SOURCES: -extension PodfileLock { - fileprivate struct ExternalSource: Codable { - let git: String - let tag: String? - let commit: String? - let branch: String? - - enum CodingKeys: String, CodingKey { - case git = ":git" - case tag = ":tag" - case commit = ":commit" - case branch = ":branch" - } - } -} - -// MARK: PodfileLock.CheckoutOption - -/// CHECKOUT OPTIONS: -extension PodfileLock { - fileprivate struct CheckoutOption: Codable { - let git: String - let tag: String? - let commit: String? - - enum CodingKeys: String, CodingKey { - case git = ":git" - case tag = ":tag" - case commit = ":commit" - } - } -} - -// MARK: PodfileLock.PodRepository - -extension PodfileLock { - fileprivate struct PodRepository: NewPodRepository { - // MARK: Lifecycle - - fileprivate init( - pod: PodfileLock.Pod, - external: PodfileLock.ExternalSource, - checkout: PodfileLock.CheckoutOption) throws - { - name = pod.package - let base = external.git.replacingOccurrences(of: ".git", with: "") - - /// branch > tag > commit - if let branch = external.branch { - url = "\(base)/archive/\(branch).zip" - return - } - - if let tag = external.tag ?? checkout.tag { - url = "\(base)/archive/\(tag).zip" - return - } - - if let commit = external.commit ?? checkout.commit { - url = "\(base)/archive/\(commit).zip" - return - } - - #warning("todo error design") - throw PodError.reason(""" - Parse Podfile.lock Error - Can't find git at \(pod.package) - """) - } - - // MARK: Internal - - let name: String - /// https://github.com/${organization,user}/${repo}/archive/${commit,branch,tag}.zip - let url: String - } -} diff --git a/Sources/Cocoapod/Pod.swift b/Sources/Cocoapod/Pod.swift deleted file mode 100644 index 50027bd..0000000 --- a/Sources/Cocoapod/Pod.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// Pod.swift -// -// -// Created by Yume on 2022/4/26. -// - -import Foundation -import PathKit -import PluginLoader -import Util -import Xcode - - -@_cdecl("createPlugin") -public func createPlugin() -> UnsafeMutableRawPointer { - Unmanaged.passRetained(_PluginBuilder()).toOpaque() -} - -// MARK: - _PluginBuilder - -final class _PluginBuilder: PluginBuilder { - override final func build(_ proj: Project) async throws -> Plugin? { - try await Pod.load(proj) - } -} - - -// MARK: - Pod - -public final class Pod { - // MARK: Lifecycle - - init(podfile: Podfile, lock: PodfileLock, repoCodes: [String]) { - self.podfile = podfile - self.lock = lock - self.repoCodes = repoCodes - } - - // MARK: Public - - public let name = "Cocoapod" - public let description = "Use `PodToBUILD`" - public let version = "0.0.1" - public let url = "https://github.com/XCodeBazelize/Bazelize" - - // MARK: Internal - - let podfile: Podfile - let lock: PodfileLock - - let repoCodes: [String] -} - -extension Pod { - // MARK: Public - - public static func parse(_ path: Path) async throws -> Pod? { - guard checkPodfile(path) else { return nil } - async let podfile = Podfile.process(path + "Podfile") - let lock = try PodfileLock.parse(path + "Podfile.lock") - async let codes = lock.repoCodes - return try await .init(podfile: podfile, lock: lock, repoCodes: codes) - } - - // MARK: Private - - private static func checkPodfile(_ path: Path) -> Bool { - let podfile = path + "Podfile" - let lock = path + "Podfile.lock" - switch (podfile.exists, lock.exists) { - case (true, true): - checkCommand() - return true - case (true, false): - print("Need Podfile.lock to check dependencies version") - exit(1) - default: - return false - } - } - - private static func checkCommand() { - guard Process.result(Env.pod, arguments: "--version") else { - print("Need install cocoapod or COCOAPOD=/xxx/pod") - exit(1) - } - } -} - -// MARK: Plugin - -extension Pod: Plugin { - // MARK: Public - - public static func load(_ proj: Project) async throws -> Pod? { - try await parse(proj.workspacePath) - } - - - public func workspace() -> String { - """ - # rules_pods - http_archive( - name = "rules_pods", - urls = ["https://github.com/pinterest/PodToBUILD/releases/download/4.1.0-412495/PodToBUILD.zip"], - # sha256 = "", - ) - - load("@rules_pods//BazelExtensions:workspace.bzl", "new_pod_repository") - """ - } - - /// "//Vendor/RxSwift:RxSwift", - /// "//Vendor/Alamofire:Alamofire", - public subscript(target: String) -> PluginTarget? { - PodPluginTarget(deps: podfile[target]) - } - - /// bazel run @rules_pods//:update_pods -- --src_root `PWD` - public func tip() { - print(""" - use bazel run @rules_pods//:update_pods -- --src_root `PWD` to install pod deps. - """) - } - - /// generate Pods.WORKSPACE - public func generateFile(_ rootPath: Path) throws { - let code = repoCodes.joined(separator: "\n\n") - let PodWorkspace = rootPath + "Pods.WORKSPACE" - print("Create \(PodWorkspace.string)") -// try to.delete() - try PodWorkspace.write(code) - } - - // MARK: Private - - private struct PodPluginTarget: PluginTarget { - let deps: [String] - var framework: [String] { - [] - } - } -} diff --git a/Sources/Cocoapod/PodError.swift b/Sources/Cocoapod/PodError.swift deleted file mode 100644 index 22353c3..0000000 --- a/Sources/Cocoapod/PodError.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// PodError.swift -// -// -// Created by Yume on 2022/5/11. -// - -import Foundation - -enum PodError: Error { - case reason(String) -} diff --git a/Sources/Cocoapod/Util.swift b/Sources/Cocoapod/Util.swift deleted file mode 100644 index cc4d9f9..0000000 --- a/Sources/Cocoapod/Util.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Util.swift -// -// -// Created by Yume on 2022/5/11. -// - -import Foundation - -enum Util { - /// name: AFNetworking - /// package: AFNetworking - /// target: AFNetworking - /// - /// name AFNetworking/NSURLSession - /// package: AFNetworking - /// target: NSURLSession - static func parse(name: String) -> (package: String, target: String) { - let parts = name.split(separator: "/").map(String.init) - switch parts.count { - case 1: - return (name, name) - /// 2 up - default: - return (parts[0], parts[1]) - } - } -} diff --git a/Sources/PluginLoader/PluginLoader.swift b/Sources/PluginLoader/PluginLoader.swift index be2dfd5..919043b 100644 --- a/Sources/PluginLoader/PluginLoader.swift +++ b/Sources/PluginLoader/PluginLoader.swift @@ -25,7 +25,7 @@ enum PluginLoader { /// ## Package.swift /// --- /// - /// `.library(name: "Cocoapod", type: .dynamic, targets: ["Cocoapod"]),` + /// `.library(name: "YourPlugin", type: .dynamic, targets: ["YourPlugin"]),` /// /// ### Loadable Plugin Implement /// @@ -37,7 +37,7 @@ enum PluginLoader { /// /// final class YourPluginBuilder: PluginBuilder { /// override final func build(_ proj: Xcode.Project) async throws -> Plugin? { - /// try await Pod.load(proj) + /// try await YourPlugin.load(proj) /// } /// } /// ``` diff --git a/Sources/Util/Env.swift b/Sources/Util/Env.swift deleted file mode 100644 index 4a05436..0000000 --- a/Sources/Util/Env.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Env.swift -// -// -// Created by Yume on 2022/4/27. -// - -import Foundation - -public enum Env { - /// COCOAPOD - public static var pod: String { - ProcessInfo.processInfo.environment["COCOAPOD"] ?? "/usr/local/bin/pod" - } -} diff --git a/Tests/CocoapodTests/Resource/Podfile b/Tests/CocoapodTests/Resource/Podfile deleted file mode 100644 index 1fd40b5..0000000 --- a/Tests/CocoapodTests/Resource/Podfile +++ /dev/null @@ -1,69 +0,0 @@ -{ - "target_definitions": [ - { - "name": "Pods", - "abstract": true, - "user_project_path": "ABCDEF.xcodeproj", - "children": [ - { - "name": "ABCDEF", - "uses_frameworks": { - "linkage": "dynamic", - "packaging": "framework" - }, - "configuration_pod_whitelist": { - "Debug": [ - "Peek" - ] - }, - "dependencies": [ - "Peek", - { - "AFNetworking": [ - "~> 4.0" - ] - }, - "Moya/RxSwift", - "Moya/Combine", - { - "DataCompression": [ - { - "git": "https://github.com/mw99/DataCompression" - } - ] - }, - { - "XLPagerTabStrip": [ - { - "git": "https://github.com/xmartlabs/XLPagerTabStrip", - "branch": "master" - } - ] - }, - { - "SVProgressHUD": [ - { - "git": "https://github.com/SVProgressHUD/SVProgressHUD", - "tag": "2.2.5" - } - ] - }, - { - "TLPhotoPicker": [ - { - "git": "https://github.com/tilltue/TLPhotoPicker", - "commit": "0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7" - } - ] - } - ], - "use_modular_headers": { - "for_pods": [ - "AFNetworking" - ] - } - } - ] - } - ] -} diff --git a/Tests/CocoapodTests/Resource/Podfile.lock b/Tests/CocoapodTests/Resource/Podfile.lock deleted file mode 100644 index 18f9dcc..0000000 --- a/Tests/CocoapodTests/Resource/Podfile.lock +++ /dev/null @@ -1,90 +0,0 @@ -PODS: - - AFNetworking (4.0.1): - - AFNetworking/NSURLSession (= 4.0.1) - - AFNetworking/Reachability (= 4.0.1) - - AFNetworking/Security (= 4.0.1) - - AFNetworking/Serialization (= 4.0.1) - - AFNetworking/UIKit (= 4.0.1) - - AFNetworking/NSURLSession (4.0.1): - - AFNetworking/Reachability - - AFNetworking/Security - - AFNetworking/Serialization - - AFNetworking/Reachability (4.0.1) - - AFNetworking/Security (4.0.1) - - AFNetworking/Serialization (4.0.1) - - AFNetworking/UIKit (4.0.1): - - AFNetworking/NSURLSession - - Alamofire (5.6.1) - - DataCompression (3.6.0) - - Moya/Combine (15.0.0): - - Moya/Core - - Moya/Core (15.0.0): - - Alamofire (~> 5.0) - - Moya/RxSwift (15.0.0): - - Moya/Core - - RxSwift (~> 6.0) - - Peek (5.3.0) - - RxSwift (6.5.0) - - SVProgressHUD (2.2.5) - - TLPhotoPicker (2.1.6) - - XLPagerTabStrip (9.0.0) - -DEPENDENCIES: - - AFNetworking (~> 4.0) - - DataCompression (from `https://github.com/mw99/DataCompression`) - - Moya/Combine - - Moya/RxSwift - - Peek - - SVProgressHUD (from `https://github.com/SVProgressHUD/SVProgressHUD`, tag `2.2.5`) - - TLPhotoPicker (from `https://github.com/tilltue/TLPhotoPicker`, commit `0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7`) - - XLPagerTabStrip (from `https://github.com/xmartlabs/XLPagerTabStrip`, branch `master`) - -SPEC REPOS: - trunk: - - AFNetworking - - Alamofire - - Moya - - Peek - - RxSwift - -EXTERNAL SOURCES: - DataCompression: - :git: https://github.com/mw99/DataCompression - SVProgressHUD: - :git: https://github.com/SVProgressHUD/SVProgressHUD - :tag: 2.2.5 - TLPhotoPicker: - :commit: 0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7 - :git: https://github.com/tilltue/TLPhotoPicker - XLPagerTabStrip: - :branch: master - :git: https://github.com/xmartlabs/XLPagerTabStrip - -CHECKOUT OPTIONS: - DataCompression: - :commit: 2c0d48be59acd5bdf1a5352d969d6f24bd7212c9 - :git: https://github.com/mw99/DataCompression - SVProgressHUD: - :git: https://github.com/SVProgressHUD/SVProgressHUD - :tag: 2.2.5 - TLPhotoPicker: - :commit: 0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7 - :git: https://github.com/tilltue/TLPhotoPicker - XLPagerTabStrip: - :commit: 903b7609b2b2dd1010efb9ee1c6a27edaa9df89b - :git: https://github.com/xmartlabs/XLPagerTabStrip - -SPEC CHECKSUMS: - AFNetworking: 7864c38297c79aaca1500c33288e429c3451fdce - Alamofire: 87bd8c952f9a4454320fce00d9cc3de57bcadaf5 - DataCompression: 06628f9c807b6f152e0da37635633f62fc51dc77 - Moya: 138f0573e53411fb3dc17016add0b748dfbd78ee - Peek: 4209f7aa72d00244616f6b4804739c04cae2e457 - RxSwift: 5710a9e6b17f3c3d6e40d6e559b9fa1e813b2ef8 - SVProgressHUD: 1428aafac632c1f86f62aa4243ec12008d7a51d6 - TLPhotoPicker: 57ad6b54a9cf8c9ec60107be0864d42f3dbe7175 - XLPagerTabStrip: 6af5fe7b41c21f371860df6bac2ddf12818c5103 - -PODFILE CHECKSUM: 3be7930c466451f51fe2597aada6fdbd7e8817d9 - -COCOAPODS: 1.11.3 diff --git a/Tests/CocoapodTests/SPMTests.swift b/Tests/CocoapodTests/SPMTests.swift deleted file mode 100644 index d0fd3c2..0000000 --- a/Tests/CocoapodTests/SPMTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// SPMTests.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import Testing -@testable import Cocoapod - -struct SPMTests { - // MARK: Internal - - /// Total 9 pod - /// - /// - AFNetworking (4.0.1): - /// x dep's dep - /// - Alamofire (5.6.1) - /// - DataCompression (3.6.0) - /// - /// - Moya/Combine (15.0.0): - /// ? Default Subspec - /// - Moya/Core (15.0.0): - /// - Moya/RxSwift (15.0.0): - /// - /// - Peek (5.3.0) - /// x dep's dep - /// - RxSwift (6.5.0) - /// - SVProgressHUD (2.2.5) - /// - TLPhotoPicker (2.1.6) - /// - XLPagerTabStrip (9.0.0) - @Test - func testParsePodfileLock() async throws { - let code = try Self.strings("Podfile.lock") - let lock = try PodfileLock.parse(code) - let repos = try await lock.repos - #expect(repos.count == 9) - - #expect(repos[0].name == "AFNetworking") - #expect(repos[1].name == "Alamofire") - #expect(repos[2].name == "DataCompression") - #expect(repos[3].name == "Moya") - #expect(repos[4].name == "Peek") - #expect(repos[5].name == "RxSwift") - #expect(repos[6].name == "SVProgressHUD") - #expect(repos[7].name == "TLPhotoPicker") - #expect(repos[8].name == "XLPagerTabStrip") - - #expect(repos[0].url == "https://github.com/AFNetworking/AFNetworking/archive/4.0.1.zip") - #expect(repos[1].url == "https://github.com/Alamofire/Alamofire/archive/5.6.1.zip") - #expect( - repos[2].url, - == "https://github.com/mw99/DataCompression/archive/2c0d48be59acd5bdf1a5352d969d6f24bd7212c9.zip") - #expect(repos[3].url == "https://github.com/Moya/Moya/archive/15.0.0.zip") - #expect(repos[4].url == "https://github.com/shaps80/Peek/archive/5.3.0.zip") - #expect(repos[5].url == "https://github.com/ReactiveX/RxSwift/archive/6.5.0.zip") - #expect(repos[6].url == "https://github.com/SVProgressHUD/SVProgressHUD/archive/2.2.5.zip") - #expect( - repos[7].url, - == "https://github.com/tilltue/TLPhotoPicker/archive/0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7.zip") - #expect(repos[8].url == "https://github.com/xmartlabs/XLPagerTabStrip/archive/master.zip") - } - - @Test - func testParsePodfile() async throws { - let code = try Self.strings("Podfile") - let podfile = try Podfile.parse(code) - - let result = podfile["ABCDEF"] - let deps = """ - //Vendor/AFNetworking:AFNetworking - //Vendor/DataCompression:DataCompression - //Vendor/Moya:Combine - //Vendor/Moya:RxSwift - //Vendor/Peek:Peek - //Vendor/SVProgressHUD:SVProgressHUD - //Vendor/TLPhotoPicker:TLPhotoPicker - //Vendor/XLPagerTabStrip:XLPagerTabStrip - """ - - #expect(result.joined(separator: "\n") == deps) - } - - // MARK: Private - - private static let sourceFile: URL = .init(fileURLWithPath: #file) - .deletingLastPathComponent() - .appendingPathComponent("Resource") - - private static func resource(_ file: String) -> String { - sourceFile.appendingPathComponent(file).path - } - - private static func strings(_ file: String) throws -> String { - try String(contentsOfFile: resource(file), encoding: .utf8) - } - - #warning("todo default spec is sub spec") -} diff --git a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift index be063ff..93e82d7 100644 --- a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift +++ b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift @@ -14,7 +14,7 @@ func parsesOnlyVersionTags() { @Test func rendersDescendingAndDeduplicatedEnumCases() throws { let file = RepoEnumFile( - source: .init(name: "XCodeProj", url: "https://github.com/MobileNativeFoundation/rules_xcodeproj"), + source: .init(name: "XcodeProj", url: "https://github.com/MobileNativeFoundation/rules_xcodeproj"), tags: [ try #require(RepoVersionTag(rawTag: "v4.0.1")), try #require(RepoVersionTag(rawTag: "4.0.0")), @@ -22,11 +22,11 @@ func rendersDescendingAndDeduplicatedEnumCases() throws { try #require(RepoVersionTag(rawTag: "3.6.0")), ]) - #expect(file.filename == "BazelDep+XCodeProj.swift") + #expect(file.filename == "BazelDep+XcodeProj.swift") #expect(file.content.contains(#"case v4_0_1 = "4.0.1""#)) #expect(file.content.contains(#"case v4_0_0 = "4.0.0""#)) #expect(file.content.contains(#"case v3_6_0 = "3.6.0""#)) - #expect(file.content.contains("static let latest: XCodeProj = .v4_0_1")) + #expect(file.content.contains("static let latest: XcodeProj = .v4_0_1")) #expect(file.content.firstRange(of: #"case v4_0_1 = "4.0.1""#)?.lowerBound ?? file.content.startIndex < file.content.firstRange(of: #"case v4_0_0 = "4.0.0""#)?.lowerBound ?? file.content.endIndex) } diff --git a/docs/Dependecy_ZH.md b/docs/Dependecy_ZH.md index db22aea..88a0278 100644 --- a/docs/Dependecy_ZH.md +++ b/docs/Dependecy_ZH.md @@ -24,10 +24,10 @@ * 套件版本(`tag`/`commit`),常見存放於 `xxx.lock` file。 * 例外: `local path` 無需版本。 * carthage: `github "SVProgressHUD/SVProgressHUD" "2.2.5"` -> `tag: "2.2.5"` - * 對應關係(`XCode Target` vs `Module`) + * 對應關係(`Xcode Target` vs `Module`) ```ruby -# XCode Target `Target1` -> Module `SVProgressHUD` +# Xcode Target `Target1` -> Module `SVProgressHUD` target 'Target1' do pod 'SVProgressHUD' end @@ -132,7 +132,7 @@ COCOAPODS: 1.11.3 --- -### 套件管理(SPM_XCode) +### 套件管理(SPM_Xcode) > `.lock` 位於 > `xxx.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` @@ -183,7 +183,7 @@ COCOAPODS: 1.11.3 --- -#### 套件管理(SPM_XCode) 條件 +#### 套件管理(SPM_Xcode) 條件 * [x] 套件來源 * [x] 套件版本 diff --git a/docs/Design.md b/docs/Design.md index 4ce8bc2..6d11dee 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -4,7 +4,7 @@ ## `Bazelize` Objectives - 1. Migrating to `bazel` with minimal impact on existing `XCode` projects. + 1. Migrating to `bazel` with minimal impact on existing `Xcode` projects. * See [Ref](#Ref) 2. Migrating `xxx.xcodeproj` and its dependencies to `bazel`, for example, `pod`, `spm`. @@ -12,13 +12,13 @@ ### Parsing `xcodeproj` -The project is parsed by [XcodeProj](https://github.com/tuist/XcodeProj) to get the `XCode Target` settings, and then filled into the corresponding `rules`. +The project is parsed by [XcodeProj](https://github.com/tuist/XcodeProj) to get the `Xcode Target` settings, and then filled into the corresponding `rules`. -(Follow-up implmentation direction: `XCode Target` -> middle layer -> generate code) +(Follow-up implmentation direction: `Xcode Target` -> middle layer -> generate code) > `Xcode Target` is treated as [Bazel Packages](https://docs.bazel.build/versions/4.2.1/build-ref.html#packages) -See [`XCode Target` setting](#XCode-Target-setting) +See [`Xcode Target` setting](#Xcode-Target-setting) --- ## Dependency Management @@ -38,22 +38,22 @@ First, let's talk about the code part. Our code will be applied to special rules We are currently focusing on `swift_library` and `objc_library` implementations. -Fortunately, `XCode Target` seems to support only one language. +Fortunately, `Xcode Target` seems to support only one language. > Except for application, we can use `bridge-header` or generated header `${target_name}-Swift.h` -### `XCode Target` type +### `Xcode Target` type -We will start with the `XCode Target` type, and then we will implement the most common types. +We will start with the `Xcode Target` type, and then we will implement the most common types. See [PBXProductType][product_type]. -#### Identifying `XCode Target` type +#### Identifying `Xcode Target` type The criterion are [PBXProductType][product_type] and [XCConfigurationList][config_list]. -### `XCode Target` + `Naming Rule` +### `Xcode Target` + `Naming Rule` `BUILD` file contains two types of rules, `xxx_library` and `main rule`. diff --git a/docs/Design_ZH.md b/docs/Design_ZH.md index fad9083..7fb4c9c 100644 --- a/docs/Design_ZH.md +++ b/docs/Design_ZH.md @@ -4,7 +4,7 @@ ## `Bazelize` 的目標 - 1. 在儘量不影響現有 `XCode` 專案的情況下,達成轉移到 `bazel` 的過程。 + 1. 在儘量不影響現有 `Xcode` 專案的情況下,達成轉移到 `bazel` 的過程。 * 見 [Ref](#Ref) 2. 將 `xxx.xcodeproj` 以及其相依套件,如 `pod`, `spm` ...,轉移至 `bazel`。 @@ -12,13 +12,13 @@ ### 解析 `xcodeproj` -我們能透過 [XcodeProj](https://github.com/tuist/XcodeProj) 去解析,得到其 `XCode Target` 設定,最後填入到對應的 `rules`。 +我們能透過 [XcodeProj](https://github.com/tuist/XcodeProj) 去解析,得到其 `Xcode Target` 設定,最後填入到對應的 `rules`。 -(後續實作方向: `XCode Target` -> 中間層 -> generate code) +(後續實作方向: `Xcode Target` -> 中間層 -> generate code) -> `XCode Target` 將視為 [Bazel Packages](https://docs.bazel.build/versions/4.2.1/build-ref.html#packages) +> `Xcode Target` 將視為 [Bazel Packages](https://docs.bazel.build/versions/4.2.1/build-ref.html#packages) -見 [`XCode Target` setting](#XCode-Target-setting) +見 [`Xcode Target` setting](#Xcode-Target-setting) --- @@ -40,23 +40,23 @@ 我們目前會著重在 `swift_library` 及 `objc_library` 的實作。 -所幸,`XCode Target` 似乎同時只支援一種語言。 +所幸,`Xcode Target` 似乎同時只支援一種語言。 > 例外: application 可透過 `bridge-header` 或 generated header `${target_name}-Swift.h`, -### `XCode Target` type +### `Xcode Target` type -我們先從 `XCode Target` type 暸解起,初步我們會先實作較為常見的幾種 type。 +我們先從 `Xcode Target` type 暸解起,初步我們會先實作較為常見的幾種 type。 見 [PBXProductType][product_type] -### 辨識 `XCode Target` type +### 辨識 `Xcode Target` type 主要由 [PBXProductType][product_type] 以及 [XCConfigurationList][config_list] 作為判斷標準。 -### `XCode Target` + `Naming Rule` +### `Xcode Target` + `Naming Rule` `BUILD` file 主要由兩種 rule 組成,`xxx_library` and `main rule`。 @@ -204,7 +204,7 @@ ios_application( --- -## `XCode Target` setting +## `Xcode Target` setting * [ ] type(application/framework/...) * [ ] setting @@ -242,7 +242,7 @@ prefix `INFOPLIST_KEY_` ## 建議事項 - * XCode Target -> 中間層實作 + * Xcode Target -> 中間層實作 * 多語言 Target * 支援 plugin diff --git a/docs/superpowers/plans/2026-04-09-xcode2-print-target.md b/docs/superpowers/plans/2026-04-09-xcode2-print-target.md index 93146d8..c294d3c 100644 --- a/docs/superpowers/plans/2026-04-09-xcode2-print-target.md +++ b/docs/superpowers/plans/2026-04-09-xcode2-print-target.md @@ -1,10 +1,10 @@ -# XCode2 Print Target Implementation Plan +# Xcode2 Print Target Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `--print-target ` option to `bazelize xcode2` that prints a human-readable summary for one target instead of the full project JSON dump. -**Architecture:** Keep JSON output as the default behavior. Move the new text rendering into a small formatter in the `XCode2` module so it can be unit tested without invoking the executable target. The CLI command will only choose between JSON mode and summary mode. +**Architecture:** Keep JSON output as the default behavior. Move the new text rendering into a small formatter in the `Xcode2` module so it can be unit tested without invoking the executable target. The CLI command will only choose between JSON mode and summary mode. **Tech Stack:** Swift, Swift Argument Parser, XCTest @@ -13,14 +13,14 @@ ### Task 1: Lock down the text output shape **Files:** -- Create: `Tests/XCode2Tests/TargetSummaryFormatterTests.swift` +- Create: `Tests/Xcode2Tests/TargetSummaryFormatterTests.swift` - Modify: `Package.swift` - [ ] **Step 1: Write the failing test** ```swift func testFormatTargetSummary() throws { - let summary = XCode.TargetSummaryFormatter.format(project: project, target: target) + let summary = Xcode.TargetSummaryFormatter.format(project: project, target: target) XCTAssertTrue(summary.contains("Target: Example")) XCTAssertTrue(summary.contains("Type: com.apple.product-type.application")) @@ -32,14 +32,14 @@ func testFormatTargetSummary() throws { - [ ] **Step 2: Run test to verify it fails** Run: `swift test --filter TargetSummaryFormatterTests/testFormatTargetSummary` -Expected: FAIL because `TargetSummaryFormatter` and the `XCode2Tests` target do not exist yet. +Expected: FAIL because `TargetSummaryFormatter` and the `Xcode2Tests` target do not exist yet. - [ ] **Step 3: Add the new test target** ```swift .testTarget( - name: "XCode2Tests", - dependencies: ["XCode2"] + name: "Xcode2Tests", + dependencies: ["Xcode2"] ), ``` @@ -58,7 +58,7 @@ Expected: FAIL because the formatter symbol is still missing. ```swift public enum TargetSummaryFormatter { - public static func format(project: XCode.Project, target: XCode.Target) -> String { + public static func format(project: Xcode.Project, target: Xcode.Target) -> String { // build readable text sections } } diff --git a/docs/superpowers/plans/2026-04-11-roadmap-command.md b/docs/superpowers/plans/2026-04-11-roadmap-command.md index ae3c5c4..ad88665 100644 --- a/docs/superpowers/plans/2026-04-11-roadmap-command.md +++ b/docs/superpowers/plans/2026-04-11-roadmap-command.md @@ -4,7 +4,7 @@ **Goal:** Add a `bazelize roadmap` command that creates the roadmap directory tree and target source symlinks for an Xcode project. -**Architecture:** The CLI command will parse `--project`, `--output`, and optional config, then load `XCode.Project` and hand off to a small tree builder. The tree builder will create root placeholders, per-target directories, and symlink target-owned filesystem entries into `Sources/` while preserving relative paths from the project root. +**Architecture:** The CLI command will parse `--project`, `--output`, and optional config, then load `Xcode.Project` and hand off to a small tree builder. The tree builder will create root placeholders, per-target directories, and symlink target-owned filesystem entries into `Sources/` while preserving relative paths from the project root. **Tech Stack:** Swift, Swift Argument Parser, PathKit, XCTest @@ -13,17 +13,17 @@ ### Task 1: Lock down the expected output tree with a failing test **Files:** -- Create: `Tests/XCode2Tests/RoadmapTreeBuilderTests.swift` +- Create: `Tests/Xcode2Tests/RoadmapTreeBuilderTests.swift` - [ ] **Step 1: Write the failing test** ```swift func testBuildCreatesTargetTreeAndSymlinks() throws { let projectPath = Path.current + "fixture/iOS2/Example.xcodeproj" - let project = try XCode.Project.load(path: projectPath, preferConfig: nil) + let project = try Xcode.Project.load(path: projectPath, preferConfig: nil) let output = Path(NSTemporaryDirectory()) + UUID().uuidString - try XCode.RoadmapTreeBuilder(output: output).build(project: project) + try Xcode.RoadmapTreeBuilder(output: output).build(project: project) XCTAssertTrue((output + "Targets/Example/Sources").exists) XCTAssertTrue((output + "Targets/Example/Generated").exists) @@ -46,11 +46,11 @@ Expected: FAIL because `RoadmapTreeBuilder` does not exist yet. - [ ] **Step 1: Add a minimal tree builder** ```swift -public extension XCode { +public extension Xcode { struct RoadmapTreeBuilder { let output: Path - public func build(project: XCode.Project) throws { + public func build(project: Xcode.Project) throws { // create root placeholders // create target directories // create symlinks @@ -109,8 +109,8 @@ Add `RoadmapCommand.self` to `subcommands`. - [ ] **Step 3: Call the builder** ```swift -let dump = try XCode.Project.load(path: path, preferConfig: config) -try XCode.RoadmapTreeBuilder(output: Path.current + output).build(project: dump) +let dump = try Xcode.Project.load(path: path, preferConfig: config) +try Xcode.RoadmapTreeBuilder(output: Path.current + output).build(project: dump) ``` - [ ] **Step 4: Re-run the focused test** diff --git a/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md b/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md index f8e43af..694dc8c 100644 --- a/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md +++ b/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md @@ -4,7 +4,7 @@ **Goal:** Make the roadmap output generate package-shaped Bazel files that move `fixture/iOS2/Example.xcodeproj` toward `bazel run //Example:Example`. -**Architecture:** Extend `RoadmapTreeBuilder` so it owns both filesystem materialization and minimal Bazel file generation. The builder will emit root files (`BUILD`, `MODULE.bazel`, `Package.swift`) and one package `BUILD` per target using lightweight string templates driven by the `XCode2` model. +**Architecture:** Extend `RoadmapTreeBuilder` so it owns both filesystem materialization and minimal Bazel file generation. The builder will emit root files (`BUILD`, `MODULE.bazel`, `Package.swift`) and one package `BUILD` per target using lightweight string templates driven by the `Xcode2` model. **Tech Stack:** Swift, PathKit, XCTest @@ -13,7 +13,7 @@ ### Task 1: Lock down package-shaped output and BUILD content with a failing test **Files:** -- Modify: `Tests/XCode2Tests/RoadmapTreeBuilderTests.swift` +- Modify: `Tests/Xcode2Tests/RoadmapTreeBuilderTests.swift` - [ ] **Step 1: Add assertions for package layout and BUILD text** @@ -92,7 +92,7 @@ Expected: PASS ### Task 4: Verify the new Bazel file output **Files:** -- Modify: `Tests/XCode2Tests/RoadmapTreeBuilderTests.swift` +- Modify: `Tests/Xcode2Tests/RoadmapTreeBuilderTests.swift` - [ ] **Step 1: Run the builder test suite** diff --git a/docs/superpowers/specs/2026-04-11-roadmap-command-design.md b/docs/superpowers/specs/2026-04-11-roadmap-command-design.md index 331cb13..5c3326e 100644 --- a/docs/superpowers/specs/2026-04-11-roadmap-command-design.md +++ b/docs/superpowers/specs/2026-04-11-roadmap-command-design.md @@ -51,7 +51,7 @@ The command follows the existing roadmap rules: ## Minimal Behavior -For each target from `XCode.Project.targets`: +For each target from `Xcode.Project.targets`: 1. create `Targets//` 2. create `Targets//Sources/` @@ -72,7 +72,7 @@ For root output: ## File Selection -The initial version should use the target file model already exposed by `XCode2`: +The initial version should use the target file model already exposed by `Xcode2`: - `target.files.sources` - `target.files.headers` @@ -95,7 +95,7 @@ The roadmap already marks missing-file behavior as deferred, so this implementat Keep the command thin and move tree generation into a small reusable builder. -- `RoadmapCommand` parses CLI arguments and loads `XCode.Project` +- `RoadmapCommand` parses CLI arguments and loads `Xcode.Project` - `RoadmapTreeBuilder` creates directories and symlinks - tests cover the builder output using the `fixture/iOS2` project diff --git a/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md b/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md index 32defc4..4922a1e 100644 --- a/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md +++ b/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md @@ -81,7 +81,7 @@ For `Static` and `Static2`: `Example` depends on Swift package products, so a placeholder `MODULE.bazel` is not enough. -Generate a minimal root `Package.swift` from `XCode.Project.packages`: +Generate a minimal root `Package.swift` from `Xcode.Project.packages`: - remotes -> `.package(url: ..., ...)` - locals -> `.package(path: ...)` From e37a1a63e8a2ffdc70b01d8b0cb4f28dcd5875d2 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 09:43:57 +0800 Subject: [PATCH 158/173] Resolve nothing when a project has no packages `swift package resolve` was run in the output directory whatever the project was, and a project with no Swift packages has no manifest written there for it to read: the run ended on `Could not find Package.swift`, after the rules were already written, so the workspace looked generated and was not. stats is such a project. --- Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift index af82596..2a62848 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift @@ -62,6 +62,16 @@ extension SwiftPM { /// rather than read back out of that manifest: the caller that wrote it knows /// them. static func loadWorkspace(output: Path, root input: Path?, locals: [Path]) async throws -> Workspace { + /// A project with no packages has no manifest written for it, and asking + /// SwiftPM to resolve one is an error rather than an empty graph. + guard (output + "Package.swift").exists else { + return .init( + packages: [], + pluginOutputs: .init(), + artifacts: output + ".build/artifacts", + directoryByIdentity: [:]) + } + try await resolve(output: output) let checkouts = output + ".build/checkouts" From 742d83d7d802e3615c550c876c8b8e1765d371ac Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 09:43:57 +0800 Subject: [PATCH 159/173] Compare real paths when a file is placed under the project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file whose path does not start with the project's is dropped to its name alone, and two spellings of one path do not compare equal: `/tmp` is a link to `/private/tmp`, and a file resolved through XcodeProj keeps the link where the root has already lost it. Every such file then globbed as `Sources//**` — matching nothing — and the package failed to load with `glob pattern didn't match anything`. Both sides are resolved with `realpath` before the comparison, which is what the SwiftPM plugin already does for the same reason. --- Sources/Xcode/Loader/Xcode+FileLoader.swift | 4 +-- Sources/Xcode/Loader/Xcode+RealPath.swift | 27 +++++++++++++++++++ Sources/Xcode/Loader/Xcode+TargetLoader.swift | 3 ++- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 Sources/Xcode/Loader/Xcode+RealPath.swift diff --git a/Sources/Xcode/Loader/Xcode+FileLoader.swift b/Sources/Xcode/Loader/Xcode+FileLoader.swift index 85541dd..337ee51 100644 --- a/Sources/Xcode/Loader/Xcode+FileLoader.swift +++ b/Sources/Xcode/Loader/Xcode+FileLoader.swift @@ -71,8 +71,8 @@ struct FileLoader { } var relativePath: String? { - let root = project.workspacePath.string - guard let fullPath else { return nil } + let root = project.workspacePath.string.realPath + guard let fullPath = fullPath?.realPath else { return nil } guard fullPath.hasPrefix(root + "/") else { return nil } return fullPath.delete(prefix: root + "/") } diff --git a/Sources/Xcode/Loader/Xcode+RealPath.swift b/Sources/Xcode/Loader/Xcode+RealPath.swift new file mode 100644 index 0000000..53d00e2 --- /dev/null +++ b/Sources/Xcode/Loader/Xcode+RealPath.swift @@ -0,0 +1,27 @@ +import Foundation +import PathKit + +extension String { + /// The same file, spelled with every symlink resolved. + /// + /// Two spellings of one path do not compare equal, and the project root and + /// the files under it do not always arrive spelled the same way: `/tmp` is a + /// link to `/private/tmp`, and a file resolved through XcodeProj can keep the + /// link where the root has already lost it. A file that then fails to look + /// like it is under the root loses its path entirely, and what it generates + /// is a glob matching nothing. + /// + /// Not `resolvingSymlinksInPath()`: that one drops a leading `/private`, + /// which is the prefix `/tmp` resolves to. + var realPath: String { + guard let resolved = realpath(self, nil) else { return self } + defer { free(resolved) } + return String(cString: resolved) + } +} + +extension Path { + var realPath: Path { + Path(string.realPath) + } +} diff --git a/Sources/Xcode/Loader/Xcode+TargetLoader.swift b/Sources/Xcode/Loader/Xcode+TargetLoader.swift index cb8d487..54f40c0 100644 --- a/Sources/Xcode/Loader/Xcode+TargetLoader.swift +++ b/Sources/Xcode/Loader/Xcode+TargetLoader.swift @@ -341,7 +341,8 @@ struct TargetLoader { return (try? root.recursiveChildren())? .filter(\.isFile) .compactMap { file in - let relative = file.string.delete(prefix: project.workspacePath.string + "/") + let relative = file.string.realPath + .delete(prefix: project.workspacePath.string.realPath + "/") guard let relative else { return nil } let pathInGroup = relative.delete(prefix: relativeRoot + "/") ?? "" From 31ff22cbe2fc79d89adb525f53989c8ef1f6c3b1 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 09:44:04 +0800 Subject: [PATCH 160/173] Build the corpus from the artifact in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus is not in this repository — `app/` and `spm/` are ignored — so CI never touched what the notes measure. Two lanes now do, both downloading the `bazelize` artifact the release build already uploads rather than building it again: one clones an app, generates its workspace and builds it with Bazel, the other clones a package whose tests only compile through a source its own build tool plugin generates. Every entry pins the revision it was measured against, so upstream moving is a deliberate bump rather than a red build nobody caused. Each lane keeps a Bazel disk cache of its own, and the matrix does not fail fast: one app failing says nothing about the others. --- .github/workflows/swift.yml | 127 ++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 5cc4c47..1a6ca67 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -130,4 +130,131 @@ jobs: name: iOS_Example.ipa path: fixture/iOS/Example.ipa if-no-files-found: ignore # 'warn' or 'ignore' + + # The corpus is not in this repository: `app/` and `spm/` are ignored, so each + # lane clones what it measures at the revision it was measured against. + IntegrateApp: + runs-on: macos-26 + needs: [artifact] + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - name: stats + repo: https://github.com/exelban/stats + rev: b4251e2ba05511de6b51b296d7b9e98a9c6545f5 + project: Stats.xcodeproj + target: //Targets/Stats + - name: MonitorControl + repo: https://github.com/MonitorControl/MonitorControl + rev: 71b8c5ae51955d17b05688371ea7b899b684bdbd + project: MonitorControl.xcodeproj + target: //Targets/MonitorControl + - name: SwiftBar + repo: https://github.com/swiftbar/SwiftBar + rev: 05cb6cb1123bc7227ee2b39f390f8bccb839fed0 + project: SwiftBar.xcodeproj + target: //Targets/SwiftBar + - name: Rectangle + repo: https://github.com/rxhanson/Rectangle + rev: b4c9c47c00b4df9cbfb3489dc5eae2f1249ad24a + project: Rectangle.xcodeproj + target: //... + - name: VirtualBuddy + repo: https://github.com/insidegui/VirtualBuddy + rev: 088351b0fc67e0b24b83e7954ad48314dda4ce04 + project: VirtualBuddy.xcodeproj + target: //Targets/VirtualBuddy + - name: iina + repo: https://github.com/iina/iina + rev: c111221ea027466b79b40bfca054772d4851e06f + project: iina.xcodeproj + target: //Targets/iina + - name: MacPass + repo: https://github.com/MacPass/MacPass + rev: 3256bc93ea94eb20b618c155e6a1b08e6fabe663 + project: MacPass.xcodeproj + target: //Targets/MacPass + submodules: true + setup: carthage bootstrap --platform macOS --cache-builds + steps: + - name: Bazel Disk Cache + uses: actions/cache@v5 + with: + path: ~/bazel-disk + key: ${{ runner.os }}-bazel-disk-${{ matrix.name }}-${{ matrix.rev }} + restore-keys: | + ${{ runner.os }}-bazel-disk-${{ matrix.name }}- + + - name: Download Bazelize + uses: actions/download-artifact@v8 + with: + name: bazelize + + - name: Clone ${{ matrix.name }} + run: | + chmod +x bazelize + git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" + git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" + + - name: Check out submodules + if: matrix.submodules + working-directory: ${{ matrix.name }} + run: git submodule update --init --recursive + + - name: Set up ${{ matrix.name }} + if: matrix.setup + working-directory: ${{ matrix.name }} + run: ${{ matrix.setup }} + + - name: Bazel Generation + run: ./bazelize --project "${{ matrix.name }}/${{ matrix.project }}" --output "${{ matrix.name }}/App" + + - name: Build Application + working-directory: ${{ matrix.name }}/App + run: bazel build --disk_cache=~/bazel-disk ${{ matrix.target }} + + IntegratePackage: + runs-on: macos-26 + needs: [artifact] + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + # A package whose tests only compile through a source its own build + # tool plugin generates. + - name: TbCodeGenerater + repo: https://github.com/yume190/TbCodeGenerater + rev: b7071d5e67189f48d5d71e06225dd1cb02470995 + target: //Packages/TbCodeGenerater/... + steps: + - name: Bazel Disk Cache + uses: actions/cache@v5 + with: + path: ~/bazel-disk + key: ${{ runner.os }}-bazel-disk-${{ matrix.name }}-${{ matrix.rev }} + restore-keys: | + ${{ runner.os }}-bazel-disk-${{ matrix.name }}- + + - name: Download Bazelize + uses: actions/download-artifact@v8 + with: + name: bazelize + + - name: Clone ${{ matrix.name }} + run: | + chmod +x bazelize + git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" + git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" + + # The package's directory name is the directory its rules live under, so + # the clone is named after the package rather than after the lane. + - name: Bazel Generation + run: ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" + + - name: Test Package + working-directory: ${{ matrix.name }}/App + run: bazel test --disk_cache=~/bazel-disk ${{ matrix.target }} From 1a894ac73eb1a00400e2bb7768d0ea8bbd15ae1f Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 10:52:29 +0800 Subject: [PATCH 161/173] Say what SwiftPM said when a plugin does not run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swift build` had its standard error discarded, so a plugin that did not run was reported as "swift build failed" and nothing else — which is every reason at once: a toolchain that cannot build the target, a checkout it could not fetch, a source that does not compile. The error lines SwiftPM printed are carried into the note instead. CI stops on that note rather than on what it causes: a target missing the sources a plugin generates fails to compile several steps later, naming those sources and never the plugin. --- .github/workflows/swift.yml | 14 ++++++++++--- .../BazelizeKit/SwiftPM/SwiftPM+Plugin.swift | 20 +++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1a6ca67..2f8b015 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -98,10 +98,14 @@ jobs: pwd chmod +x bazelize + # A plugin that did not run leaves the target missing the sources it + # generates, and the compile error that follows names those sources rather + # than the plugin. The run says so; the lane stops there. - name: Bazel Generation working-directory: fixture/iOS run: | - ../../bazelize --project Example.xcodeproj --output App + ../../bazelize --project Example.xcodeproj --output App | tee bazelize.log + ! grep -q "did not run its plugins" bazelize.log - name: Build Application working-directory: fixture/iOS/App @@ -209,7 +213,9 @@ jobs: run: ${{ matrix.setup }} - name: Bazel Generation - run: ./bazelize --project "${{ matrix.name }}/${{ matrix.project }}" --output "${{ matrix.name }}/App" + run: | + ./bazelize --project "${{ matrix.name }}/${{ matrix.project }}" --output "${{ matrix.name }}/App" | tee bazelize.log + ! grep -q "did not run its plugins" bazelize.log - name: Build Application working-directory: ${{ matrix.name }}/App @@ -252,7 +258,9 @@ jobs: # The package's directory name is the directory its rules live under, so # the clone is named after the package rather than after the lane. - name: Bazel Generation - run: ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" + run: | + ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" | tee bazelize.log + ! grep -q "did not run its plugins" bazelize.log - name: Test Package working-directory: ${{ matrix.name }}/App diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift index 126d22a..ae2640e 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift @@ -100,10 +100,13 @@ extension SwiftPM { "--target", target, ]), output: .discarded, - error: .discarded) + /// What SwiftPM said is the only thing that explains this: the + /// target failing to build is a toolchain, a network or a source + /// problem, and none of them can be guessed from an exit code. + error: .string(limit: 1024 * 1024)) if result.terminationStatus.isSuccess { return nil } - failure = "swift build failed" + failure = Self.reason(result.standardError) ?? "swift build failed" } catch { failure = error.localizedDescription } @@ -117,6 +120,19 @@ extension SwiftPM { return message } + /// What SwiftPM complained about, without the build log around it. + private static func reason(_ error: String?) -> String? { + guard let error else { return nil } + + let lines = error + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.lowercased().hasPrefix("error:") } + + let reason = lines.suffix(3).joined(separator: " ") + return reason.isEmpty ? nil : reason + } + /// `.build/plugins/outputs/////…` private static func outputsRoot(of target: String, in package: Package) -> Path { package.root + ".build/plugins/outputs" + package.identity + target From d632059273dc168bc537364e0f5eadd59f036edc Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 11:05:12 +0800 Subject: [PATCH 162/173] Keep the fixture's plugin running on the toolchain CI has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the runner's Swift 6.3 does that 6.4 does not, both of which end as "cannot find X in scope" several steps after the cause: - a build tool plugin's tool is looked up as a product, so an executable target without a product of the same name fails the whole package with "no product named". The fixture's `local1-tool` product is named `Local1Tool` now. - a package with no `platforms:` builds its macro for the oldest macOS SwiftPM supports, which is older than swift-syntax declares. The fixture states macOS 10.15. 6.3 also does not run a build tool plugin for a C-family target at all — a clean build produces the Swift target's generated sources and nothing for the clang one — so the fixture no longer calls into what the plugin generates for `LocalTarget3`. The rules still compile it wherever the toolchain produces it. iina and MacPass leave the CI matrix commented out until they are green on a runner rather than only here. --- .github/workflows/swift.yml | 27 ++++++++++--------- fixture/iOS/Local1/Package.swift | 9 ++++++- .../LocalTarget3/include/LocalTarget3.h | 2 -- .../Sources/LocalTarget3/src/LocalTarget3.m | 11 +++----- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 2f8b015..c814b58 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -170,18 +170,21 @@ jobs: rev: 088351b0fc67e0b24b83e7954ad48314dda4ce04 project: VirtualBuddy.xcodeproj target: //Targets/VirtualBuddy - - name: iina - repo: https://github.com/iina/iina - rev: c111221ea027466b79b40bfca054772d4851e06f - project: iina.xcodeproj - target: //Targets/iina - - name: MacPass - repo: https://github.com/MacPass/MacPass - rev: 3256bc93ea94eb20b618c155e6a1b08e6fabe663 - project: MacPass.xcodeproj - target: //Targets/MacPass - submodules: true - setup: carthage bootstrap --platform macOS --cache-builds + # Not yet green on a runner, so not yet a lane. iina is the app the + # notes still list an open problem for, and MacPass needs its + # submodules and a Carthage bootstrap that nothing here has proven. + # - name: iina + # repo: https://github.com/iina/iina + # rev: c111221ea027466b79b40bfca054772d4851e06f + # project: iina.xcodeproj + # target: //Targets/iina + # - name: MacPass + # repo: https://github.com/MacPass/MacPass + # rev: 3256bc93ea94eb20b618c155e6a1b08e6fabe663 + # project: MacPass.xcodeproj + # target: //Targets/MacPass + # submodules: true + # setup: carthage bootstrap --platform macOS --cache-builds steps: - name: Bazel Disk Cache uses: actions/cache@v5 diff --git a/fixture/iOS/Local1/Package.swift b/fixture/iOS/Local1/Package.swift index 37cf3c4..8607576 100644 --- a/fixture/iOS/Local1/Package.swift +++ b/fixture/iOS/Local1/Package.swift @@ -6,6 +6,13 @@ import PackageDescription let package = Package( name: "Local1", + /// The host platform the macro and the plugin's tool are built for. Without + /// it SwiftPM builds them for the oldest macOS it supports, and swift-syntax + /// declares a newer one — which is a build failure, and a build failure is a + /// plugin that never ran. + platforms: [ + .macOS(.v10_15), + ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( @@ -15,7 +22,7 @@ let package = Package( name: "LocalLib2", targets: ["LocalTarget2"]), .executable( - name: "local1-tool", + name: "Local1Tool", targets: ["Local1Tool"]), ], dependencies: [ diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h index bd6131e..220e5ae 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h +++ b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h @@ -12,8 +12,6 @@ NS_ASSUME_NONNULL_BEGIN @interface LocalTarget3 : NSObject + (int) test; - (int) test2; -/// What the package's build tool plugin generated. -- (int) generated; @end NS_ASSUME_NONNULL_END diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m index a844ef9..852f6f3 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m +++ b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m @@ -7,19 +7,14 @@ #import "LocalTarget3.h" -/// Written by the Local1Gen build tool plugin, which SwiftPM compiles into this -/// target: its header is on no search path of a hand-written source, so the -/// symbol is declared rather than included. -extern int local1_plugin_value(void); - @implementation LocalTarget3 +/// The plugin's C source is compiled into this target when the toolchain runs +/// the plugin for a C-family target, which Swift 6.3 does not and 6.4 does. +/// Nothing here calls into it, so the fixture links either way. + (int) test { return 1 << 4; } - (int) test2 { return LocalTarget3.test; } -- (int) generated { - return local1_plugin_value(); -} @end From 186546789a4062c1312e181dc905e2423748c448 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 11:29:28 +0800 Subject: [PATCH 163/173] Write down what CI does not run, and why iina waits on the dylib-as-source problem the notes already describe, MacPass on a Carthage step nothing has proven on a runner, and TbCodeGenerater on a product rename in its own repository. The three differences between the runner's Swift 6.3 and the 6.4 used here are written down beside them, because each one ends as a missing symbol rather than as anything about a plugin. --- Notes.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Notes.md b/Notes.md index 8aa9612..7f53ba3 100644 --- a/Notes.md +++ b/Notes.md @@ -16,3 +16,20 @@ plist 的 `$(xxx)`:專案自己宣告的(pbxproj/xcconfig)與 Xcode 從 (`SDK_VERSION`、`XCODE_VERSION_*`、`SDK_NAME`、`PLATFORM_NAME`、`CONFIGURATION`) 都在 Swift 層取代掉了,剩下 `plisttool` 自己認的那幾個原樣交給 rules_apple。 只存在於 CI 環境或 secret 的設定仍然解不出來——那種 key 會被丟掉並具名回報。 + +CI(`.github/workflows/swift.yml`)目前沒跑的: + +- **iina**:matrix 裡註解掉,等上面那條 dylib 的問題解掉、在 runner 上綠過再打開。 +- **MacPass**:需要 submodule 加 `carthage bootstrap`,那一步沒在 runner 上驗證過。 +- **TbCodeGenerater**:plugin 的 tool 在 Swift 6.3 是用 product 名去查的,那個 package + 的 product 叫 `tbCodeGenerater` 而 target 叫 `TbCodeGenerater`,所以整個 package + 在 runner 上建不起來(`no product named 'TbCodeGenerater'`)。改名之後要更新 CI 裡 + 釘的 revision。 + +runner 是 Xcode 26(Swift 6.3),本機是 27(6.4),兩者對 build tool plugin 的差別: + +- 6.3 用 product 名查 plugin 的 tool,6.4 接受 target 名。 +- 6.3 不為 C 系 target 跑 build tool plugin,而且**回報成功**——沒有產物也沒有錯誤。 + 6.4 會跑。fixture 的 ObjC target 因此不呼叫 plugin 產生的符號。 +- 沒宣告 `platforms:` 的 package,6.3 用它支援的最舊 macOS 去建 macro,會和 + swift-syntax 宣告的版本打架。 From 2c58518d25e45316f0b17de369f0f33f5e6a2893 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 12:59:34 +0800 Subject: [PATCH 164/173] Run a package's build tool plugins as their host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking SwiftPM to run them meant building the target they are attached to, which fails for reasons that have nothing to do with the plugin — and on Swift 6.3 silently does not run a plugin for a C-family target at all, reporting success and producing nothing. bazelize is the host now: it compiles the plugin against the toolchain's `PackagePlugin`, hands it the package graph and the directory its output belongs in, and runs the commands it asks for. The protocol is SwiftPM's: a length-prefixed JSON message each way over the plugin's standard input and output. Only what a build tool plugin uses is mirrored, and a message that cannot be decoded is reported as the toolchain speaking a protocol this does not, rather than read as an absent command. What this buys, measured on both toolchains: the fixture's C-family target gets its generated sources on Swift 6.3, which `swift build` never produced there. The files are written where they belong rather than linked out of `.build`, so the generated workspace no longer needs the package's build directory to exist. The tool a plugin runs is still built by SwiftPM — it is an executable target with ordinary dependencies — but only that one product, looked up in the manifest rather than guessed from the target's name. --- Sources/BazelizeKit/Kit.swift | 2 +- .../SwiftPM/SwiftPM+Generator.swift | 31 +- .../SwiftPM/SwiftPM+Manifest.swift | 4 + .../BazelizeKit/SwiftPM/SwiftPM+Plugin.swift | 131 +----- .../SwiftPM/SwiftPM+PluginContext.swift | 189 +++++++++ .../SwiftPM/SwiftPM+PluginHost.swift | 222 +++++++++++ .../SwiftPM/SwiftPM+PluginProcess.swift | 211 ++++++++++ .../SwiftPM/SwiftPM+PluginWire.swift | 373 ++++++++++++++++++ .../SwiftPM/SwiftPM+Workspace.swift | 5 - .../LocalTarget3/include/LocalTarget3.h | 2 + .../Sources/LocalTarget3/src/LocalTarget3.m | 12 +- 11 files changed, 1042 insertions(+), 140 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+PluginContext.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+PluginWire.swift diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index d9837f2..f630e81 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -118,7 +118,7 @@ extension Kit { output: outputRoot, workspace: workspace, deployment: deployment) - try generator.generate() + try await generator.generate() packageTips = generator.notes let count = workspace.packages.count diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index ba49c3d..204eb77 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -24,6 +24,10 @@ extension SwiftPM { let deployment: Deployment + /// What this project's own packages' build tool plugins wrote, which the + /// generator runs itself before it writes any rule. + private(set) var pluginOutputs = PluginOutputs() + private var kinds: [String: [String: TargetKind]] = [:] /// What a caller tells the user about: where the build differs from what @@ -36,8 +40,9 @@ extension SwiftPM { self.deployment = deployment } - func generate() throws { - notes.append(contentsOf: workspace.pluginOutputs.notes) + func generate() async throws { + pluginOutputs = await runPlugins() + notes.append(contentsOf: pluginOutputs.notes) for package in workspace.packages { kinds[package.directory] = try supportedTargets(of: package) @@ -267,22 +272,24 @@ extension SwiftPM { static let none = PluginGenerated(sources: [], headers: [], resources: []) } + /// What a plugin wrote for a target, split the way the target's own rule + /// takes it. + /// + /// The files are already where they belong: the host gave the plugin + /// this directory to write into, so nothing is moved or linked here — + /// they are real files of the package's `Generated/`, and the output + /// stands without the package's `.build`. func materialize( pluginOutputsOf target: PackageTarget, in package: Package, at root: Path, kind: TargetKind) throws -> PluginGenerated { - guard let output = workspace.pluginOutputs.output(of: target.name, in: package) else { + guard let output = pluginOutputs.output(of: target.name, in: package) else { return .none } let directory = "Generated/\(target.name)Plugin" - let generated = root + directory - if generated.exists || generated.isSymlink { - try? generated.delete() - } - try generated.mkpath() /// What the target's own rule compiles; a Swift target compiles Swift, /// and a C-family one whatever clang takes. @@ -297,19 +304,11 @@ extension SwiftPM { let base = output.root.normalize().string for file in output.files { - /// Where the file sits under the directory the plugins wrote into, - /// kept as it is: a plugin of the target has a directory of its - /// own there and writes a tree inside it if it likes, and renaming - /// that into one flat directory is a rename nothing asked for. let relative = file.normalize().string .delete(prefix: base) .trimmingCharacters(in: ["/"]) guard !relative.isEmpty else { continue } - let link = generated + relative - try link.parent().mkpath() - try link.symlink(file) - let path = "\(directory)/\(relative)" let `extension` = file.extension ?? "" if compiled.contains(`extension`) { diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift index 08e3aa4..10e80c9 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift @@ -26,6 +26,9 @@ extension SwiftPM { let dependencies: [Dependency] let cLanguageStandard: String? let cxxLanguageStandard: String? + /// `{"_version": "6.0.0"}`: which `PackageDescription` the manifest was + /// written against, which a plugin has to be compiled against too. + let toolsVersion: String init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: AnyKey.self) @@ -36,6 +39,7 @@ extension SwiftPM { dependencies = container.list(Dependency.self, "dependencies") cLanguageStandard = container.value(String.self, "cLanguageStandard") cxxLanguageStandard = container.value(String.self, "cxxLanguageStandard") + toolsVersion = container.value([String: String].self, "toolsVersion")?["_version"] ?? "5.9.0" } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift index ae2640e..c6ae8dd 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift @@ -7,35 +7,34 @@ import Foundation @preconcurrency import PathKit -import Subprocess -import Util extension SwiftPM { /// What a build tool plugin produced, by target. /// - /// A plugin is a program the compiler host asks for build commands over a - /// private protocol, so bazelize does not run it: SwiftPM does, and leaves the - /// result under `.build/plugins/outputs`. Those files are taken as they are, - /// the way every other thing SwiftPM already produced is. + /// bazelize is the plugin's host: it compiles the plugin, hands it the + /// package graph and a directory to write into, and runs the commands the + /// plugin asks for. Where the files go is the host's decision — the + /// package's own `Generated/Plugin` — and what they are called is + /// the plugin's. /// - /// The consequence is the one every generated file here has: they change when - /// bazelize runs again, not when the input changes. That is also why only a - /// package in the project's own repository is built — running a plugin means - /// building its package with SwiftPM, and doing that for every dependency that - /// merely lints would make generating a workspace cost a full SwiftPM build. + /// The consequence is the one every generated file here has: they change + /// when bazelize runs again, not when the input changes. Only a package in + /// the project's own repository is run, because a plugin's tool still has to + /// be built, and building one for every dependency that merely lints would + /// make generating a workspace cost a full build. struct PluginOutputs: Sendable { - /// What one target's plugins wrote, and the directory they wrote it into: - /// two plugins of the same target write into one directory each, and a - /// `prebuildCommand` writes a tree, so a file is only named by where it + /// What one target's plugins wrote, and the directory they wrote it + /// into: two plugins of the same target write into a directory each, and + /// a prebuild command writes a tree, so a file is only named by where it /// sits under that root. struct Output: Sendable { let root: Path let files: [Path] } - /// What kept a plugin from producing what a target expects, for the run to - /// say out loud: the compile error a missing generated file causes names - /// the file, never the plugin. + /// What kept a plugin from producing what a target expects, for the run + /// to say out loud: the compile error a missing generated file causes + /// names the file, never the plugin. let notes: [String] /// Keyed `/`. @@ -50,102 +49,4 @@ extension SwiftPM { outputs["\(package.directory)/\(target)"] } } - - /// Runs the plugins of the packages this project owns, and collects what they - /// wrote. - static func runPlugins(of packages: [Package]) async -> PluginOutputs { - var outputs: [String: PluginOutputs.Output] = [:] - var notes: [String] = [] - - for package in packages where package.isRoot || package.isLocal { - let targets = package.manifest.targets - .filter { !$0.pluginUsages.isEmpty } - .map(\.name) - guard !targets.isEmpty else { continue } - - for target in targets { - /// What a previous run left there is not what the plugins produce - /// now: SwiftPM names the files it declared and leaves the rest, - /// while everything found here is taken as the target's own. - try? outputsRoot(of: target, in: package).delete() - - if let failure = await build(target: target, of: package) { - notes.append(failure) - continue - } - - let produced = self.outputs(of: target, in: package) - guard !produced.files.isEmpty else { continue } - outputs["\(package.directory)/\(target)"] = produced - } - } - - return .init(outputs: outputs, notes: notes) - } - - // MARK: Private - - /// Building the target is what makes SwiftPM run its plugins; there is no - /// command that only runs them. - /// - /// Returns why the plugins did not run, or `nil` when they did. - private static func build(target: String, of package: Package) async -> String? { - let failure: String - do { - let result = try await Subprocess.run( - .name("swift"), - arguments: Arguments([ - "build", - "--package-path", package.root.string, - "--target", target, - ]), - output: .discarded, - /// What SwiftPM said is the only thing that explains this: the - /// target failing to build is a toolchain, a network or a source - /// problem, and none of them can be guessed from an exit code. - error: .string(limit: 1024 * 1024)) - - if result.terminationStatus.isSuccess { return nil } - failure = Self.reason(result.standardError) ?? "swift build failed" - } catch { - failure = error.localizedDescription - } - - let message = """ - \(package.directory)/\(target) did not run its plugins: \(failure). \ - The target is built with SwiftPM to run them, so whatever they generate \ - is missing from it. - """ - Log.codeGenerate.warning("\(message, privacy: .public)") - return message - } - - /// What SwiftPM complained about, without the build log around it. - private static func reason(_ error: String?) -> String? { - guard let error else { return nil } - - let lines = error - .split(separator: "\n", omittingEmptySubsequences: true) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { $0.lowercased().hasPrefix("error:") } - - let reason = lines.suffix(3).joined(separator: " ") - return reason.isEmpty ? nil : reason - } - - /// `.build/plugins/outputs/////…` - private static func outputsRoot(of target: String, in package: Package) -> Path { - package.root + ".build/plugins/outputs" + package.identity + target - } - - /// Every file a target's plugins wrote, not only the Swift ones: SwiftPM - /// splits what a plugin produced into the target's sources and its resources, - /// and a `prebuildCommand` writes a whole directory whose contents it never - /// names. - private static func outputs(of target: String, in package: Package) -> PluginOutputs.Output { - let root = outputsRoot(of: target, in: package) - guard root.isDirectory else { return .init(root: root, files: []) } - - return .init(root: root, files: Generator.walk(root).sorted()) - } } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginContext.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginContext.swift new file mode 100644 index 0000000..bb14bf3 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginContext.swift @@ -0,0 +1,189 @@ +// +// SwiftPM+PluginContext.swift +// +// +// The package graph a plugin is handed. +// + +import Foundation +@preconcurrency import PathKit + +extension SwiftPM { + /// Builds what a plugin is told about the package it is running for. + /// + /// A plugin reads the target it was asked about — its sources, its name, the + /// directory it lives in — and the package around it. That is what this + /// assembles, in the shape SwiftPM's protocol spells: every path interned so + /// a graph of files repeats no directory. + struct PluginContextBuilder { + // MARK: Lifecycle + + init(package: Package, generator: Generator) { + self.package = package + self.generator = generator + } + + // MARK: Internal + + /// Adds the package and every target in it, and answers which id the + /// target being asked about has. + mutating func add(package: Package, asking target: PackageTarget) throws -> Int { + let directoryId = add(path: package.root.absolute().string) + + var targetIds: [Int] = [] + for (index, candidate) in package.manifest.targets.enumerated() { + indexByTarget[candidate.name] = index + targetIds.append(index) + } + + targets = try package.manifest.targets.map { candidate in + try wire(candidate, in: package, sources: candidate.name == target.name) + } + + products = package.manifest.products.map { product in + let ids = product.targets.compactMap { indexByTarget[$0] } + return .init( + name: product.name, + targetIds: ids, + info: product.kind == .executable + ? .executable(mainTargetId: ids.first ?? 0) + : .library) + } + + packages = [ + .init( + identity: package.identity, + displayName: package.manifest.name, + directoryId: directoryId, + origin: package.isRoot ? .root : .local(pathId: directoryId), + toolsVersion: Self.version(package.manifest.toolsVersion), + dependencies: [], + productIds: Array(products.indices), + targetIds: targetIds), + ] + + guard let id = indexByTarget[target.name] else { + throw PluginError.undecodable("the target asked about is not in its own package") + } + + return id + } + + /// Interns a path, answering the id the wire refers to it by. + mutating func add(path: String) -> Int { + if let id = idByPath[path] { return id } + + let id = paths.count + paths.append(.init(baseURLId: nil, subpath: path)) + idByPath[path] = id + return id + } + + func context(workDirectoryId: Int, tools: [String: PluginWire.Tool]) -> PluginWire.InputContext { + .init( + paths: paths, + targets: targets, + products: products, + packages: packages, + xcodeTargets: [], + xcodeProjects: [], + pluginWorkDirId: workDirectoryId, + toolSearchDirIds: [], + accessibleTools: tools) + } + + // MARK: Private + + private let package: Package + private let generator: Generator + + private var paths: [PluginWire.URLNode] = [] + private var idByPath: [String: Int] = [:] + private var indexByTarget: [String: Int] = [:] + private var targets: [PluginWire.Target] = [] + private var products: [PluginWire.Product] = [] + private var packages: [PluginWire.Package] = [] + + /// `6.0.0` as the three numbers the wire wants. + private static func version(_ value: String) -> PluginWire.Package.ToolsVersion { + let parts = value.split(separator: ".").compactMap { Int($0) } + return .init( + major: parts.count > 0 ? parts[0] : 5, + minor: parts.count > 1 ? parts[1] : 9, + patch: parts.count > 2 ? parts[2] : 0) + } + + /// One target, with its files listed only for the target being asked + /// about: a plugin reads those, and walking every target of a package to + /// tell it about files it never looks at is work for nothing. + private mutating func wire( + _ target: PackageTarget, + in package: Package, + sources listed: Bool) throws -> PluginWire.Target + { + let directory = generator.sourceDirectory(of: target, in: package) ?? package.root + let directoryId = add(path: directory.absolute().string) + + let dependencies: [PluginWire.Target.Dependency] = target.dependencies.compactMap { dependency in + switch dependency.kind { + case .target(let name), .byName(let name): + return indexByTarget[name].map { .target($0) } + case .product: + return nil + } + } + + let files: [PluginWire.File] = listed + ? Generator.walk(directory).map { file in + .init( + basePathId: directoryId, + name: file.absolute().string.delete(prefix: directory.absolute().string + "/") ?? file + .lastComponent, + type: Self.type(of: file)) + } + : [] + + return .init( + name: target.name, + directoryId: directoryId, + dependencies: dependencies, + info: Self.info(of: target, module: Generator.moduleName(target.name), sources: files)) + } + + private static func info( + of target: PackageTarget, + module: String, + sources: [PluginWire.File]) -> PluginWire.TargetInfo + { + switch target.type { + case "binary": + return .binary(artifactId: 0) + case "system": + return .system + default: + return .swift(module: module, kind: Self.kind(of: target), sources: sources) + } + } + + /// What SwiftPM calls the kind of a source module. + private static func kind(of target: PackageTarget) -> String { + switch target.type { + case "executable", "snippet": + return "executable" + case "test": + return "test" + case "macro": + return "macro" + default: + return "generic" + } + } + + private static func type(of file: Path) -> String { + let `extension` = file.extension ?? "" + if Generator.headerExtensions.contains(`extension`) { return "header" } + if `extension` == "swift" || Generator.compileExtensions.contains(`extension`) { return "source" } + return "resource" + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift new file mode 100644 index 0000000..6214e2e --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift @@ -0,0 +1,222 @@ +// +// SwiftPM+PluginHost.swift +// +// +// Running a package's build tool plugins. +// + +import Foundation +@preconcurrency import PathKit +import Subprocess +import System +import Util + +extension SwiftPM.Generator { + /// Runs the build tool plugins of the packages this project owns, into the + /// directory their output belongs in. + /// + /// bazelize is the plugin host here: it compiles the plugin, hands it the + /// package graph and a directory to write into, and runs the commands it + /// asks for. What the plugin writes and what it calls those files is the + /// plugin's business — the host supplies the place, and is told afterwards + /// what landed there. + /// + /// Asking SwiftPM instead means building the whole target the plugin is + /// attached to, which fails for reasons that have nothing to do with the + /// plugin, and which on some toolchains silently does not run the plugin at + /// all for a C-family target. + func runPlugins() async -> SwiftPM.PluginOutputs { + var outputs: [String: SwiftPM.PluginOutputs.Output] = [:] + var notes: [String] = [] + + for package in workspace.packages where package.isRoot || package.isLocal { + for target in package.manifest.targets where !target.pluginUsages.isEmpty { + let directory = pluginWorkDirectory(of: target, in: package) + try? directory.delete() + + var produced = false + for usage in target.pluginUsages { + do { + produced = try await run( + plugin: usage, + on: target, + in: package, + at: directory) || produced + } catch { + notes.append(note(usage, target, package, "\(error)")) + } + } + + guard produced, directory.isDirectory else { continue } + outputs["\(package.directory)/\(target.name)"] = .init( + root: directory, + files: Self.walk(directory).sorted()) + } + } + + return .init(outputs: outputs, notes: notes) + } + + /// Where a target's plugins write: beside the rules of the package that + /// declares it, which is where every other generated file of that package + /// already is. + func pluginWorkDirectory(of target: SwiftPM.PackageTarget, in package: SwiftPM.Package) -> Path { + output + PluginSwiftPM.packagesDirectory + package.directory + "Generated/\(target.name)Plugin" + } + + // MARK: Private + + private func note( + _ usage: SwiftPM.PluginUsage, + _ target: SwiftPM.PackageTarget, + _ package: SwiftPM.Package, + _ reason: String) -> String + { + let message = """ + \(package.directory)/\(target.name) did not run the \(usage.name) plugin: \(reason). \ + Whatever that plugin generates is missing from the target. + """ + Log.codeGenerate.warning("\(message, privacy: .public)") + return message + } + + /// Asks one plugin what to run, and runs it. `true` when it asked for + /// anything at all. + private func run( + plugin usage: SwiftPM.PluginUsage, + on target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + at directory: Path) async throws -> Bool + { + guard let (pluginTarget, pluginPackage) = self.plugin(usage, from: package) else { + throw SwiftPM.PluginError.undecodable("no target named \(usage.name) declares it") + } + + let executable = try await compile(plugin: pluginTarget, in: pluginPackage) + try directory.mkpath() + + let context = try await self.context( + for: target, + in: package, + plugin: pluginTarget, + pluginPackage: pluginPackage, + workDirectory: directory) + + let commands = try await SwiftPM.PluginHost.ask(executable: executable, request: context) + guard !commands.isEmpty else { return false } + + for command in commands { + try await SwiftPM.PluginHost.run(command) + } + + return true + } + + /// The target that implements a plugin, and the package it belongs to: a + /// usage names the plugin, and optionally the package it comes from. + private func plugin( + _ usage: SwiftPM.PluginUsage, + from package: SwiftPM.Package) -> (SwiftPM.PackageTarget, SwiftPM.Package)? + { + let packages: [SwiftPM.Package] + if let name = usage.package { + let directory = workspace.directoryByIdentity[name.lowercased()] + packages = workspace.packages.filter { $0.directory == directory } + } else { + packages = [package] + workspace.packages.filter { $0.directory != package.directory } + } + + for candidate in packages { + if let target = candidate.manifest.targets.first(where: { + $0.name == usage.name && $0.type == "plugin" + }) { + return (target, candidate) + } + } + + return nil + } + + /// Compiles a plugin into a program the host can talk to. + /// + /// A plugin target depends on nothing but the toolchain's `PackagePlugin`, + /// so compiling it needs no package graph — which is the whole reason the + /// host can run one without building anything else. + private func compile(plugin target: SwiftPM.PackageTarget, in package: SwiftPM.Package) async throws -> Path { + let built = output + ".bazelize/plugins" + package.directory + target.name + if built.exists { return built } + + guard let directory = sourceDirectory(of: target, in: package) else { + throw SwiftPM.PluginError.compileFailed("no source directory") + } + + let sources = Self.walk(directory).filter { $0.extension == "swift" }.map(\.string) + guard !sources.isEmpty else { + throw SwiftPM.PluginError.compileFailed("no sources") + } + + try built.parent().mkpath() + try await SwiftPM.PluginHost.compile( + sources: sources, + module: target.name, + toolsVersion: package.manifest.toolsVersion, + to: built) + + return built + } + + /// The graph the plugin is given, and the tools it may run. + private func context( + for target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + plugin: SwiftPM.PackageTarget, + pluginPackage: SwiftPM.Package, + workDirectory: Path) async throws -> SwiftPM.PluginWire.Request + { + var builder = SwiftPM.PluginContextBuilder(package: package, generator: self) + let targetId = try builder.add(package: package, asking: target) + let workDirId = builder.add(path: workDirectory.absolute().string) + + var tools: [String: SwiftPM.PluginWire.Tool] = [:] + for dependency in plugin.dependencies { + guard case .target(let name) = dependency.kind else { + guard case .byName(let name) = dependency.kind else { continue } + if let tool = try await self.tool(named: name, in: pluginPackage) { + tools[name] = .init(path: builder.add(path: tool.string), triples: nil) + } + continue + } + + if let tool = try await self.tool(named: name, in: pluginPackage) { + tools[name] = .init(path: builder.add(path: tool.string), triples: nil) + } + } + + return .init( + context: builder.context(workDirectoryId: workDirId, tools: tools), + rootPackageId: 0, + targetId: targetId, + pluginGeneratedSources: [], + pluginGeneratedResources: []) + } + + /// The program a plugin runs, built by SwiftPM because it is an ordinary + /// executable target with ordinary dependencies. + /// + /// Only this one product is built, rather than the target the plugin is + /// attached to: which product holds the tool is read from the manifest here + /// instead of guessed from the target's name, which is what some toolchains + /// get wrong. + private func tool(named name: String, in package: SwiftPM.Package) async throws -> Path? { + guard package.manifest.targets.contains(where: { $0.name == name && $0.type == "executable" }) + else { + return nil + } + + let product = package.manifest.products.first { product in + product.targets.contains(name) + }?.name ?? name + + return try await SwiftPM.PluginHost.build(product: product, of: package.root) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift new file mode 100644 index 0000000..c002057 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift @@ -0,0 +1,211 @@ +// +// SwiftPM+PluginProcess.swift +// +// +// Compiling a plugin, talking to it, and running what it asks for. +// + +import Foundation +@preconcurrency import PathKit +import Subprocess +import System +import Util + +extension SwiftPM { + /// The three things a plugin host does with processes. + enum PluginHost { + /// Compiles a plugin target into a program. + /// + /// The only thing it links is the toolchain's `PackagePlugin`, which is + /// what makes running a plugin independent of building anything else. + static func compile( + sources: [String], + module: String, + toolsVersion: String, + to executable: Path) async throws + { + guard let api = try await pluginAPI() else { throw PluginError.noToolchain } + + let result = try await Subprocess.run( + .name("swiftc"), + arguments: Arguments([ + "-I", api.string, + "-L", api.string, + "-lPackagePlugin", + "-Xlinker", "-rpath", "-Xlinker", api.string, + /// Which `PackagePlugin` API the plugin was written against; + /// its availability is stated in terms of it. + "-package-description-version", toolsVersion, + "-parse-as-library", + "-module-name", module, + "-o", executable.string, + ] + sources), + output: .discarded, + error: .string(limit: 1024 * 1024)) + + guard result.terminationStatus.isSuccess else { + throw PluginError.compileFailed(Self.errors(result.standardError)) + } + } + + /// Asks a plugin what to run, and collects what it answers. + /// + /// The protocol is a length-prefixed JSON message each way over the + /// plugin's standard input and output; its own printing goes to standard + /// error, which is forwarded as diagnostics. + static func ask(executable: Path, request: PluginWire.Request) async throws -> [PluginWire.Command] { + let payload = try JSONEncoder().encode(request) + + var input = Data() + withUnsafeBytes(of: UInt64(payload.count).littleEndian) { input.append(contentsOf: $0) } + input.append(payload) + + let result = try await Subprocess.run( + .path(FilePath(executable.string)), + input: .data(input), + output: .data(limit: 64 * 1024 * 1024), + error: .string(limit: 1024 * 1024)) + + var commands: [PluginWire.Command] = [] + for response in try messages(in: Data(result.standardOutput)) { + switch response { + case .build(let command): + commands.append(command) + case .prebuild(let command, let directory): + try Path(URL(string: directory)?.path ?? directory).mkpath() + commands.append(command) + case .diagnostic(let severity, let message): + Log.codeGenerate.warning("plugin \(severity, privacy: .public): \(message, privacy: .public)") + case .progress, .unsupported: + continue + } + } + + guard result.terminationStatus.isSuccess || !commands.isEmpty else { + throw PluginError.compileFailed(Self.errors(result.standardError)) + } + + return commands + } + + /// Runs one command a plugin asked for. + static func run(_ command: PluginWire.Command) async throws { + let executable = Self.path(command.executable) + let overrides = command.environment.reduce(into: [Subprocess.Environment.Key: String?]()) { all, entry in + guard let key = Subprocess.Environment.Key(rawValue: entry.key) else { return } + all[key] = entry.value + } + + let result = try await Subprocess.run( + .path(FilePath(executable)), + arguments: Arguments(command.arguments.map(Self.path)), + environment: .inherit.updating(overrides), + workingDirectory: command.workingDirectory.map { FilePath(Self.path($0)) }, + output: .discarded, + error: .string(limit: 1024 * 1024)) + + guard result.terminationStatus.isSuccess else { + throw PluginError.compileFailed( + "\(command.displayName ?? Path(executable).lastComponent): \(Self.errors(result.standardError))") + } + } + + /// Builds the product that holds a plugin's tool. + static func build(product: String, of package: Path) async throws -> Path? { + let build = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["build", "--package-path", package.string, "--product", product]), + output: .discarded, + error: .string(limit: 1024 * 1024)) + + guard build.terminationStatus.isSuccess else { + throw PluginError.compileFailed("the plugin's tool does not build: \(Self.errors(build.standardError))") + } + + let directory = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["build", "--package-path", package.string, "--show-bin-path"]), + output: .string(limit: 64 * 1024), + error: .discarded) + + guard + let path = Optional(directory.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines)), + !path.isEmpty + else { + return nil + } + + let tool = Path(path) + product + return tool.exists ? tool : nil + } + + // MARK: Private + + /// A plugin speaks in file URLs; a command line takes paths. + private static func path(_ value: String) -> String { + guard value.hasPrefix("file://") else { return value } + return URL(string: value)?.path ?? value + } + + private static func messages(in data: Data) throws -> [PluginWire.Response] { + var responses: [PluginWire.Response] = [] + var offset = data.startIndex + + while offset + 8 <= data.endIndex { + let header = data[offset ..< offset + 8] + let count = Int(header.reduce(UInt64(0)) { total, byte in + (total >> 8) | (UInt64(byte) << 56) + }.littleEndian) + + let start = offset + 8 + guard count > 0, start + count <= data.endIndex else { + throw PluginError.undecodable("a message claims \(count) bytes and the stream has fewer") + } + + let payload = data[start ..< start + count] + do { + responses.append(try JSONDecoder().decode(PluginWire.Response.self, from: payload)) + } catch { + throw PluginError.undecodable("\(error)") + } + + offset = start + count + } + + return responses + } + + /// Where the toolchain keeps the module a plugin is compiled against. + private static func pluginAPI() async throws -> Path? { + let result = try await Subprocess.run( + .name("xcrun"), + arguments: Arguments(["--find", "swiftc"]), + output: .string(limit: 4096), + error: .discarded) + + guard + result.terminationStatus.isSuccess, + let found = Optional(result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines)), + !found.isEmpty + else { + return nil + } + + /// `/usr/bin/swiftc` → `/usr/lib/swift/pm/PluginAPI` + let api = Path(found).parent().parent() + "lib/swift/pm/PluginAPI" + return api.isDirectory ? api : nil + } + + private static func errors(_ output: String?) -> String { + guard let output else { return "no output" } + + let lines = output + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.lowercased().hasPrefix("error:") } + + let reason = lines.suffix(3).joined(separator: " ") + return reason.isEmpty ? output.suffix(400).trimmingCharacters(in: .whitespacesAndNewlines) : reason + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginWire.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginWire.swift new file mode 100644 index 0000000..d43264c --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginWire.swift @@ -0,0 +1,373 @@ +// +// SwiftPM+PluginWire.swift +// +// +// The messages a build tool plugin and its host exchange. +// + +import Foundation + +// MARK: - SwiftPM.PluginWire + +extension SwiftPM { + /// What the host sends a plugin and what it says back. + /// + /// A plugin is a program that speaks one protocol: length-prefixed JSON over + /// its standard input and output, carrying enums SwiftPM declares in + /// `PluginMessages.swift`. Only the part a build tool plugin uses is + /// mirrored here — the package graph it is given, and the commands it + /// answers with. + /// + /// The protocol belongs to the toolchain, so a message that cannot be + /// decoded is reported as exactly that rather than read as an absent + /// command. + enum PluginWire { + /// A path, as the wire spells it: a subpath of another path, so a graph + /// of files repeats no directory. + struct URLNode: Encodable { + let baseURLId: Int? + let subpath: String + } + + struct Tool: Encodable { + let path: Int + let triples: [String]? + } + + struct File: Encodable { + let basePathId: Int + let name: String + let type: String + } + + struct Target: Encodable { + let name: String + let directoryId: Int + let dependencies: [Dependency] + let info: TargetInfo + + enum Dependency: Encodable { + case target(Int) + case product(Int) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .target(let id): + try container.encode(["targetId": id], forKey: AnyKey("target")) + case .product(let id): + try container.encode(["productId": id], forKey: AnyKey("product")) + } + } + } + } + + /// What the plugin is told a target is made of. + /// + /// Only the kinds a package can hold are spelled out; the shape of each + /// is SwiftPM's, down to the key names. + enum TargetInfo: Encodable { + case swift(module: String, kind: String, sources: [File]) + case clang(module: String, kind: String, sources: [File], publicHeadersDirId: Int?) + case binary(artifactId: Int) + case system + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .swift(let module, let kind, let sources): + try container.encode( + SwiftInfo( + moduleName: module, + kind: kind, + sourceFiles: sources, + compilationConditions: [], + linkedLibraries: [], + linkedFrameworks: []), + forKey: AnyKey("swiftSourceModuleInfo")) + case .clang(let module, let kind, let sources, let headers): + try container.encode( + ClangInfo( + moduleName: module, + kind: kind, + sourceFiles: sources, + preprocessorDefinitions: [], + headerSearchPaths: [], + publicHeadersDirId: headers, + linkedLibraries: [], + linkedFrameworks: []), + forKey: AnyKey("clangSourceModuleInfo")) + case .binary(let artifact): + try container.encode( + BinaryInfo( + kind: ["xcframework": Empty()], + origin: ["local": Empty()], + artifactId: artifact), + forKey: AnyKey("binaryArtifactInfo")) + case .system: + try container.encode( + SystemInfo(pkgConfig: nil, compilerFlags: [], linkerFlags: []), + forKey: AnyKey("systemLibraryInfo")) + } + } + + private struct SwiftInfo: Encodable { + let moduleName: String + let kind: String + let sourceFiles: [File] + let compilationConditions: [String] + let linkedLibraries: [String] + let linkedFrameworks: [String] + } + + private struct ClangInfo: Encodable { + let moduleName: String + let kind: String + let sourceFiles: [File] + let preprocessorDefinitions: [String] + let headerSearchPaths: [String] + let publicHeadersDirId: Int? + let linkedLibraries: [String] + let linkedFrameworks: [String] + } + + private struct BinaryInfo: Encodable { + let kind: [String: Empty] + let origin: [String: Empty] + let artifactId: Int + } + + private struct SystemInfo: Encodable { + let pkgConfig: String? + let compilerFlags: [String] + let linkerFlags: [String] + } + + private struct Empty: Encodable {} + } + + struct Product: Encodable { + let name: String + let targetIds: [Int] + let info: Info + + enum Info: Encodable { + case executable(mainTargetId: Int) + case library + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .executable(let main): + try container.encode(["mainTargetId": main], forKey: AnyKey("executable")) + case .library: + try container.encode( + ["kind": ["automatic": [String: String]()]], + forKey: AnyKey("library")) + } + } + } + } + + struct Package: Encodable { + let identity: String + let displayName: String + let directoryId: Int + let origin: Origin + let toolsVersion: ToolsVersion + let dependencies: [Dependency] + let productIds: [Int] + let targetIds: [Int] + + struct ToolsVersion: Encodable { + let major: Int + let minor: Int + let patch: Int + } + + struct Dependency: Encodable { + let packageId: Int + } + + /// Where the package came from. A plugin can ask, and one that does + /// is told the truth: the package under the tool is the root, one in + /// the project's own repository is local, the rest are checkouts. + enum Origin: Encodable { + case root + case local(pathId: Int) + case repository(url: String, displayVersion: String, revision: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .root: + try container.encode([String: String](), forKey: AnyKey("root")) + case .local(let path): + try container.encode(["path": path], forKey: AnyKey("local")) + case .repository(let url, let version, let revision): + try container.encode( + [ + "url": url, + "displayVersion": version, + "scmRevision": revision, + ], + forKey: AnyKey("repository")) + } + } + } + } + + /// The whole graph, as one message's worth of it. + struct InputContext: Encodable { + let paths: [URLNode] + let targets: [Target] + let products: [Product] + let packages: [Package] + let xcodeTargets: [String] + let xcodeProjects: [String] + let pluginWorkDirId: Int + let toolSearchDirIds: [Int] + let accessibleTools: [String: Tool] + } + + /// `createBuildToolCommands`, the only thing the host asks of a build + /// tool plugin. + struct Request: Encodable { + let context: InputContext + let rootPackageId: Int + let targetId: Int + let pluginGeneratedSources: [Int] + let pluginGeneratedResources: [Int] + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + try container.encode(Body(self), forKey: AnyKey("createBuildToolCommands")) + } + + private struct Body: Encodable { + let context: InputContext + let rootPackageId: Int + let targetId: Int + let pluginGeneratedSources: [Int] + let pluginGeneratedResources: [Int] + + init(_ request: Request) { + context = request.context + rootPackageId = request.rootPackageId + targetId = request.targetId + pluginGeneratedSources = request.pluginGeneratedSources + pluginGeneratedResources = request.pluginGeneratedResources + } + } + } + + /// What a plugin says back. A build tool plugin sends commands and + /// diagnostics; the rest belongs to a command plugin asking the host to + /// build or test something, which this host does not do. + enum Response: Decodable { + case diagnostic(severity: String, message: String) + case progress(String) + case build(Command) + case prebuild(Command, outputDirectory: String) + case unsupported(String) + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + guard let key = container.allKeys.first else { + throw PluginError.undecodable("a message with no case") + } + + switch key.stringValue { + case "emitDiagnostic": + let body = try container.decode(Diagnostic.self, forKey: key) + self = .diagnostic(severity: body.severity, message: body.message) + case "emitProgress": + let body = try container.decode(Progress.self, forKey: key) + self = .progress(body.message) + case "defineBuildCommand": + let body = try container.decode(BuildCommand.self, forKey: key) + self = .build(.init(body.configuration, inputs: body.inputFiles, outputs: body.outputFiles)) + case "definePrebuildCommand": + let body = try container.decode(PrebuildCommand.self, forKey: key) + self = .prebuild( + .init(body.configuration, inputs: [], outputs: []), + outputDirectory: body.outputFilesDirectory) + default: + self = .unsupported(key.stringValue) + } + } + + private struct Diagnostic: Decodable { + let severity: String + let message: String + } + + private struct Progress: Decodable { + let message: String + } + + private struct BuildCommand: Decodable { + let configuration: Command.Configuration + let inputFiles: [String] + let outputFiles: [String] + } + + private struct PrebuildCommand: Decodable { + let configuration: Command.Configuration + let outputFilesDirectory: String + } + } + + /// A program the plugin asks to have run, with what it says it reads and + /// writes. + struct Command { + let displayName: String? + let executable: String + let arguments: [String] + let environment: [String: String] + let workingDirectory: String? + let inputs: [String] + let outputs: [String] + + init(_ configuration: Configuration, inputs: [String], outputs: [String]) { + displayName = configuration.displayName + executable = configuration.executable + arguments = configuration.arguments + environment = configuration.environment + workingDirectory = configuration.workingDirectory + self.inputs = inputs + self.outputs = outputs + } + + struct Configuration: Decodable { + let displayName: String? + let executable: String + let arguments: [String] + let environment: [String: String] + let workingDirectory: String? + } + } + } +} + +// MARK: - SwiftPM.PluginError + +extension SwiftPM { + enum PluginError: Error, CustomStringConvertible { + /// The toolchain's protocol is not the one mirrored here. + case undecodable(String) + case compileFailed(String) + case noToolchain + + var description: String { + switch self { + case .undecodable(let reason): + return "the plugin protocol of this toolchain is not the one bazelize speaks: \(reason)" + case .compileFailed(let reason): + return "the plugin itself does not compile: \(reason)" + case .noToolchain: + return "no toolchain to compile a plugin with" + } + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift index 2a62848..9994d1c 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift @@ -37,9 +37,6 @@ extension SwiftPM { struct Workspace { let packages: [Package] - /// What the build tool plugins of this project's own packages wrote. - let pluginOutputs: PluginOutputs - /// Where SwiftPM unpacked the binary targets it fetched. let artifacts: Path @@ -67,7 +64,6 @@ extension SwiftPM { guard (output + "Package.swift").exists else { return .init( packages: [], - pluginOutputs: .init(), artifacts: output + ".build/artifacts", directoryByIdentity: [:]) } @@ -98,7 +94,6 @@ extension SwiftPM { return .init( packages: packages, - pluginOutputs: await runPlugins(of: packages), artifacts: output + ".build/artifacts", directoryByIdentity: directoryByIdentity) } diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h index 220e5ae..bd6131e 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h +++ b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h @@ -12,6 +12,8 @@ NS_ASSUME_NONNULL_BEGIN @interface LocalTarget3 : NSObject + (int) test; - (int) test2; +/// What the package's build tool plugin generated. +- (int) generated; @end NS_ASSUME_NONNULL_END diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m index 852f6f3..1626353 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m +++ b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m @@ -7,14 +7,20 @@ #import "LocalTarget3.h" +/// Written by the Local1Gen build tool plugin, compiled into this target by the +/// rules bazelize generates: bazelize runs the plugin itself, so this holds on +/// every toolchain rather than only the ones whose `swift build` runs a plugin +/// for a C-family target. +extern int local1_plugin_value(void); + @implementation LocalTarget3 -/// The plugin's C source is compiled into this target when the toolchain runs -/// the plugin for a C-family target, which Swift 6.3 does not and 6.4 does. -/// Nothing here calls into it, so the fixture links either way. + (int) test { return 1 << 4; } - (int) test2 { return LocalTarget3.test; } +- (int) generated { + return local1_plugin_value(); +} @end From a78062af655824c55c9d48aa198bc1c45cbf25bb Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 13:24:17 +0800 Subject: [PATCH 165/173] Give the workspace its own way to run its plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What a build tool plugin writes is the plugin's to decide, so changing the plugin changes the files a target compiles while nothing else about the project moves — and regenerating the whole workspace to pick that up is the wrong unit of work. The generated workspace now carries the command instead: bazel run //:plugins It runs `bazelize plugins`, which compiles each plugin, hands it the package graph and the directory its output belongs in, and writes what comes back into `Packages/*/Generated/*Plugin` — the rules are not touched. They do not need touching, because they no longer name a plugin's files: the rules glob the directory by the kinds of file found in it. A plugin that starts writing `Generated2.swift` instead of `Generated.swift` needs `bazel run //:plugins` and nothing else. Only the kinds that are there are globbed, so an empty directory fails the package loudly rather than compiling without sources. `sh_binary` comes from rules_shell, which Bazel no longer ships itself. --- RepoSources.yml | 3 ++ Sources/BazelRules/Rules+Shell.swift | 38 +++++++++++++++++ Sources/Bazelize/Command.swift | 33 +++++++++++++++ Sources/BazelizeKit/Bazel/Bazel+Module.swift | 6 +++ .../BazelDep/BazelDep+RulesShell.swift | 18 ++++++++ Sources/BazelizeKit/Kit.swift | 8 ++++ .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 39 +++++++++++++++++- .../SwiftPM/SwiftPM+Generator.swift | 41 ++++++++++++++----- .../SwiftPM/SwiftPM+PluginHost.swift | 18 ++++++++ 9 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 Sources/BazelRules/Rules+Shell.swift create mode 100644 Sources/BazelizeKit/BazelDep/BazelDep+RulesShell.swift diff --git a/RepoSources.yml b/RepoSources.yml index f27b4e6..a299d2c 100644 --- a/RepoSources.yml +++ b/RepoSources.yml @@ -21,6 +21,9 @@ - name: RulesCC url: https://github.com/bazelbuild/rules_cc module: rules_cc +- name: RulesShell + url: https://github.com/bazelbuild/rules_shell + module: rules_shell - name: AppleSupport url: https://github.com/bazelbuild/apple_support module: apple_support diff --git a/Sources/BazelRules/Rules+Shell.swift b/Sources/BazelRules/Rules+Shell.swift new file mode 100644 index 0000000..9fd6439 --- /dev/null +++ b/Sources/BazelRules/Rules+Shell.swift @@ -0,0 +1,38 @@ +// +// Rules+Shell.swift +// +// +// The rule a workspace's own scripts are run with. +// + +import Foundation +import Starlark + +// MARK: - Rules.Shell + +extension Rules { + /// https://github.com/bazelbuild/rules_shell + public enum Shell: String, LoadableRule { + public var module: String { + "@rules_shell//shell:sh_binary.bzl" + } + + case sh_binary + } +} + +// MARK: - Rules.Shell.Call + +extension Rules.Shell { + public enum Call { + public static func sh_binary( + name: String, + srcs: [String]) -> Starlark.Statement.Call + { + Rules.Shell.sh_binary.call { + "name" => name + "srcs" => srcs + } + } + } +} diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index a8ca249..56ef948 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -21,12 +21,45 @@ struct Command: AsyncParsableCommand { version: version, subcommands: [ GenerateCommand.self, + PluginsCommand.self, XcodeCommand.self, // RoadmapCommand.self, ], defaultSubcommand: GenerateCommand.self) } +// MARK: - PluginsCommand + +/// Runs the build tool plugins of a generated workspace, and nothing else. +/// +/// What a plugin writes is decided by the plugin, so a change to its own source +/// changes the files a target compiles without anything else about the project +/// moving. This is the command that brings those files up to date — the +/// generated workspace exposes it as `bazel run //:plugins`, the way a Bazel +/// workspace exposes every other thing that writes back into it. +struct PluginsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "plugins", + abstract: "Run the build tool plugins of a generated workspace.") + + @Option(name: [.customLong("output", withSingleDash: false)], help: "PATH/TO/OUTPUT") + var output = "." + + @Option(name: [.customLong("local", withSingleDash: false)], help: "PATH/TO/LOCAL/PACKAGE") + var locals: [String] = [] + + func run() async throws { + let outputPath = Path.current + output + let notes = try await SwiftPM.runPlugins( + output: outputPath, + locals: locals.map { Path.current + $0 }) + + for note in notes { + print(note) + } + } +} + // MARK: - GenerateCommand struct GenerateCommand: AsyncParsableCommand { diff --git a/Sources/BazelizeKit/Bazel/Bazel+Module.swift b/Sources/BazelizeKit/Bazel/Bazel+Module.swift index 4b03813..0f7e0df 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Module.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Module.swift @@ -17,6 +17,9 @@ extension Bazel { private let skylib: BazelDep.BazelSkylib = .latest private let cc: BazelDep.RulesCC = .latest private let appleSupport: BazelDep.AppleSupport = .latest + /// What `//:plugins` is a `sh_binary` of: Bazel itself no longer has + /// that rule. + private let shell: BazelDep.RulesShell = .latest init(_ root: Path) { path = root + "MODULE.bazel" @@ -42,6 +45,9 @@ extension Bazel { builder.bazel_dep( name: "rules_cc", version: cc.rawValue) + builder.bazel_dep( + name: "rules_shell", + version: shell.rawValue) } } } diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+RulesShell.swift b/Sources/BazelizeKit/BazelDep/BazelDep+RulesShell.swift new file mode 100644 index 0000000..9942abe --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+RulesShell.swift @@ -0,0 +1,18 @@ +extension BazelDep { + /// https://github.com/bazelbuild/rules_shell + enum RulesShell: String { + static let latest: RulesShell = .v0_8_0 + + case v0_8_0 = "0.8.0" + case v0_7_1 = "0.7.1" + case v0_6_1 = "0.6.1" + case v0_6_0 = "0.6.0" + case v0_5_1 = "0.5.1" + case v0_5_0 = "0.5.0" + case v0_4_1 = "0.4.1" + case v0_4_0 = "0.4.0" + case v0_3_0 = "0.3.0" + case v0_2_0 = "0.2.0" + case v0_1_0 = "0.1.0" + } +} diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index f630e81..97c15d9 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -255,6 +255,14 @@ extension Kit { let path = resolvedOutputPath(custom.path) try path.parent().mkpath() try path.write(custom.content) + + /// A script is written to be run: `sh_binary` refuses one that is + /// not executable. + if path.extension == "sh" { + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: path.string) + } } try plugins.forEach { plugin in diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index dfd7b7d..591e2d8 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -169,10 +169,47 @@ final class PluginSwiftPM: PluginBuiltin { pinnedRevisions[Self.repositoryModuleName(url: url).lowercased()] } + /// The workspace's own way to run its build tool plugins. + override func build(_ builder: CodeBuilder) { + guard hasPackages else { return } + + builder.load(loadableRule: Rules.Shell.sh_binary) + builder.call( + Rules.Shell.Call.sh_binary( + name: "plugins", + srcs: ["plugins.sh"])) + } + override var custom: [PluginBuiltin.Custom]? { guard hasPackages else { return nil } - return [package, packageResolved].compactMap { $0 } + [ignore] + return [package, packageResolved].compactMap { $0 } + [ignore, plugins] + } + + /// `bazel run //:plugins`: what brings the files a build tool plugin writes + /// up to date, without generating the workspace again. + /// + /// A plugin decides what it writes, so changing the plugin changes those + /// files while nothing else about the project moves — and the rules glob the + /// directory rather than name the files, so they need no regenerating. This + /// is the workspace's own way to run them, the way `bazel mod tidy` is the + /// workspace's way to fix its module file. + private var plugins: PluginBuiltin.Custom { + let arguments = ["--output", "."] + locals.flatMap { local in + ["--local", (kit.project.workspaceRoot + local.relativePath).absolute().string.quoted] + } + + return .init( + path: "plugins.sh", + content: """ + #!/bin/bash + # Runs this workspace's build tool plugins, writing what they generate + # back into `Packages/*/Generated/*Plugin`. + set -euo pipefail + cd "${BUILD_WORKSPACE_DIRECTORY:-$(dirname "$0")}" + exec bazelize plugins \(arguments.joined(separator: " ")) + + """) } /// SwiftPM's working directory is not part of the Bazel workspace: a checkout diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift index 204eb77..3a131ef 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -273,12 +273,17 @@ extension SwiftPM { } /// What a plugin wrote for a target, split the way the target's own rule - /// takes it. + /// takes it, as patterns rather than names. /// /// The files are already where they belong: the host gave the plugin /// this directory to write into, so nothing is moved or linked here — /// they are real files of the package's `Generated/`, and the output /// stands without the package's `.build`. + /// + /// What they are called is the plugin's business and changes when the + /// plugin does, so the rules name the directory and the kinds of file in + /// it, never a file. `bazel run //:plugins` writes a new set into the + /// same place and the rules still hold. func materialize( pluginOutputsOf target: PackageTarget, in package: Package, @@ -298,9 +303,10 @@ extension SwiftPM { return ["swift"] }() - var sources: [String] = [] - var headers: [String] = [] - var resources: [String] = [] + var sources: Set = [] + var headers: Set = [] + var resources: Set = [] + var named: [String] = [] let base = output.root.normalize().string for file in output.files { @@ -309,18 +315,33 @@ extension SwiftPM { .trimmingCharacters(in: ["/"]) guard !relative.isEmpty else { continue } - let path = "\(directory)/\(relative)" - let `extension` = file.extension ?? "" + /// A file with no extension is the one thing a pattern cannot + /// stand for, so that one is named. + guard let `extension` = file.extension, !`extension`.isEmpty else { + named.append("\(directory)/\(relative)") + continue + } + if compiled.contains(`extension`) { - sources.append(path) + sources.insert(`extension`) } else if Self.headerExtensions.contains(`extension`) { - headers.append(path) + headers.insert(`extension`) } else { - resources.append(path) + resources.insert(`extension`) } } - return .init(sources: sources, headers: headers, resources: resources) + /// Only the kinds that are there: a pattern matching nothing fails + /// the package, which is what should happen when the directory is + /// empty — and not before that. + func patterns(_ extensions: Set) -> [String] { + extensions.sorted().map { "\(directory)/**/*.\($0)" } + } + + return .init( + sources: patterns(sources), + headers: patterns(headers), + resources: patterns(resources) + named) } /// The sources stay where SwiftPM put them; the package directory carries diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift index 6214e2e..f3284fa 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift @@ -11,6 +11,24 @@ import Subprocess import System import Util +extension SwiftPM { + /// Runs the build tool plugins of an already generated workspace. + /// + /// Nothing else is generated: the rules are already there and do not change + /// when a plugin writes a different set of files, because they glob the + /// directory the plugin writes into. What comes back is what to tell the + /// user about. + public static func runPlugins(output: Path, locals: [Path]) async throws -> [String] { + let workspace = try await loadWorkspace(output: output, root: nil, locals: locals) + let generator = Generator( + output: output, + workspace: workspace, + deployment: .init(project: [:])) + + return await generator.runPlugins().notes + } +} + extension SwiftPM.Generator { /// Runs the build tool plugins of the packages this project owns, into the /// directory their output belongs in. From 5785fccf03bcc9df58d1b9e6762b428734942fa9 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 13:59:24 +0800 Subject: [PATCH 166/173] Follow the plugin host in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard that stops a lane when a plugin did not run matched the old wording, which no longer exists: a run now names the plugin it could not run rather than saying a target's plugins failed as a group. The package lane is commented out with the reason it cannot pass yet — Swift 6.3 looks a plugin's tool up as a product, and TbCodeGenerater's product is named `tbCodeGenerater` while its plugin names the target `TbCodeGenerater`, so the package does not build on a runner at all. --- .github/workflows/swift.yml | 98 +++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index c814b58..04475b9 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -105,7 +105,7 @@ jobs: working-directory: fixture/iOS run: | ../../bazelize --project Example.xcodeproj --output App | tee bazelize.log - ! grep -q "did not run its plugins" bazelize.log + ! grep -q "did not run the" bazelize.log - name: Build Application working-directory: fixture/iOS/App @@ -218,54 +218,58 @@ jobs: - name: Bazel Generation run: | ./bazelize --project "${{ matrix.name }}/${{ matrix.project }}" --output "${{ matrix.name }}/App" | tee bazelize.log - ! grep -q "did not run its plugins" bazelize.log + ! grep -q "did not run the" bazelize.log - name: Build Application working-directory: ${{ matrix.name }}/App run: bazel build --disk_cache=~/bazel-disk ${{ matrix.target }} - IntegratePackage: - runs-on: macos-26 - needs: [artifact] - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - # A package whose tests only compile through a source its own build - # tool plugin generates. - - name: TbCodeGenerater - repo: https://github.com/yume190/TbCodeGenerater - rev: b7071d5e67189f48d5d71e06225dd1cb02470995 - target: //Packages/TbCodeGenerater/... - steps: - - name: Bazel Disk Cache - uses: actions/cache@v5 - with: - path: ~/bazel-disk - key: ${{ runner.os }}-bazel-disk-${{ matrix.name }}-${{ matrix.rev }} - restore-keys: | - ${{ runner.os }}-bazel-disk-${{ matrix.name }}- - - - name: Download Bazelize - uses: actions/download-artifact@v8 - with: - name: bazelize - - - name: Clone ${{ matrix.name }} - run: | - chmod +x bazelize - git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" - git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" - - # The package's directory name is the directory its rules live under, so - # the clone is named after the package rather than after the lane. - - name: Bazel Generation - run: | - ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" | tee bazelize.log - ! grep -q "did not run its plugins" bazelize.log - - - name: Test Package - working-directory: ${{ matrix.name }}/App - run: bazel test --disk_cache=~/bazel-disk ${{ matrix.target }} - + # Not yet green on a runner, so not yet a lane: Swift 6.3 looks a plugin's + # tool up as a product, and this package's executable product is named + # `tbCodeGenerater` while the target its plugin names is `TbCodeGenerater`, + # so the package does not build there at all. Renaming the product upstream + # fixes it, and the pinned revision moves with it. + # IntegratePackage: + # runs-on: macos-26 + # needs: [artifact] + # timeout-minutes: 60 + # strategy: + # fail-fast: false + # matrix: + # include: + # - name: TbCodeGenerater + # repo: https://github.com/yume190/TbCodeGenerater + # rev: b7071d5e67189f48d5d71e06225dd1cb02470995 + # target: //Packages/TbCodeGenerater/... + # steps: + # - name: Bazel Disk Cache + # uses: actions/cache@v5 + # with: + # path: ~/bazel-disk + # key: ${{ runner.os }}-bazel-disk-${{ matrix.name }}-${{ matrix.rev }} + # restore-keys: | + # ${{ runner.os }}-bazel-disk-${{ matrix.name }}- + # + # - name: Download Bazelize + # uses: actions/download-artifact@v8 + # with: + # name: bazelize + # + # - name: Clone ${{ matrix.name }} + # run: | + # chmod +x bazelize + # git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" + # git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" + # + # # The package's directory name is the directory its rules live under, so + # # the clone is named after the package rather than after the lane. + # - name: Bazel Generation + # run: | + # ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" | tee bazelize.log + # ! grep -q "did not run the" bazelize.log + # + # - name: Test Package + # working-directory: ${{ matrix.name }}/App + # run: bazel test --disk_cache=~/bazel-disk ${{ matrix.target }} + # + # \ No newline at end of file From d3c3db592a9967b4e86ffd3e32fd23a60c32232a Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 14:16:23 +0800 Subject: [PATCH 167/173] Cache what a corpus lane downloads, not only what it builds Each lane kept a Bazel disk cache and nothing else, so every run fetched rules_apple, rules_swift and the rest again, and resolved the app's Swift packages from scratch before a rule was written. Three caches now, written into `~/.bazelrc` so every `bazel` in the job reads them: the disk cache for what a build produced, the repository cache for what it downloaded, and the output directory's `.build` for what SwiftPM resolved. The fixture lane was caching a directory nothing writes, keyed on a `WORKSPACE` file that no longer exists; it caches the same three things now. `swift_deps.bzl` and `swift_deps_index.json` go with them: rspm generated those into the fixture and nothing has read them since it was removed. --- .github/workflows/swift.yml | 47 ++++++-- fixture/iOS/swift_deps.bzl | 25 ----- fixture/iOS/swift_deps_index.json | 171 ------------------------------ 3 files changed, 38 insertions(+), 205 deletions(-) delete mode 100644 fixture/iOS/swift_deps.bzl delete mode 100644 fixture/iOS/swift_deps_index.json diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 04475b9..3c24803 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -79,13 +79,23 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Bazel iOS Cache + # Every `bazel` in this job reads these: the disk cache holds what the + # build produced, the repository cache what it downloaded. Without the + # second one a runner fetches rules_apple and the rest again every time. + - name: Bazel Cache uses: actions/cache@v5 with: - path: fixture/iOS/cache - key: ${{ runner.os }}-IntegrateIOS-fixture-ios-${{ hashFiles('**/fixture/iOS/WORKSPACE') }} + path: | + ~/bazel-disk + ~/bazel-repo + key: ${{ runner.os }}-bazel-IntegrateIOS-${{ github.run_id }} restore-keys: | - ${{ runner.os }}-IntegrateIOS-fixture-ios- + ${{ runner.os }}-bazel-IntegrateIOS- + + - name: Bazel Cache Settings + run: | + echo "build --disk_cache=$HOME/bazel-disk" >> ~/.bazelrc + echo "build --repository_cache=$HOME/bazel-repo" >> ~/.bazelrc - name: Download Bazelize uses: actions/download-artifact@v8 @@ -186,13 +196,32 @@ jobs: # submodules: true # setup: carthage bootstrap --platform macOS --cache-builds steps: - - name: Bazel Disk Cache + # The disk cache holds what a build produced, the repository cache what it + # downloaded, and `.build` what SwiftPM resolved for the app's packages: + # generation resolves that graph before a single rule is written. + - name: Bazel Cache + uses: actions/cache@v5 + with: + path: | + ~/bazel-disk + ~/bazel-repo + key: ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}- + ${{ runner.os }}-bazel-${{ matrix.name }}- + + - name: SwiftPM Cache uses: actions/cache@v5 with: - path: ~/bazel-disk - key: ${{ runner.os }}-bazel-disk-${{ matrix.name }}-${{ matrix.rev }} + path: ${{ matrix.name }}/App/.build + key: ${{ runner.os }}-spm-${{ matrix.name }}-${{ matrix.rev }} restore-keys: | - ${{ runner.os }}-bazel-disk-${{ matrix.name }}- + ${{ runner.os }}-spm-${{ matrix.name }}- + + - name: Bazel Cache Settings + run: | + echo "build --disk_cache=$HOME/bazel-disk" >> ~/.bazelrc + echo "build --repository_cache=$HOME/bazel-repo" >> ~/.bazelrc - name: Download Bazelize uses: actions/download-artifact@v8 @@ -222,7 +251,7 @@ jobs: - name: Build Application working-directory: ${{ matrix.name }}/App - run: bazel build --disk_cache=~/bazel-disk ${{ matrix.target }} + run: bazel build ${{ matrix.target }} # Not yet green on a runner, so not yet a lane: Swift 6.3 looks a plugin's # tool up as a product, and this package's executable product is named diff --git a/fixture/iOS/swift_deps.bzl b/fixture/iOS/swift_deps.bzl deleted file mode 100644 index 5304c80..0000000 --- a/fixture/iOS/swift_deps.bzl +++ /dev/null @@ -1,25 +0,0 @@ -load("@cgrindel_swift_bazel//swiftpkg:defs.bzl", "local_swift_package", "swift_package") - -# Contents of swift_deps.bzl -def swift_dependencies(): - local_swift_package( - name = "swiftpkg_local1", - dependencies_index = "@//:swift_deps_index.json", - path = "Local1", - ) - - # version: 0.6.7 - swift_package( - name = "swiftpkg_anycodable", - commit = "862808b2070cd908cb04f9aafe7de83d35f81b05", - dependencies_index = "@//:swift_deps_index.json", - remote = "https://github.com/Flight-School/AnyCodable", - ) - - # version: 6.5.0 - swift_package( - name = "swiftpkg_rxswift", - commit = "b4307ba0b6425c0ba4178e138799946c3da594f8", - dependencies_index = "@//:swift_deps_index.json", - remote = "https://github.com/ReactiveX/RxSwift", - ) diff --git a/fixture/iOS/swift_deps_index.json b/fixture/iOS/swift_deps_index.json deleted file mode 100644 index 5b7ba11..0000000 --- a/fixture/iOS/swift_deps_index.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "modules": [ - { - "name": "AnyCodable", - "c99name": "AnyCodable", - "label": "@swiftpkg_anycodable//:Sources_AnyCodable" - }, - { - "name": "AnyCodableTests", - "c99name": "AnyCodableTests", - "label": "@swiftpkg_anycodable//:Tests_AnyCodableTests" - }, - { - "name": "LocalTarget1", - "c99name": "LocalTarget1", - "label": "@swiftpkg_local1//:Sources_LocalTarget1" - }, - { - "name": "LocalTarget2", - "c99name": "LocalTarget2", - "label": "@swiftpkg_local1//:Sources_LocalTarget2" - }, - { - "name": "LocalTarget3", - "c99name": "LocalTarget3", - "label": "@swiftpkg_local1//:Sources_LocalTarget3" - }, - { - "name": "Local1Tests", - "c99name": "Local1Tests", - "label": "@swiftpkg_local1//:Tests_Local1Tests" - }, - { - "name": "RxBlocking", - "c99name": "RxBlocking", - "label": "@swiftpkg_rxswift//:Sources_RxBlocking" - }, - { - "name": "RxCocoa", - "c99name": "RxCocoa", - "label": "@swiftpkg_rxswift//:Sources_RxCocoa" - }, - { - "name": "RxCocoaRuntime", - "c99name": "RxCocoaRuntime", - "label": "@swiftpkg_rxswift//:Sources_RxCocoaRuntime" - }, - { - "name": "RxRelay", - "c99name": "RxRelay", - "label": "@swiftpkg_rxswift//:Sources_RxRelay" - }, - { - "name": "RxSwift", - "c99name": "RxSwift", - "label": "@swiftpkg_rxswift//:Sources_RxSwift" - }, - { - "name": "RxTest", - "c99name": "RxTest", - "label": "@swiftpkg_rxswift//:Sources_RxTest" - } - ], - "products": [ - { - "identity": "anycodable", - "name": "AnyCodable", - "type": "library", - "target_labels": [ - "@swiftpkg_anycodable//:Sources_AnyCodable" - ] - }, - { - "identity": "local1", - "name": "LocalLib1", - "type": "library", - "target_labels": [ - "@swiftpkg_local1//:Sources_LocalTarget1", - "@swiftpkg_local1//:Sources_LocalTarget3" - ] - }, - { - "identity": "local1", - "name": "LocalLib2", - "type": "library", - "target_labels": [ - "@swiftpkg_local1//:Sources_LocalTarget2" - ] - }, - { - "identity": "rxswift", - "name": "RxBlocking", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxBlocking" - ] - }, - { - "identity": "rxswift", - "name": "RxBlocking-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxBlocking" - ] - }, - { - "identity": "rxswift", - "name": "RxCocoa", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxCocoa" - ] - }, - { - "identity": "rxswift", - "name": "RxCocoa-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxCocoa" - ] - }, - { - "identity": "rxswift", - "name": "RxRelay", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxRelay" - ] - }, - { - "identity": "rxswift", - "name": "RxRelay-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxRelay" - ] - }, - { - "identity": "rxswift", - "name": "RxSwift", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxSwift" - ] - }, - { - "identity": "rxswift", - "name": "RxSwift-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxSwift" - ] - }, - { - "identity": "rxswift", - "name": "RxTest", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxTest" - ] - }, - { - "identity": "rxswift", - "name": "RxTest-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxTest" - ] - } - ] -} \ No newline at end of file From 5c87f9a4c8c854d9e0cd39c63f750a7ce65595ed Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 14:23:07 +0800 Subject: [PATCH 168/173] Clear the old output out of the iOS fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bazelize used to generate into the fixture directory itself, and the leftovers were still there: a `BUILD` in every target's source directory, a `Package.swift` and its resolution at the fixture root, a `.bazelrc` importing a `config.bazelrc` that no longer exists, a `.bazelversion` pinning a Bazel the workspace stopped using, and 450MB of `cache/` and `.build/` from builds run out of that layout. The workspace is `App/` now, generated whole by `make bazelize`, and it carries its own `.bazelrc` and `.bazelversion`. The ignore rules followed the old layout too, naming each file it generated into the fixture. One directory is ignored now — and explicitly: `App/` was only being ignored because a rule meant for the corpus directory `app/` matches it on a case-insensitive filesystem. --- .gitignore | 11 +++-------- fixture/iOS/.bazelrc | 28 ---------------------------- fixture/iOS/.bazelversion | 1 - 3 files changed, 3 insertions(+), 37 deletions(-) delete mode 100644 fixture/iOS/.bazelrc delete mode 100644 fixture/iOS/.bazelversion diff --git a/.gitignore b/.gitignore index 473ab1c..6bcbb48 100644 --- a/.gitignore +++ b/.gitignore @@ -187,14 +187,9 @@ app/ spm/ Generated/ -# bazelize output inside the iOS fixture -fixture/iOS/BUILD -fixture/iOS/*/BUILD -fixture/iOS/MODULE.bazel -fixture/iOS/MODULE.bazel.lock -fixture/iOS/Package.swift -fixture/iOS/Package.resolved -fixture/iOS/config.bazelrc +# bazelize output inside the iOS fixture: one directory, the one `make bazelize` +# writes +fixture/iOS/App/ # running a local package's build tool plugins resolves that package fixture/iOS/Local1/Package.resolved diff --git a/fixture/iOS/.bazelrc b/fixture/iOS/.bazelrc deleted file mode 100644 index 04c5c8f..0000000 --- a/fixture/iOS/.bazelrc +++ /dev/null @@ -1,28 +0,0 @@ -startup --batch -startup --output_user_root=/tmp/bazelize-fixture-output - -import %workspace%/config.bazelrc - -build --disk_cache=cache -# build --experimental_enable_bzlmod - -# build --apple_platform_type=ios -# build --verbose_failures -build --ios_simulator_device="iPhone 16" -test --ios_simulator_device="iPhone 16" - - -# build --macos_minimum_os=10.15 - -# # Make sure no warnings slip into the C++ tools we vendor -# build --features treat_warnings_as_errors - -# # The default strategy is worker, which has sandboxing disabled by default, -# # which can hide issues with non-hermetic bugs. -# build --strategy=SwiftCompile=sandboxed - -# # build --ios_minimum_os=15.5 -# # build --ios_simulator_device="iPhone 13" -# # build --ios_simulator_version=15.5 -# # build --xcode_version=13.4.1 -common --enable_bzlmod diff --git a/fixture/iOS/.bazelversion b/fixture/iOS/.bazelversion deleted file mode 100644 index 6d28907..0000000 --- a/fixture/iOS/.bazelversion +++ /dev/null @@ -1 +0,0 @@ -8.5.0 From b5044d0ecf3fccaa85b24603a365bd03fed5ee21 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 21:35:57 +0800 Subject: [PATCH 169/173] Boot one simulator before the fixture's tests, and run them one at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner ran four simulator tests at once against a simulator that did not exist yet, so each paid to create and boot its own: the first test took 247s, the second 152s, and the last timed out at 302s having spent all of it waiting. None of that is the tests — locally the same four take four seconds against a simulator that is already there. The lane creates and boots the device up front, outside any test's clock, and runs the tests serially so they share it — rules_apple's runner reuses a simulator of the right type and version rather than making another. The timeout is raised to 900s and failures print their log, so the next one of these says what happened instead of only that it took too long. --- .github/workflows/swift.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 3c24803..05f82b3 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -127,12 +127,31 @@ jobs: run: | cp "$(bazel cquery //Targets/Example --output=files | grep '\.ipa$')" ../Example.ipa + # A simulator test on a runner pays for booting the simulator, and three + # of them booting at once pay for it three times over: the first test took + # 247s here and the last one timed out at 302s having never started. The + # simulator is booted once, up front, and the tests run one at a time. + - name: Boot Simulator + run: | + udid="$(xcrun simctl list devices available \ + | awk '/^-- iOS 27.0 --$/{f=1;next} /^-- /{f=0} f && /iPhone 17 \(/ {gsub(/[()]/,"");print $3; exit}')" + if [ -z "$udid" ]; then + udid="$(xcrun simctl create "iPhone 17" \ + com.apple.CoreSimulator.SimDeviceType.iPhone-17 \ + com.apple.CoreSimulator.SimRuntime.iOS-27-0)" + fi + xcrun simctl boot "$udid" || true + xcrun simctl bootstatus "$udid" + - name: Unit Test working-directory: fixture/iOS/App run: | bazel test \ --@build_bazel_rules_apple//apple/build_settings:ios_simulator_device="iPhone 17" \ --@build_bazel_rules_apple//apple/build_settings:ios_simulator_version=27.0 \ + --local_test_jobs=1 \ + --test_timeout=900 \ + --test_output=errors \ //Targets/ExampleTests \ //Targets/Framework1Tests \ //Targets/Framework2Tests \ From 1c50aae8c590d8f86d30f9e346d2b65b5e489560 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sat, 19 Sep 2026 22:00:04 +0800 Subject: [PATCH 170/173] Use the simulator the runner has, and clone before restoring a cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the last run failed, neither of them the code under test: The simulator step named a runtime this repository is developed against — `iOS-27-0` — and the runner ships whatever its own Xcode has, so `simctl` answered `Invalid runtime` and the step took the job down with it. The runner's newest available iOS runtime and an iPhone on it are looked up instead, created only if there is none, and the device and version are passed to `bazel test` rather than written into it. A device name has spaces (`iPhone Air`), so the three fields come back one per line. The app lane restored `/App/.build` before cloning the app, and `git clone` refuses a directory that exists. The cache is restored after the clone. --- .github/workflows/swift.yml | 76 +++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 05f82b3..1669a02 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -127,19 +127,51 @@ jobs: run: | cp "$(bazel cquery //Targets/Example --output=files | grep '\.ipa$')" ../Example.ipa - # A simulator test on a runner pays for booting the simulator, and three - # of them booting at once pay for it three times over: the first test took - # 247s here and the last one timed out at 302s having never started. The - # simulator is booted once, up front, and the tests run one at a time. + # A simulator test on a runner pays for booting the simulator, and four of + # them booting at once pay for it four times over: the first test took + # 247s here and the last timed out at 302s having never started. One + # simulator is booted up front, outside any test's clock, and the tests + # run one at a time so they share it. + # + # Which simulator is the runner's business, not ours: it has whatever + # runtime its Xcode ships, which is not the one this repository is + # developed against. - name: Boot Simulator run: | - udid="$(xcrun simctl list devices available \ - | awk '/^-- iOS 27.0 --$/{f=1;next} /^-- /{f=0} f && /iPhone 17 \(/ {gsub(/[()]/,"");print $3; exit}')" - if [ -z "$udid" ]; then - udid="$(xcrun simctl create "iPhone 17" \ - com.apple.CoreSimulator.SimDeviceType.iPhone-17 \ - com.apple.CoreSimulator.SimRuntime.iOS-27-0)" - fi + # A device name has spaces in it, so one field per line. + { read -r device; read -r version; read -r udid; } < <(python3 - <<'PY' + import json, subprocess + + def simctl(*arguments): + listed = subprocess.run(["xcrun", "simctl", "list", "-j", *arguments], capture_output=True, text=True) + return json.loads(listed.stdout) + + runtimes = [runtime for runtime in simctl("runtimes")["runtimes"] + if runtime["identifier"].startswith("com.apple.CoreSimulator.SimRuntime.iOS") + and runtime.get("isAvailable")] + runtimes.sort(key=lambda runtime: [int(part) for part in runtime["version"].split(".")]) + runtime = runtimes[-1] + + devices = [device for device in simctl("devices", "available")["devices"].get(runtime["identifier"], []) + if device["name"].startswith("iPhone")] + if devices: + name, udid = devices[-1]["name"], devices[-1]["udid"] + else: + kinds = [kind for kind in simctl("devicetypes")["devicetypes"] if kind["name"].startswith("iPhone")] + name = kinds[-1]["name"] + created = subprocess.run( + ["xcrun", "simctl", "create", name, kinds[-1]["identifier"], runtime["identifier"]], + capture_output=True, text=True) + udid = created.stdout.strip() + + print(name) + print(runtime["version"]) + print(udid) + PY + ) + echo "Simulator: $device, iOS $version ($udid)" + echo "SIMULATOR_DEVICE=$device" >> "${GITHUB_ENV:-/dev/null}" + echo "SIMULATOR_VERSION=$version" >> "${GITHUB_ENV:-/dev/null}" xcrun simctl boot "$udid" || true xcrun simctl bootstatus "$udid" @@ -147,8 +179,8 @@ jobs: working-directory: fixture/iOS/App run: | bazel test \ - --@build_bazel_rules_apple//apple/build_settings:ios_simulator_device="iPhone 17" \ - --@build_bazel_rules_apple//apple/build_settings:ios_simulator_version=27.0 \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_device="$SIMULATOR_DEVICE" \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_version="$SIMULATOR_VERSION" \ --local_test_jobs=1 \ --test_timeout=900 \ --test_output=errors \ @@ -229,14 +261,6 @@ jobs: ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}- ${{ runner.os }}-bazel-${{ matrix.name }}- - - name: SwiftPM Cache - uses: actions/cache@v5 - with: - path: ${{ matrix.name }}/App/.build - key: ${{ runner.os }}-spm-${{ matrix.name }}-${{ matrix.rev }} - restore-keys: | - ${{ runner.os }}-spm-${{ matrix.name }}- - - name: Bazel Cache Settings run: | echo "build --disk_cache=$HOME/bazel-disk" >> ~/.bazelrc @@ -253,6 +277,16 @@ jobs: git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" + # After the clone: a restored cache would otherwise create the directory + # `git clone` insists on creating itself. + - name: SwiftPM Cache + uses: actions/cache@v5 + with: + path: ${{ matrix.name }}/App/.build + key: ${{ runner.os }}-spm-${{ matrix.name }}-${{ matrix.rev }} + restore-keys: | + ${{ runner.os }}-spm-${{ matrix.name }}- + - name: Check out submodules if: matrix.submodules working-directory: ${{ matrix.name }} From d522d546a04fa5a4282a95307be75bc74b327691 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sun, 20 Sep 2026 12:19:14 +0800 Subject: [PATCH 171/173] Have Bazel build the plugins, and run them from the workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin was compiled by bazelize and its tool built by SwiftPM, which is the one thing left that could fail for reasons having nothing to do with the plugin: a toolchain that looks a plugin's tool up as a product cannot even load the package the tool lives in, so nothing about that package can be generated with its plugins run. Both are Bazel targets now. A plugin is a `swift_binary` with the toolchain's `PackagePlugin` on its search path — it links nothing else — and the tool already was one. `//:plugins` carries them as `data`, so `bazel run //:plugins` builds them and hands their paths to the host, which speaks the protocol and runs what the plugin asks for. No second Bazel inside the first one's lock, and no SwiftPM in the plugin path at all. The rules glob a target's plugin directory whenever the target declares a plugin, before anything has written there, so `allow_empty` is set: a package that cannot load is a package that cannot run `//:plugins` to fill it. Measured with the plugin directory, `.bazelize` and the package's `.build` deleted: `bazel run //:plugins` puts all four of the fixture's generated files back without compiling a plugin or building a tool itself, and does the same for TbCodeGenerater. The lane for that package is a lane again. --- .github/workflows/swift.yml | 110 ++++++------ Sources/BazelRules/Rules+Shell.swift | 6 +- Sources/Bazelize/Command.swift | 21 ++- Sources/BazelizeKit/Kit.swift | 9 +- .../BazelizeKit/Plugin/Plugin+SwiftPM.swift | 45 ++--- .../BazelizeKit/SwiftPM/SwiftPM+Clang.swift | 3 +- .../SwiftPM/SwiftPM+Executable.swift | 3 +- .../SwiftPM/SwiftPM+Generator.swift | 73 ++++++-- .../BazelizeKit/SwiftPM/SwiftPM+Macro.swift | 3 +- .../SwiftPM/SwiftPM+PluginHost.swift | 22 ++- .../SwiftPM/SwiftPM+PluginProcess.swift | 45 ++--- .../SwiftPM/SwiftPM+PluginRule.swift | 157 ++++++++++++++++++ .../SwiftPM/SwiftPM+Resources.swift | 4 +- .../BazelizeKit/SwiftPM/SwiftPM+Test.swift | 3 +- .../Starlark/Value/Starlark+Value.swift | 23 ++- 15 files changed, 393 insertions(+), 134 deletions(-) create mode 100644 Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1669a02..6cdf311 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -306,52 +306,64 @@ jobs: working-directory: ${{ matrix.name }}/App run: bazel build ${{ matrix.target }} - # Not yet green on a runner, so not yet a lane: Swift 6.3 looks a plugin's - # tool up as a product, and this package's executable product is named - # `tbCodeGenerater` while the target its plugin names is `TbCodeGenerater`, - # so the package does not build there at all. Renaming the product upstream - # fixes it, and the pinned revision moves with it. - # IntegratePackage: - # runs-on: macos-26 - # needs: [artifact] - # timeout-minutes: 60 - # strategy: - # fail-fast: false - # matrix: - # include: - # - name: TbCodeGenerater - # repo: https://github.com/yume190/TbCodeGenerater - # rev: b7071d5e67189f48d5d71e06225dd1cb02470995 - # target: //Packages/TbCodeGenerater/... - # steps: - # - name: Bazel Disk Cache - # uses: actions/cache@v5 - # with: - # path: ~/bazel-disk - # key: ${{ runner.os }}-bazel-disk-${{ matrix.name }}-${{ matrix.rev }} - # restore-keys: | - # ${{ runner.os }}-bazel-disk-${{ matrix.name }}- - # - # - name: Download Bazelize - # uses: actions/download-artifact@v8 - # with: - # name: bazelize - # - # - name: Clone ${{ matrix.name }} - # run: | - # chmod +x bazelize - # git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" - # git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" - # - # # The package's directory name is the directory its rules live under, so - # # the clone is named after the package rather than after the lane. - # - name: Bazel Generation - # run: | - # ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" | tee bazelize.log - # ! grep -q "did not run the" bazelize.log - # - # - name: Test Package - # working-directory: ${{ matrix.name }}/App - # run: bazel test --disk_cache=~/bazel-disk ${{ matrix.target }} - # - # \ No newline at end of file + # A package whose tests only compile through a source its own build tool + # plugin generates. The plugin and its tool are built by Bazel, so this + # holds on a toolchain that cannot load the package with SwiftPM at all. + IntegratePackage: + runs-on: macos-26 + needs: [artifact] + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: TbCodeGenerater + repo: https://github.com/yume190/TbCodeGenerater + rev: b7071d5e67189f48d5d71e06225dd1cb02470995 + target: //Packages/TbCodeGenerater/... + steps: + - name: Bazel Cache + uses: actions/cache@v5 + with: + path: | + ~/bazel-disk + ~/bazel-repo + key: ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}- + ${{ runner.os }}-bazel-${{ matrix.name }}- + + - name: Bazel Cache Settings + run: | + echo "build --disk_cache=$HOME/bazel-disk" >> ~/.bazelrc + echo "build --repository_cache=$HOME/bazel-repo" >> ~/.bazelrc + + - name: Download Bazelize + uses: actions/download-artifact@v8 + with: + name: bazelize + + - name: Clone ${{ matrix.name }} + run: | + chmod +x bazelize + git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" + git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" + + # The package's directory name is the directory its rules live under, so + # the clone is named after the package rather than after the lane. + # Generation runs the plugins with what it has, which on this toolchain is + # not enough to build the plugin's tool — `//:plugins` is what builds the + # plugin and the tool with Bazel and runs them, so that is what the lane + # checks. Nothing else is regenerated: the rules glob the directory. + - name: Bazel Generation + run: ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" + + - name: Run Plugins + working-directory: ${{ matrix.name }}/App + run: | + PATH="$GITHUB_WORKSPACE:$PATH" bazel run //:plugins + + - name: Test Package + working-directory: ${{ matrix.name }}/App + run: bazel test ${{ matrix.target }} + diff --git a/Sources/BazelRules/Rules+Shell.swift b/Sources/BazelRules/Rules+Shell.swift index 9fd6439..1fbda3f 100644 --- a/Sources/BazelRules/Rules+Shell.swift +++ b/Sources/BazelRules/Rules+Shell.swift @@ -27,11 +27,15 @@ extension Rules.Shell { public enum Call { public static func sh_binary( name: String, - srcs: [String]) -> Starlark.Statement.Call + srcs: [String], + data: [String] = []) -> Starlark.Statement.Call { Rules.Shell.sh_binary.call { "name" => name "srcs" => srcs + if !data.isEmpty { + "data" => data.map { Starlark.Label.named($0) } + } } } } diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index 56ef948..d6d8d19 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -48,16 +48,35 @@ struct PluginsCommand: AsyncParsableCommand { @Option(name: [.customLong("local", withSingleDash: false)], help: "PATH/TO/LOCAL/PACKAGE") var locals: [String] = [] + /// `NAME=PATH`, for the programs Bazel built: `//:plugins` has them as + /// `data`, so a plugin runs without SwiftPM building anything. + @Option(name: [.customLong("plugin", withSingleDash: false)], help: "NAME=PATH/TO/PLUGIN") + var plugins: [String] = [] + + @Option(name: [.customLong("tool", withSingleDash: false)], help: "NAME=PATH/TO/TOOL") + var tools: [String] = [] + func run() async throws { let outputPath = Path.current + output let notes = try await SwiftPM.runPlugins( output: outputPath, - locals: locals.map { Path.current + $0 }) + locals: locals.map { Path.current + $0 }, + plugins: Self.programs(plugins), + tools: Self.programs(tools)) for note in notes { print(note) } } + + private static func programs(_ arguments: [String]) -> [String: Path] { + arguments.reduce(into: [:]) { programs, argument in + guard let separator = argument.firstIndex(of: "=") else { return } + let name = String(argument[.. PluginGenerated { - guard let output = pluginOutputs.output(of: target.name, in: package) else { - return .none - } + /// A target that asks for no plugin has no such directory, and a + /// pattern for one would be a pattern for something that is never + /// coming. + guard !target.pluginUsages.isEmpty else { return .none } let directory = "Generated/\(target.name)Plugin" @@ -303,13 +340,20 @@ extension SwiftPM { return ["swift"] }() - var sources: Set = [] - var headers: Set = [] + /// The kinds the target could compile, whether or not the plugin has + /// run yet: the rules are written once and the files arrive later, + /// from `bazel run //:plugins`. + var sources = compiled + var headers: Set = { + if case .clang = kind { return Set(Self.headerExtensions) } + return [] + }() var resources: Set = [] var named: [String] = [] - let base = output.root.normalize().string - for file in output.files { + let output = pluginOutputs.output(of: target.name, in: package) + let base = output?.root.normalize().string ?? "" + for file in output?.files ?? [] { let relative = file.normalize().string .delete(prefix: base) .trimmingCharacters(in: ["/"]) @@ -331,9 +375,6 @@ extension SwiftPM { } } - /// Only the kinds that are there: a pattern matching nothing fails - /// the package, which is what should happen when the directory is - /// empty — and not before that. func patterns(_ extensions: Set) -> [String] { extensions.sorted().map { "\(directory)/**/*.\($0)" } } @@ -703,7 +744,11 @@ extension SwiftPM { relativeFiles(of: target, in: package, prefix: prefix)) + generated + (resources?.accessors ?? []), - exclude: excluded(target, prefix: prefix)), + exclude: excluded(target, prefix: prefix), + /// The plugin's directory is globbed before anything has + /// written into it: `bazel run //:plugins` does that, and + /// a package that cannot load cannot run it. + allowEmpty: true), deps: deps(of: target, in: package).nonEmpty.map { labels in .build { labels } }, diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift index 09ac19f..992a890 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift @@ -34,7 +34,8 @@ extension SwiftPM.Generator { sources(of: target, prefix: prefix, extensions: ["swift"]), relativeFiles(of: target, in: package, prefix: prefix)) + generated, - exclude: excluded(target, prefix: prefix)), + exclude: excluded(target, prefix: prefix), + allowEmpty: true), copts: copts(of: target).nonEmpty, deps: deps(of: target, in: package).nonEmpty.map { labels in .build { labels } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift index f3284fa..8a3b490 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift @@ -18,12 +18,21 @@ extension SwiftPM { /// when a plugin writes a different set of files, because they glob the /// directory the plugin writes into. What comes back is what to tell the /// user about. - public static func runPlugins(output: Path, locals: [Path]) async throws -> [String] { + /// `plugins` and `tools` are what Bazel built, by target name: a plugin is + /// a program and so is the tool it runs, and building them is Bazel's job + /// wherever `//:plugins` is what started this. + public static func runPlugins( + output: Path, + locals: [Path], + plugins: [String: Path] = [:], + tools: [String: Path] = [:]) async throws -> [String] + { let workspace = try await loadWorkspace(output: output, root: nil, locals: locals) let generator = Generator( output: output, workspace: workspace, - deployment: .init(project: [:])) + deployment: .init(project: [:]), + built: .init(plugins: plugins, tools: tools)) return await generator.runPlugins().notes } @@ -161,6 +170,10 @@ extension SwiftPM.Generator { /// so compiling it needs no package graph — which is the whole reason the /// host can run one without building anything else. private func compile(plugin target: SwiftPM.PackageTarget, in package: SwiftPM.Package) async throws -> Path { + /// Bazel built it: `//:plugins` has the plugin as `data`, so it is in + /// the runfiles by the time the host runs. + if let prebuilt = built.plugins[target.name], prebuilt.exists { return prebuilt } + let built = output + ".bazelize/plugins" + package.directory + target.name if built.exists { return built } @@ -231,6 +244,11 @@ extension SwiftPM.Generator { return nil } + /// Bazel built it, so SwiftPM never has to load the package the tool + /// lives in — which some toolchains cannot do when a plugin names its + /// tool by target. + if let prebuilt = built.tools[name], prebuilt.exists { return prebuilt } + let product = package.manifest.products.first { product in product.targets.contains(name) }?.name ?? name diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift index c002057..fdf70c9 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift @@ -24,15 +24,15 @@ extension SwiftPM { toolsVersion: String, to executable: Path) async throws { - guard let api = try await pluginAPI() else { throw PluginError.noToolchain } + guard let api = pluginAPIPath else { throw PluginError.noToolchain } let result = try await Subprocess.run( .name("swiftc"), arguments: Arguments([ - "-I", api.string, - "-L", api.string, + "-I", api, + "-L", api, "-lPackagePlugin", - "-Xlinker", "-rpath", "-Xlinker", api.string, + "-Xlinker", "-rpath", "-Xlinker", api, /// Which `PackagePlugin` API the plugin was written against; /// its availability is stated in terms of it. "-package-description-version", toolsVersion, @@ -175,26 +175,31 @@ extension SwiftPM { return responses } - /// Where the toolchain keeps the module a plugin is compiled against. - private static func pluginAPI() async throws -> Path? { - let result = try await Subprocess.run( - .name("xcrun"), - arguments: Arguments(["--find", "swiftc"]), - output: .string(limit: 4096), - error: .discarded) + /// Where the toolchain keeps the module a plugin is compiled against, + /// which the rules that build a plugin need spelled out. + /// + /// `xcrun` is asked once: a run builds against one toolchain. + static let pluginAPIPath: String? = { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--find", "swiftc"] - guard - result.terminationStatus.isSuccess, - let found = Optional(result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines)), - !found.isEmpty - else { - return nil - } + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + + guard (try? process.run()) != nil else { return nil } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + let found = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !found.isEmpty else { return nil } /// `/usr/bin/swiftc` → `/usr/lib/swift/pm/PluginAPI` let api = Path(found).parent().parent() + "lib/swift/pm/PluginAPI" - return api.isDirectory ? api : nil - } + return api.isDirectory ? api.string : nil + }() private static func errors(_ output: String?) -> String { guard let output else { return "no output" } diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift new file mode 100644 index 0000000..3afeec6 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift @@ -0,0 +1,157 @@ +// +// SwiftPM+PluginRule.swift +// +// +// Building a build tool plugin, and the command that runs it. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A build tool plugin as a program Bazel builds. + /// + /// A plugin links nothing but the toolchain's `PackagePlugin`, so this is an + /// ordinary `swift_binary` with the module on its search path. Building it + /// here rather than with SwiftPM is what keeps the plugin path independent + /// of whether the package it lives in builds at all — some toolchains + /// cannot even load a package whose plugin names its tool by target. + func buildPlugin( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + builder: CodeBuilder) + { + guard let api = SwiftPM.PluginHost.pluginAPIPath else { return } + + builder.load(loadableRule: Rules.Swift.swift_binary) + builder.call( + Rules.Swift.Call.swift_binary( + name: ruleName(of: target.name, in: package), + copts: [ + "-I", api, + /// Which `PackagePlugin` the plugin was written against; its + /// availability is stated in terms of the tools version. + "-package-description-version", package.manifest.toolsVersion, + ], + linkopts: [ + "-L", api, + "-lPackagePlugin", + "-Xlinker", "-rpath", "-Xlinker", api, + ], + module_name: Self.moduleName(target.name), + srcs: Starlark.glob(["\(prefix)/**/*.swift"]), + tags: Self.manual, + visibility: .public)) + } + + /// `bazel run //:plugins`, and everything it needs built first. + /// + /// The plugins and the tools they run are `data` of the script, so running + /// it builds them: a script that called `bazel build` itself would be a + /// second Bazel inside the first one's lock. + func writePluginRunner(locals: [Path]) throws { + let binaries = pluginBinaries + guard !binaries.isEmpty else { return } + + let group = CodeBuilder() + group.call( + Rules.Builtin.Call.filegroup( + name: "plugins", + srcs: .build { binaries.map(\.label).sorted().map { Starlark.Label.named($0) } }, + visibility: .public)) + try (packagesRoot + "BUILD").write(group.build()) + + let arguments = ["--output", "."] + + locals.flatMap { local in ["--local", local.absolute().string.quoted] } + + binaries.flatMap { binary in + [binary.isPlugin ? "--plugin" : "--tool", "\(binary.name)=$runfiles/\(binary.path)"] + } + + let script = output + "plugins.sh" + try script.write(""" + #!/bin/bash + # Runs this workspace's build tool plugins, writing what they generate + # back into `Packages/*/Generated/*Plugin`. + # + # The plugins and their tools are built by Bazel: they are `data` of this + # script, so they are in its runfiles by the time it runs. + set -euo pipefail + runfiles="${RUNFILES_DIR:-$0.runfiles}/_main" + cd "${BUILD_WORKSPACE_DIRECTORY:-$(dirname "$0")}" + exec bazelize plugins \(arguments.joined(separator: " ")) + + """) + + /// `sh_binary` refuses a script that is not executable. + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.string) + } + + /// What a plugin needs built: the plugin itself, and the tools it runs. + private var pluginBinaries: [PluginBinary] { + var binaries: [PluginBinary] = [] + + for package in workspace.packages where package.isRoot || package.isLocal { + let used = Set(package.manifest.targets.flatMap(\.pluginUsages).map(\.name)) + guard !used.isEmpty else { continue } + + for target in package.manifest.targets where used.contains(target.name) { + guard target.type == "plugin" else { continue } + binaries.append(binary(of: target, in: package, isPlugin: true)) + + for dependency in target.dependencies { + guard case .target(let name) = dependency.kind else { + guard case .byName(let name) = dependency.kind else { continue } + if let tool = tool(named: name, in: package) { binaries.append(tool) } + continue + } + if let tool = tool(named: name, in: package) { binaries.append(tool) } + } + } + } + + return binaries + } + + private func tool(named name: String, in package: SwiftPM.Package) -> PluginBinary? { + guard + let target = package.manifest.targets.first(where: { + $0.name == name && $0.type == "executable" + }) + else { + return nil + } + + return binary(of: target, in: package, isPlugin: false) + } + + private func binary( + of target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + isPlugin: Bool) -> PluginBinary + { + let rule = ruleName(of: target.name, in: package) + let directory = "\(PluginSwiftPM.packagesDirectory)/\(package.directory)" + + return .init( + name: target.name, + label: "//\(directory):\(rule)", + path: "\(directory)/\(rule)", + isPlugin: isPlugin) + } +} + +extension SwiftPM.Generator { + /// A program `//:plugins` has Bazel build before it runs. + struct PluginBinary { + let name: String + let label: String + /// Where it sits in the runner's runfiles. + let path: String + let isPlugin: Bool + } +} + diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift index d321af2..ee1abb4 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -105,7 +105,7 @@ extension SwiftPM.Generator { builder.call( Rules.Apple.Resources.Call.apple_resource_group( name: group, - resources: Starlark.glob(patterns))) + resources: Starlark.glob(patterns, allowEmpty: true))) } for (index, prefixToStrip) in structured.keys.sorted().enumerated() { @@ -131,7 +131,7 @@ extension SwiftPM.Generator { /// A glob and a group cannot be added together in one attribute, so /// once there is a group everything is a group. resources: groups.isEmpty - ? resources.nonEmpty.map { Starlark.glob($0) } + ? resources.nonEmpty.map { Starlark.glob($0, allowEmpty: true) } : .build { groups.map { Starlark.Label.named(":\($0)") } }, tags: Self.manual)) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift index 1fc591c..92802ba 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift @@ -48,7 +48,8 @@ extension SwiftPM.Generator { relativeFiles(of: target, in: package, prefix: prefix)) + generated + (resources?.accessors ?? []), - exclude: excluded(target, prefix: prefix)), + exclude: excluded(target, prefix: prefix), + allowEmpty: true), deps: deps(of: target, in: package).nonEmpty.map { labels in .build { labels } }, diff --git a/Sources/Starlark/Starlark/Value/Starlark+Value.swift b/Sources/Starlark/Starlark/Value/Starlark+Value.swift index 6a492f5..1ae31b4 100644 --- a/Sources/Starlark/Starlark/Value/Starlark+Value.swift +++ b/Sources/Starlark/Starlark/Value/Starlark+Value.swift @@ -9,8 +9,11 @@ extension Starlark { .custom(value) } - public static func glob(_ files: [String], exclude: [String] = []) -> Value { - .glob(files, exclude: exclude) + /// `allowEmpty` is for a directory something else writes into: the pattern + /// stands for what will be there, and a package that cannot be loaded until + /// it is cannot be the thing that puts it there. + public static func glob(_ files: [String], exclude: [String] = [], allowEmpty: Bool = false) -> Value { + .glob(files, exclude: exclude, allowEmpty: allowEmpty) } public indirect enum Value: Sendable, Text { @@ -21,7 +24,7 @@ extension Starlark { case array([Value]) case dictionary([String: Value]) case select(Starlark.Select) - case glob([String], exclude: [String]) + case glob([String], exclude: [String], allowEmpty: Bool) case custom(String) case none @@ -104,13 +107,17 @@ extension Starlark { return value ? "True" : "False" case .select(let value): return value.text - case .glob(let files, let exclude): + case .glob(let files, let exclude, let allowEmpty): let asset = Value(files.sorted()) ?? .none - guard !exclude.isEmpty else { - return "glob(\(asset.text))" + var arguments = [asset.text] + if !exclude.isEmpty { + let excluded = Value(exclude.sorted()) ?? .none + arguments.append("exclude = \(excluded.text)") } - let excluded = Value(exclude.sorted()) ?? .none - return "glob(\(asset.text), exclude = \(excluded.text))" + if allowEmpty { + arguments.append("allow_empty = True") + } + return "glob(\(arguments.joined(separator: ", ")))" case .custom(let value): return value case .none: From 0b7be4d19cbe9557faecbae49e2701386b0144c5 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sun, 20 Sep 2026 16:49:54 +0800 Subject: [PATCH 172/173] Take the macro, the tool and the plugin back out of the iOS fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture is an Xcode project, and Xcode's build system does not run a build tool plugin for a C-family target: `LocalTarget3` called a symbol from the source `Local1Gen` generates for it, so opening the fixture in Xcode ended in `Undefined symbol: _local1_plugin_value`. `Local1Macros` and `Local1Tool` go with it — the package is back to the three plain targets it had. What those covered is covered by a package that is not an Xcode project: `spm/TbCodeGenerater`, whose tests only compile through a source its own build tool plugin generates, and whose plugin and tool are built by Bazel. The SwiftPM notes said the fixture stood for macros and generated sources; they name that package now. --- docs/SPM.md | 7 ++- docs/SPM_ZH.md | 7 ++- fixture/iOS/ExampleTests/ExampleTests.swift | 8 --- fixture/iOS/Local1/Package.swift | 30 +-------- .../iOS/Local1/Plugins/Local1Gen/Plugin.swift | 29 --------- .../Sources/Local1Macros/Local1Macros.swift | 23 ------- .../iOS/Local1/Sources/Local1Tool/main.swift | 61 ------------------- .../Sources/LocalTarget1/LocalTarget1.swift | 18 ------ .../LocalTarget3/include/LocalTarget3.h | 2 - .../Sources/LocalTarget3/src/LocalTarget3.m | 9 --- 10 files changed, 10 insertions(+), 184 deletions(-) delete mode 100644 fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift delete mode 100644 fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift delete mode 100644 fixture/iOS/Local1/Sources/Local1Tool/main.swift diff --git a/docs/SPM.md b/docs/SPM.md index d285984..9db024a 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -241,8 +241,9 @@ output of `swift package dump-package` and `describe`). | PluginTarget | 1 | No macro targets, and no mixed-language targets (SwiftPM does not allow them). -The iOS fixture declares one instead, so the rules for a macro are exercised by -a build rather than by inspection. +Nothing in the corpus exercises a macro or a source-generating plugin by being +built, so what covers those is `spm/TbCodeGenerater`, whose tests only compile +through a source its own build tool plugin generates. ### Build settings (targets / packages using them) @@ -285,7 +286,7 @@ a plugin target nobody consumes" as rules, **all 119 packages fall into stages |---|---| | pure Swift libraries, no resources | 58 | | + clang / resources / binary / system | 61 (119 cumulative) | -| macros, source-generating plugins | 0 (none in the corpus; the fixture has a macro) | +| macros, source-generating plugins | 0 (none in the corpus; `spm/TbCodeGenerater` covers a source-generating plugin) | The minimum stage each app needs (expanded from each workspace's `Package.resolved`): diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md index 1271374..595d83b 100644 --- a/docs/SPM_ZH.md +++ b/docs/SPM_ZH.md @@ -216,8 +216,9 @@ package 自己宣告的 platform floor 是**故意忽略**的——逐 package | SystemLibraryTarget | 2 | | PluginTarget | 1 | -沒有 macro target,也沒有混合語言 target(SwiftPM 本來就不允許)。iOS fixture 自己 -補了一個 macro,所以 macro 的規則是用「建起來並驗證展開結果」來檢查,不是靠讀產出。 +沒有 macro target,也沒有混合語言 target(SwiftPM 本來就不允許)。語料裡沒有任何 +東西會「靠建起來」驗證 macro 或會產生原始碼的 plugin,這兩件事由 +`spm/TbCodeGenerater` 守著——它的測試只有靠自己 build tool plugin 產生的原始碼才編得過。 ### build settings(用到的 target 數/package 數) @@ -258,7 +259,7 @@ target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: |---|---| | 純 Swift library、無 resource | 58 | | + clang/resources/binary/system | 61(累計 119) | -| macro、會產生原始碼的 plugin | 0(語料裡沒有;fixture 自己有一個 macro) | +| macro、會產生原始碼的 plugin | 0(語料裡沒有;會產生原始碼的 plugin 由 `spm/TbCodeGenerater` 守著) | 每個 app 需要的最低階段(用各 workspace 的 `Package.resolved` 展開): diff --git a/fixture/iOS/ExampleTests/ExampleTests.swift b/fixture/iOS/ExampleTests/ExampleTests.swift index a5d959a..7230aed 100644 --- a/fixture/iOS/ExampleTests/ExampleTests.swift +++ b/fixture/iOS/ExampleTests/ExampleTests.swift @@ -5,7 +5,6 @@ // Created by Yume on 2023/1/7. // -import LocalTarget1 import XCTest @testable import Example @@ -13,11 +12,4 @@ final class ExampleTests: XCTestCase { func testExample() throws { XCTAssertEqual(test(), 0b1111) } - - /// The local package declares a macro and expands it in its own sources, so - /// the value only exists if the compiler loaded the generated plugin. - func testPackageMacroExpands() throws { - XCTAssertEqual(LocalTarget1.stringified.0, 2) - XCTAssertEqual(LocalTarget1.stringified.1, "1 + 1") - } } diff --git a/fixture/iOS/Local1/Package.swift b/fixture/iOS/Local1/Package.swift index 8607576..ed0413d 100644 --- a/fixture/iOS/Local1/Package.swift +++ b/fixture/iOS/Local1/Package.swift @@ -1,18 +1,10 @@ // swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. -import CompilerPluginSupport import PackageDescription let package = Package( name: "Local1", - /// The host platform the macro and the plugin's tool are built for. Without - /// it SwiftPM builds them for the oldest macOS it supports, and swift-syntax - /// declares a newer one — which is a build failure, and a build failure is a - /// plugin that never ran. - platforms: [ - .macOS(.v10_15), - ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( @@ -21,40 +13,22 @@ let package = Package( .library( name: "LocalLib2", targets: ["LocalTarget2"]), - .executable( - name: "Local1Tool", - targets: ["Local1Tool"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.5.0"), - .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. - .macro( - name: "Local1Macros", - dependencies: [ - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - ]), .target( name: "LocalTarget1", - dependencies: ["RxSwift", "Local1Macros"], - plugins: ["Local1Gen"]), + dependencies: ["RxSwift"]), .target( name: "LocalTarget2", dependencies: ["RxSwift"]), .target( - name: "LocalTarget3", - plugins: ["Local1Gen"]), - .executableTarget( - name: "Local1Tool"), - .plugin( - name: "Local1Gen", - capability: .buildTool(), - dependencies: ["Local1Tool"]), + name: "LocalTarget3"), .testTarget( name: "Local1Tests", dependencies: ["LocalTarget1"]), diff --git a/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift b/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift deleted file mode 100644 index 3a69ac9..0000000 --- a/fixture/iOS/Local1/Plugins/Local1Gen/Plugin.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation -import PackagePlugin - -/// A build tool plugin whose tool writes more than Swift: the sources a target -/// compiles, a header those sources include, and a resource it bundles. -/// -/// The package's own executable target is the tool, the way TbCodeGenerater's is. -@main -struct Local1Gen: BuildToolPlugin { - func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { - let tool = try context.tool(named: "Local1Tool") - let directory = context.pluginWorkDirectory - - /// A C-family target compiles what the plugin writes as C; a Swift one - /// compiles the Swift and bundles the rest. - let kind = target.name == "LocalTarget3" ? "clang" : "swift" - let outputs = kind == "clang" - ? ["LocalTarget3Generated.c", "LocalTarget3Generated.h"] - : ["Local1Generated.swift", "assets/local1-generated.json"] - - return [ - .buildCommand( - displayName: "Generate \(kind) files for \(target.name)", - executable: tool.path, - arguments: ["--kind", kind, "--output", directory.string], - outputFiles: outputs.map { directory.appending($0) }), - ] - } -} diff --git a/fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift b/fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift deleted file mode 100644 index 77e74cf..0000000 --- a/fixture/iOS/Local1/Sources/Local1Macros/Local1Macros.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftCompilerPlugin -import SwiftSyntax -import SwiftSyntaxBuilder -import SwiftSyntaxMacros - -/// `#stringify(1 + 1)` expands to `(1 + 1, "1 + 1")`. -public struct StringifyMacro: ExpressionMacro { - public static func expansion( - of node: some FreestandingMacroExpansionSyntax, - in _: some MacroExpansionContext) throws -> ExprSyntax - { - guard let argument = node.arguments.first?.expression else { - fatalError("#stringify takes one argument") - } - - return "(\(argument), \(literal: argument.description))" - } -} - -@main -struct Local1MacrosPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [StringifyMacro.self] -} diff --git a/fixture/iOS/Local1/Sources/Local1Tool/main.swift b/fixture/iOS/Local1/Sources/Local1Tool/main.swift deleted file mode 100644 index 58dae91..0000000 --- a/fixture/iOS/Local1/Sources/Local1Tool/main.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -/// A tool the package builds, which is what a build tool plugin runs. -/// -/// Without arguments it is just a command line tool, which is what the binary -/// rule for it builds; with them it writes the files the plugin declared as its -/// outputs. -func argument(_ name: String) -> String? { - guard let index = CommandLine.arguments.firstIndex(of: name) else { return nil } - let value = CommandLine.arguments.index(after: index) - return value < CommandLine.arguments.endIndex ? CommandLine.arguments[value] : nil -} - -guard let output = argument("--output"), let kind = argument("--kind") else { - print("Local1Tool") - exit(0) -} - -let directory = URL(fileURLWithPath: output, isDirectory: true) - -func write(_ contents: String, to name: String) throws { - let file = directory.appendingPathComponent(name) - try FileManager.default.createDirectory( - at: file.deletingLastPathComponent(), - withIntermediateDirectories: true) - try contents.write(to: file, atomically: true, encoding: .utf8) -} - -switch kind { -case "clang": - try write( - """ - int local1_plugin_value(void); - """, - to: "LocalTarget3Generated.h") - try write( - """ - #include "LocalTarget3Generated.h" - - int local1_plugin_value(void) { - return 42; - } - """, - to: "LocalTarget3Generated.c") -default: - try write( - """ - /// Written by the Local1Gen build tool plugin. - public enum Local1Generated { - public static let value = 42 - } - """, - to: "Local1Generated.swift") - /// In a directory of its own, because a plugin writes wherever it likes under - /// the output directory and what it wrote has to keep its place. - try write( - """ - {"generatedBy": "Local1Gen"} - """, - to: "assets/local1-generated.json") -} diff --git a/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift b/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift index ec6db63..5146472 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift +++ b/fixture/iOS/Local1/Sources/LocalTarget1/LocalTarget1.swift @@ -1,23 +1,5 @@ -/// Expanded by the package's own macro target. -@freestanding(expression) -public macro stringify(_ value: T) -> (T, String) = #externalMacro( - module: "Local1Macros", - type: "StringifyMacro") - -// MARK: - LocalTarget1 - public struct LocalTarget1 { public private(set) var text = "Hello, World!" public init() { } - - /// `("1 + 1", 2)` without writing either out twice. - public static var stringified: (Int, String) { - #stringify(1 + 1) - } - - /// What the package's build tool plugin generated. - public static var generated: Int { - Local1Generated.value - } } diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h index bd6131e..220e5ae 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h +++ b/fixture/iOS/Local1/Sources/LocalTarget3/include/LocalTarget3.h @@ -12,8 +12,6 @@ NS_ASSUME_NONNULL_BEGIN @interface LocalTarget3 : NSObject + (int) test; - (int) test2; -/// What the package's build tool plugin generated. -- (int) generated; @end NS_ASSUME_NONNULL_END diff --git a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m index 1626353..4e62bd7 100644 --- a/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m +++ b/fixture/iOS/Local1/Sources/LocalTarget3/src/LocalTarget3.m @@ -7,12 +7,6 @@ #import "LocalTarget3.h" -/// Written by the Local1Gen build tool plugin, compiled into this target by the -/// rules bazelize generates: bazelize runs the plugin itself, so this holds on -/// every toolchain rather than only the ones whose `swift build` runs a plugin -/// for a C-family target. -extern int local1_plugin_value(void); - @implementation LocalTarget3 + (int) test { return 1 << 4; @@ -20,7 +14,4 @@ + (int) test { - (int) test2 { return LocalTarget3.test; } -- (int) generated { - return local1_plugin_value(); -} @end From 0f6fa621e9ce3867de9ca799d42733a5b23f16b6 Mon Sep 17 00:00:00 2001 From: yume190 Date: Sun, 20 Sep 2026 17:22:51 +0800 Subject: [PATCH 173/173] Declare the plugins group for every project with packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `//:plugins` is written into the root `BUILD` for any project that has Swift packages — whether one of those packages has a build tool plugin is not known when that file is written — but the `//Packages:plugins` it names was only written when there was a plugin to put in it. A project with packages and no plugins therefore had a label that does not resolve, and `bazel build //...` failed to analyse before it built anything. Rectangle is such a project. The group and the script are written for any project with packages now, empty when there is nothing to run: a command that does nothing beats a workspace that does not load. --- Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift index 3afeec6..b4d88ba 100644 --- a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift @@ -54,9 +54,17 @@ extension SwiftPM.Generator { /// it builds them: a script that called `bazel build` itself would be a /// second Bazel inside the first one's lock. func writePluginRunner(locals: [Path]) throws { + /// The root `BUILD` declares `//:plugins` for any project with packages, + /// because whether one of them has a plugin is not known when that file + /// is written. So both of the things it names are written for any such + /// project: a package with nothing to run is a command that does + /// nothing, and a label that does not resolve is a workspace that does + /// not load. + guard (output + "Package.swift").exists else { return } + let binaries = pluginBinaries - guard !binaries.isEmpty else { return } + try packagesRoot.mkpath() let group = CodeBuilder() group.call( Rules.Builtin.Call.filegroup(