From 4194834414b2bd92c05697785c887ca7f6bbe80b Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 14 Jul 2026 17:40:06 +0200 Subject: [PATCH] feat: Add Swift package traits Preserve optional package traits through project spec parsing, encoding, and Xcode project generation. Update XcodeProj and document the top-level package syntax. --- Docs/ProjectSpec.md | 8 ++ Package.resolved | 4 +- Package.swift | 2 +- Sources/ProjectSpec/Project.swift | 2 +- Sources/ProjectSpec/SpecValidation.swift | 2 +- Sources/ProjectSpec/SwiftPackage.swift | 26 +++++-- Sources/XcodeGenKit/PBXProjGenerator.swift | 8 +- Sources/XcodeGenKit/SchemeGenerator.swift | 2 +- Tests/ProjectSpecTests/SpecLoadingTests.swift | 73 ++++++++++++++++++- .../ProjectGeneratorTests.swift | 10 ++- 10 files changed, 115 insertions(+), 22 deletions(-) diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index 17f6c5b3..d8c42780 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -1256,21 +1256,29 @@ Swift packages are defined at a project level, and then linked to individual tar - `branch: master` - `revision: xxxxxx` - [ ] **github** : **String**- this is an optional helper you can use for github repos. Instead of specifying the full url in `url` you can just specify the github org and repo +- [ ] **traits**: **[String]** - Optional Swift package traits to enable for this package reference. Trait names are written to the Xcode project in the specified order. ### Local Package - [x] **path**: **String** - the path to the package in local. The path must be directory with a `Package.swift`. - [ ] **group** : **String**- Optional path that specifies the location where the package will live in your xcode project. Use `""` to specify the project root. - [ ] **excludeFromProject** : **String**- Optional flag to exclude the package from the generated project (useful if the package is already added via xcworkspace and the project is not intended for standalone use), defaults to `false` +- [ ] **traits**: **[String]** - Optional Swift package traits to enable for this package reference. Use the top-level `packages` mapping because the legacy `localPackages` syntax cannot specify traits. ```yml packages: Yams: url: https://github.com/jpsim/Yams from: 2.0.0 + traits: + - StrictConcurrency Ink: github: JohnSundell/Ink from: 0.5.0 + Sentry: + path: ../.. + traits: + - NoUIFramework RxClient: path: ../RxClient AppFeature: diff --git a/Package.resolved b/Package.resolved index b0833fcb..c8b92c4e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tuist/XcodeProj.git", "state" : { - "revision" : "01bb77000bc8c23a09ea2058f4954612f03cb705", - "version" : "9.10.1" + "revision" : "eb001108f64467b0cc7e8cbf90b3b4aaa1822cbb", + "version" : "9.14.0" } }, { diff --git a/Package.swift b/Package.swift index cded9e42..a6217b4f 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( .package(url: "https://github.com/yonaskolb/JSONUtilities.git", from: "4.2.0"), .package(url: "https://github.com/kylef/Spectre.git", from: "0.9.2"), .package(url: "https://github.com/onevcat/Rainbow.git", from: "4.0.0"), - .package(url: "https://github.com/tuist/XcodeProj.git", exact: "9.10.1"), + .package(url: "https://github.com/tuist/XcodeProj.git", exact: "9.14.0"), .package(url: "https://github.com/jakeheis/SwiftCLI.git", from: "6.0.3"), .package(url: "https://github.com/mxcl/Version", from: "2.0.0"), .package(url: "https://github.com/freddi-kit/ArtifactBundleGen", exact: "0.0.8") diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index e872a27b..d9bd7682 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -204,7 +204,7 @@ extension Project { packages.merge(localPackages.reduce(into: [String: SwiftPackage]()) { // Project name will be obtained by resolved abstractpath's lastComponent for dealing with some path case, like "../" let packageName = (basePath + Path($1).normalize()).lastComponent - $0[packageName] = .local(path: $1, group: nil, excludeFromProject: false) + $0[packageName] = .local(path: $1, group: nil, excludeFromProject: false, traits: nil) } ) } diff --git a/Sources/ProjectSpec/SpecValidation.swift b/Sources/ProjectSpec/SpecValidation.swift index 76ee603f..b548d5fd 100644 --- a/Sources/ProjectSpec/SpecValidation.swift +++ b/Sources/ProjectSpec/SpecValidation.swift @@ -57,7 +57,7 @@ extension Project { } for (name, package) in packages { - if case let .local(path, _, _) = package, !(basePath + Path(path).normalize()).exists { + if case let .local(path, _, _, _) = package, !(basePath + Path(path).normalize()).exists { errors.append(.invalidLocalPackage(name)) } } diff --git a/Sources/ProjectSpec/SwiftPackage.swift b/Sources/ProjectSpec/SwiftPackage.swift index 2ec58f6c..fdb26768 100644 --- a/Sources/ProjectSpec/SwiftPackage.swift +++ b/Sources/ProjectSpec/SwiftPackage.swift @@ -9,8 +9,8 @@ public enum SwiftPackage: Equatable { static let githubPrefix = "https://github.com/" - case remote(url: String, versionRequirement: VersionRequirement) - case local(path: String, group: String?, excludeFromProject: Bool) + case remote(url: String, versionRequirement: VersionRequirement, traits: [String]? = nil) + case local(path: String, group: String?, excludeFromProject: Bool, traits: [String]? = nil) public var isLocal: Bool { if case .local = self { @@ -23,10 +23,17 @@ public enum SwiftPackage: Equatable { extension SwiftPackage: JSONObjectConvertible { public init(jsonDictionary: JSONDictionary) throws { + var traits: [String]? + if jsonDictionary["traits"] != nil { + // Need to assign the trait to a non-optional variable first to resolve method overloading ambiguity + let decodedTraits: [String] = try jsonDictionary.json(atKeyPath: "traits", invalidItemBehaviour: .fail) + traits = decodedTraits + } + if let path: String = jsonDictionary.json(atKeyPath: "path") { let customLocation: String? = jsonDictionary.json(atKeyPath: "group") let excludeFromProject: Bool = jsonDictionary.json(atKeyPath: "excludeFromProject") ?? false - self = .local(path: path, group: customLocation, excludeFromProject: excludeFromProject) + self = .local(path: path, group: customLocation, excludeFromProject: excludeFromProject, traits: traits) } else { let versionRequirement: VersionRequirement = try VersionRequirement(jsonDictionary: jsonDictionary) try Self.validateVersion(versionRequirement: versionRequirement) @@ -37,7 +44,7 @@ extension SwiftPackage: JSONObjectConvertible { } else { url = try jsonDictionary.json(atKeyPath: "url") } - self = .remote(url: url, versionRequirement: versionRequirement) + self = .remote(url: url, versionRequirement: versionRequirement, traits: traits) } } @@ -68,7 +75,7 @@ extension SwiftPackage: JSONEncodable { public func toJSONValue() -> Any { var dictionary: JSONDictionary = [:] switch self { - case .remote(let url, let versionRequirement): + case .remote(let url, let versionRequirement, let traits): if url.hasPrefix(Self.githubPrefix) { dictionary["github"] = url.replacingOccurrences(of: Self.githubPrefix, with: "") } else { @@ -91,11 +98,18 @@ extension SwiftPackage: JSONEncodable { case .revision(let revision): dictionary["revision"] = revision } + + if let traits { + dictionary["traits"] = traits + } return dictionary - case let .local(path, group, excludeFromProject): + case let .local(path, group, excludeFromProject, traits): dictionary["path"] = path dictionary["group"] = group dictionary["excludeFromProject"] = excludeFromProject + if let traits { + dictionary["traits"] = traits + } } return dictionary diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 1f20e850..d561aeaf 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -167,12 +167,12 @@ public class PBXProjGenerator { for (name, package) in project.packages { switch package { - case let .remote(url, versionRequirement): - let packageReference = XCRemoteSwiftPackageReference(repositoryURL: url, versionRequirement: versionRequirement) + case let .remote(url, versionRequirement, traits): + let packageReference = XCRemoteSwiftPackageReference(repositoryURL: url, versionRequirement: versionRequirement, traits: traits) packageReferences[name] = packageReference addObject(packageReference) - case let .local(path, group, excludeFromProject): - let packageReference = XCLocalSwiftPackageReference(relativePath: path) + case let .local(path, group, excludeFromProject, traits): + let packageReference = XCLocalSwiftPackageReference(relativePath: path, traits: traits) localPackageReferences[name] = packageReference if !excludeFromProject { diff --git a/Sources/XcodeGenKit/SchemeGenerator.swift b/Sources/XcodeGenKit/SchemeGenerator.swift index c29faac4..bb5205c2 100644 --- a/Sources/XcodeGenKit/SchemeGenerator.swift +++ b/Sources/XcodeGenKit/SchemeGenerator.swift @@ -173,7 +173,7 @@ public class SchemeGenerator { switch target.location { case .package(let packageName): guard let package = self.project.getPackage(packageName), - case let .local(path, _, _) = package else { + case let .local(path, _, _, _) = package else { throw SchemeGenerationError.missingPackage(packageName) } return XCScheme.BuildableReference( diff --git a/Tests/ProjectSpecTests/SpecLoadingTests.swift b/Tests/ProjectSpecTests/SpecLoadingTests.swift index d3db0d9c..d45ce83d 100644 --- a/Tests/ProjectSpecTests/SpecLoadingTests.swift +++ b/Tests/ProjectSpecTests/SpecLoadingTests.swift @@ -1485,7 +1485,7 @@ class SpecLoadingTests: XCTestCase { $0.it("parses packages") { let project = Project(name: "spm", packages: [ - "package1": .remote(url: "package.git", versionRequirement: .exact("1.2.2")), + "package1": .remote(url: "package.git", versionRequirement: .exact("1.2.2"), traits: ["FeatureA", "FeatureB"]), "package2": .remote(url: "package.git", versionRequirement: .upToNextMajorVersion("1.2.2")), "package3": .remote(url: "package.git", versionRequirement: .upToNextMinorVersion("1.2.2")), "package4": .remote(url: "package.git", versionRequirement: .branch("master")), @@ -1493,10 +1493,11 @@ class SpecLoadingTests: XCTestCase { "package6": .remote(url: "package.git", versionRequirement: .range(from: "1.2.0", to: "1.2.5")), "package7": .remote(url: "package.git", versionRequirement: .exact("1.2.2")), "package8": .remote(url: "package.git", versionRequirement: .upToNextMajorVersion("4.0.0-beta.5")), - "package9": .local(path: "package/package", group: nil, excludeFromProject: false), + "package9": .local(path: "package/package", group: nil, excludeFromProject: false, traits: ["NoUIFramework", "Networking"]), "package10": .remote(url: "https://github.com/yonaskolb/XcodeGen", versionRequirement: .exact("1.2.2")), "XcodeGen": .local(path: "../XcodeGen", group: nil, excludeFromProject: false), "package11": .local(path: "../XcodeGen", group: "Packages/Feature", excludeFromProject: false), + "package12": .remote(url: "empty-traits.git", versionRequirement: .exact("1.0.0"), traits: []), ], options: .init(localPackagesGroup: "MyPackages")) let dictionary: [String: Any] = [ @@ -1505,7 +1506,7 @@ class SpecLoadingTests: XCTestCase { "localPackagesGroup": "MyPackages", ], "packages": [ - "package1": ["url": "package.git", "exactVersion": "1.2.2"], + "package1": ["url": "package.git", "exactVersion": "1.2.2", "traits": ["FeatureA", "FeatureB"]], "package2": ["url": "package.git", "majorVersion": "1.2.2"], "package3": ["url": "package.git", "minorVersion": "1.2.2"], "package4": ["url": "package.git", "branch": "master"], @@ -1513,9 +1514,10 @@ class SpecLoadingTests: XCTestCase { "package6": ["url": "package.git", "minVersion": "1.2.0", "maxVersion": "1.2.5"], "package7": ["url": "package.git", "version": "1.2.2"], "package8": ["url": "package.git", "majorVersion": "4.0.0-beta.5"], - "package9": ["path": "package/package"], + "package9": ["path": "package/package", "traits": ["NoUIFramework", "Networking"]], "package10": ["github": "yonaskolb/XcodeGen", "exactVersion": "1.2.2"], "package11": ["path": "../XcodeGen", "group": "Packages/Feature"], + "package12": ["url": "empty-traits.git", "exactVersion": "1.0.0", "traits": [String]()], ], "localPackages": ["../XcodeGen"], ] @@ -1523,6 +1525,69 @@ class SpecLoadingTests: XCTestCase { try expect(parsedSpec) == project } + $0.it("encodes package traits while preserving omitted, empty, and non-empty values") { + func jsonDictionary(for package: SwiftPackage) throws -> [String: Any] { + try unwrap(package.toJSONValue() as? [String: Any]) + } + + let remoteWithoutTraits = try jsonDictionary(for: .remote( + url: "package.git", + versionRequirement: .exact("1.0.0") + )) + let remoteWithEmptyTraits = try jsonDictionary(for: .remote( + url: "package.git", + versionRequirement: .exact("1.0.0"), + traits: [] + )) + let remoteWithTraits = try jsonDictionary(for: .remote( + url: "package.git", + versionRequirement: .exact("1.0.0"), + traits: ["FeatureA", "FeatureB"] + )) + let localWithoutTraits = try jsonDictionary(for: .local( + path: "../Package", + group: nil, + excludeFromProject: false + )) + let localWithEmptyTraits = try jsonDictionary(for: .local( + path: "../Package", + group: nil, + excludeFromProject: false, + traits: [] + )) + let localWithTraits = try jsonDictionary(for: .local( + path: "../Package", + group: nil, + excludeFromProject: false, + traits: ["NoUIFramework", "Networking"] + )) + + XCTAssertFalse(remoteWithoutTraits.keys.contains("traits")) + XCTAssertEqual(remoteWithEmptyTraits["traits"] as? [String], []) + XCTAssertEqual(remoteWithTraits["traits"] as? [String], ["FeatureA", "FeatureB"]) + XCTAssertFalse(localWithoutTraits.keys.contains("traits")) + XCTAssertEqual(localWithEmptyTraits["traits"] as? [String], []) + XCTAssertEqual(localWithTraits["traits"] as? [String], ["NoUIFramework", "Networking"]) + } + + $0.it("rejects invalid package traits") { + let invalidPackages: [[String: Any]] = [ + [ + "url": "package.git", + "exactVersion": "1.0.0", + "traits": "FeatureA", + ], + [ + "path": "../Package", + "traits": ["FeatureA", 1], + ], + ] + + for dictionary in invalidPackages { + try expect(expression: { _ = try SwiftPackage(jsonDictionary: dictionary) }).toThrow() + } + } + $0.it("parses old local package format") { let project = Project(name: "spm", packages: [ "XcodeGen": .local(path: "../XcodeGen", group: nil, excludeFromProject: false), diff --git a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift index e3827473..4811f278 100644 --- a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift @@ -1565,9 +1565,9 @@ class ProjectGeneratorTests: XCTestCase { ) let project = Project(name: "test", targets: [app], packages: [ - "XcodeGen": .remote(url: "http://github.com/yonaskolb/XcodeGen", versionRequirement: .branch("master")), + "XcodeGen": .remote(url: "http://github.com/yonaskolb/XcodeGen", versionRequirement: .branch("master"), traits: ["FeatureA", "FeatureB"]), "Codability": .remote(url: "http://github.com/yonaskolb/Codability", versionRequirement: .exact("1.0.0")), - "Yams": .local(path: "../Yams", group: nil, excludeFromProject: false), + "Yams": .local(path: "../Yams", group: nil, excludeFromProject: false, traits: ["NoUIFramework", "Networking"]), ], options: .init(localPackagesGroup: "MyPackages")) let pbxProject = try project.generatePbxProj(specValidate: false) @@ -1577,17 +1577,22 @@ class ProjectGeneratorTests: XCTestCase { try expect(projectSpecDependency.package?.name) == "XcodeGen" try expect(projectSpecDependency.package?.versionRequirement) == .branch("master") + try expect(projectSpecDependency.package?.traits) == ["FeatureA", "FeatureB"] let codabilityDependency = try unwrap(nativeTarget.packageProductDependencies?.first(where: { $0.productName == "Codability" })) try expect(codabilityDependency.package?.name) == "Codability" try expect(codabilityDependency.package?.versionRequirement) == .exact("1.0.0") + try expect(codabilityDependency.package?.traits).beNil() let localPackagesGroup = try unwrap(try pbxProject.getMainGroup().children.first(where: { $0.name == "MyPackages" }) as? PBXGroup) let yamsLocalPackageFile = try unwrap(pbxProject.fileReferences.first(where: { $0.path == "../Yams" })) try expect(localPackagesGroup.children.contains(yamsLocalPackageFile)) == true try expect(yamsLocalPackageFile.lastKnownFileType) == "folder" + + let yamsPackageReference = try unwrap(pbxProject.rootObject?.localPackages.first(where: { $0.relativePath == "../Yams" })) + try expect(yamsPackageReference.traits) == ["NoUIFramework", "Networking"] } $0.it("generates local swift packages") { @@ -1610,6 +1615,7 @@ class ProjectGeneratorTests: XCTestCase { let localPackageReference = try unwrap(pbxProject.rootObject?.localPackages.first) try expect(pbxProject.rootObject?.localPackages.count) == 1 try expect(localPackageReference.relativePath) == "../XcodeGen" + try expect(localPackageReference.traits).beNil() let frameworkPhases = nativeTarget.buildPhases.compactMap { $0 as? PBXFrameworksBuildPhase }