From c0f5b32b36799ca05df583e32fb2a419fa3a5e3c Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Fri, 17 Apr 2026 12:25:18 +0200 Subject: [PATCH 1/8] Implement targetOrdering option --- CHANGELOG.md | 3 ++ Docs/ProjectSpec.md | 1 + Sources/ProjectSpec/SpecOptions.swift | 7 +++ Sources/ProjectSpec/SpecValidation.swift | 13 +++++ Sources/ProjectSpec/SpecValidationError.swift | 6 +++ Sources/XcodeGenKit/PBXProjGenerator.swift | 11 +++- Tests/ProjectSpecTests/ProjectSpecTests.swift | 26 ++++++++++ Tests/ProjectSpecTests/SpecLoadingTests.swift | 15 ++++++ .../PBXProjGeneratorTests.swift | 50 ++++++++++++++++++- 9 files changed, 129 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f876da2e..dd8c138e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Next Version +### Added +- Added `targetOrdering` option to control the order of targets in the generated project (sidebar in Xcode, `xcodebuild -list` output). Unlisted targets fall back to the existing alphabetical order, so behavior is unchanged when the option is not set. @mirkokg + ## 2.45.4 ### Fixed diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index 17f6c5b3..49569716 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -151,6 +151,7 @@ Note that target names can also be changed by adding a `name` property to a targ - `top` - at the top, before files - `bottom` (default) - at the bottom, after other files - [ ] **groupOrdering**: **[[GroupOrdering]](#groupOrdering)** - An order of groups. +- [ ] **targetOrdering**: **[String]** - An ordered list of target names. Listed targets appear in this order in the generated project (sidebar in Xcode, `xcodebuild -list` output); unlisted targets follow, sorted alphabetically. If a name in this list doesn't match any target or aggregate target, a validation error is raised. Defaults to an empty list, in which case all targets are sorted alphabetically. - [ ] **transitivelyLinkDependencies**: **Bool** - If this is `true` then targets will link to the dependencies of their target dependencies. If a target should embed its dependencies, such as application and test bundles, it will embed these transitive dependencies as well. Some complex setups might want to set this to `false` and explicitly specify dependencies at every level. Targets can override this with [Target](#target).transitivelyLinkDependencies. Defaults to `false`. - [ ] **generateEmptyDirectories**: **Bool** - If this is `true` then empty directories will be added to project too else will be missed. Defaults to `false`. - [ ] **findCarthageFrameworks**: **Bool** - When this is set to `true`, all the individual frameworks for Carthage framework dependencies will automatically be found. This property can be overridden individually for each carthage dependency - for more details see See **findFrameworks** in the [Dependency](#dependency) section. Defaults to `false`. diff --git a/Sources/ProjectSpec/SpecOptions.swift b/Sources/ProjectSpec/SpecOptions.swift index 07582d42..a3557d13 100644 --- a/Sources/ProjectSpec/SpecOptions.swift +++ b/Sources/ProjectSpec/SpecOptions.swift @@ -30,6 +30,7 @@ public struct SpecOptions: Equatable { public var transitivelyLinkDependencies: Bool public var groupSortPosition: GroupSortPosition public var groupOrdering: [GroupOrdering] + public var targetOrdering: [String] public var fileTypes: [String: FileType] public var generateEmptyDirectories: Bool public var findCarthageFrameworks: Bool @@ -96,6 +97,7 @@ public struct SpecOptions: Equatable { transitivelyLinkDependencies: Bool = transitivelyLinkDependenciesDefault, groupSortPosition: GroupSortPosition = groupSortPositionDefault, groupOrdering: [GroupOrdering] = [], + targetOrdering: [String] = [], fileTypes: [String: FileType] = [:], generateEmptyDirectories: Bool = generateEmptyDirectoriesDefault, findCarthageFrameworks: Bool = findCarthageFrameworksDefault, @@ -124,6 +126,7 @@ public struct SpecOptions: Equatable { self.transitivelyLinkDependencies = transitivelyLinkDependencies self.groupSortPosition = groupSortPosition self.groupOrdering = groupOrdering + self.targetOrdering = targetOrdering self.fileTypes = fileTypes self.generateEmptyDirectories = generateEmptyDirectories self.findCarthageFrameworks = findCarthageFrameworks @@ -160,6 +163,7 @@ extension SpecOptions: JSONObjectConvertible { transitivelyLinkDependencies = jsonDictionary.json(atKeyPath: "transitivelyLinkDependencies") ?? SpecOptions.transitivelyLinkDependenciesDefault groupSortPosition = jsonDictionary.json(atKeyPath: "groupSortPosition") ?? SpecOptions.groupSortPositionDefault groupOrdering = jsonDictionary.json(atKeyPath: "groupOrdering") ?? [] + targetOrdering = jsonDictionary.json(atKeyPath: "targetOrdering") ?? [] generateEmptyDirectories = jsonDictionary.json(atKeyPath: "generateEmptyDirectories") ?? SpecOptions.generateEmptyDirectoriesDefault findCarthageFrameworks = jsonDictionary.json(atKeyPath: "findCarthageFrameworks") ?? SpecOptions.findCarthageFrameworksDefault localPackagesGroup = jsonDictionary.json(atKeyPath: "localPackagesGroup") @@ -218,6 +222,9 @@ extension SpecOptions: JSONEncodable { if schemePathPrefix != SpecOptions.schemePathPrefixDefault { dict["schemePathPrefix"] = schemePathPrefix } + if !targetOrdering.isEmpty { + dict["targetOrdering"] = targetOrdering + } return dict } diff --git a/Sources/ProjectSpec/SpecValidation.swift b/Sources/ProjectSpec/SpecValidation.swift index 76ee603f..1aa7a96b 100644 --- a/Sources/ProjectSpec/SpecValidation.swift +++ b/Sources/ProjectSpec/SpecValidation.swift @@ -77,6 +77,19 @@ extension Project { } } + if !options.targetOrdering.isEmpty { + let knownTargetNames = Set(projectTargets.map { $0.name }) + var seen = Set() + for name in options.targetOrdering { + if !seen.insert(name).inserted { + errors.append(.duplicateTargetOrderingEntry(name)) + } + if !knownTargetNames.contains(name) { + errors.append(.invalidTargetOrderingReference(name)) + } + } + } + for settings in settingGroups.values { errors += validateSettings(settings) } diff --git a/Sources/ProjectSpec/SpecValidationError.swift b/Sources/ProjectSpec/SpecValidationError.swift index 85fd07e2..39e13c3d 100644 --- a/Sources/ProjectSpec/SpecValidationError.swift +++ b/Sources/ProjectSpec/SpecValidationError.swift @@ -43,6 +43,8 @@ public struct SpecValidationError: Error, CustomStringConvertible { case duplicateDependencies(target: String, dependencyReference: String) case invalidPluginPackageReference(plugin: String, package: String) case emptySourcePath(target: String) + case invalidTargetOrderingReference(String) + case duplicateTargetOrderingEntry(String) public var description: String { switch self { @@ -112,6 +114,10 @@ public struct SpecValidationError: Error, CustomStringConvertible { return "Plugin \(plugin) has invalid package reference \(package)" case let .emptySourcePath(target): return "Target \(target.quoted) has an empty source path entry" + case let .invalidTargetOrderingReference(name): + return "Target ordering includes \(name.quoted) which is not a known target or aggregate target" + case let .duplicateTargetOrderingEntry(name): + return "Target ordering contains \(name.quoted) more than once" } } } diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 1f20e850..b41a3dd7 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -323,8 +323,15 @@ public class PBXProjGenerator { pbxProject.localPackages = localPackageReferences.sorted { $0.key < $1.key }.map { $1 } let allTargets: [PBXTarget] = targetObjects.valueArray + targetAggregateObjects.valueArray - pbxProject.targets = allTargets - .sorted { $0.name < $1.name } + let targetOrdering = project.options.targetOrdering + pbxProject.targets = allTargets.sorted { lhs, rhs in + let lhsIndex = targetOrdering.firstIndex(of: lhs.name) ?? Int.max + let rhsIndex = targetOrdering.firstIndex(of: rhs.name) ?? Int.max + if lhsIndex != rhsIndex { + return lhsIndex < rhsIndex + } + return lhs.name < rhs.name + } pbxProject.attributes = projectAttributes pbxProject.targetAttributes = generateTargetAttributes() return pbxProj diff --git a/Tests/ProjectSpecTests/ProjectSpecTests.swift b/Tests/ProjectSpecTests/ProjectSpecTests.swift index c308df96..237a6234 100644 --- a/Tests/ProjectSpecTests/ProjectSpecTests.swift +++ b/Tests/ProjectSpecTests/ProjectSpecTests.swift @@ -153,6 +153,32 @@ class ProjectSpecTests: XCTestCase { try expectValidationError(project, .invalidBuildSettingConfig("invalidSettingGroupConfig")) } + $0.it("fails with unknown target in targetOrdering") { + var project = baseProject + project.targets = [Target(name: "App", type: .application, platform: .iOS)] + project.options = SpecOptions(targetOrdering: ["App", "Ghost"]) + try expectValidationError(project, .invalidTargetOrderingReference("Ghost")) + } + + $0.it("fails with duplicate entry in targetOrdering") { + var project = baseProject + project.targets = [ + Target(name: "App", type: .application, platform: .iOS), + Target(name: "Tests", type: .unitTestBundle, platform: .iOS), + ] + project.options = SpecOptions(targetOrdering: ["App", "Tests", "App"]) + try expectValidationError(project, .duplicateTargetOrderingEntry("App")) + } + + $0.it("accepts a valid targetOrdering referencing native and aggregate targets") { + var project = baseProject + project.targets = [Target(name: "App", type: .application, platform: .iOS)] + project.aggregateTargets = [AggregateTarget(name: "Agg", targets: ["App"])] + project.options = SpecOptions(targetOrdering: ["Agg", "App"]) + try expectNoValidationError(project, .invalidTargetOrderingReference("Agg")) + try expectNoValidationError(project, .invalidTargetOrderingReference("App")) + } + $0.it("fails with duplicate dependencies") { var project = baseProject project.targets = [ diff --git a/Tests/ProjectSpecTests/SpecLoadingTests.swift b/Tests/ProjectSpecTests/SpecLoadingTests.swift index d3db0d9c..d2d3ab69 100644 --- a/Tests/ProjectSpecTests/SpecLoadingTests.swift +++ b/Tests/ProjectSpecTests/SpecLoadingTests.swift @@ -1483,6 +1483,21 @@ class SpecLoadingTests: XCTestCase { try expect(parsedSpec) == expected } + $0.it("parses targetOrdering option") { + let options = SpecOptions(targetOrdering: ["App", "Tests"]) + let expected = Project(name: "test", options: options) + let dictionary: [String: Any] = ["options": [ + "targetOrdering": ["App", "Tests"], + ]] + let parsedSpec = try getProjectSpec(dictionary) + try expect(parsedSpec) == expected + } + + $0.it("defaults targetOrdering to empty when not specified") { + let parsedSpec = try getProjectSpec(["options": [String: Any]()]) + try expect(parsedSpec.options.targetOrdering) == [] + } + $0.it("parses packages") { let project = Project(name: "spm", packages: [ "package1": .remote(url: "package.git", versionRequirement: .exact("1.2.2")), diff --git a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift index 842cb86c..6788444d 100644 --- a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift @@ -396,7 +396,55 @@ class PBXProjGeneratorTests: XCTestCase { } } } - + + func testTargetOrdering() { + describe { + $0.it("honors targetOrdering in the generated pbxproj") { + var options = SpecOptions() + options.targetOrdering = ["C", "A"] + + let a = Target(name: "A", type: .framework, platform: .iOS) + let b = Target(name: "B", type: .framework, platform: .iOS) + let c = Target(name: "C", type: .framework, platform: .iOS) + let project = Project(name: "Test", targets: [a, b, c], options: options) + + let pbxProj = try project.generatePbxProj() + let targetNames = pbxProj.projects.first?.targets.map { $0.name } + try expect(targetNames) == ["C", "A", "B"] + } + + $0.it("falls back to alphabetical when targetOrdering is empty") { + let a = Target(name: "A", type: .framework, platform: .iOS) + let b = Target(name: "B", type: .framework, platform: .iOS) + let c = Target(name: "C", type: .framework, platform: .iOS) + let project = Project(name: "Test", targets: [a, b, c]) + + let pbxProj = try project.generatePbxProj() + let targetNames = pbxProj.projects.first?.targets.map { $0.name } + try expect(targetNames) == ["A", "B", "C"] + } + + $0.it("orders aggregate targets alongside native targets") { + var options = SpecOptions() + options.targetOrdering = ["Agg", "App"] + + let app = Target(name: "App", type: .application, platform: .iOS) + let framework = Target(name: "Framework", type: .framework, platform: .iOS) + let aggregate = AggregateTarget(name: "Agg", targets: ["App"]) + let project = Project( + name: "Test", + targets: [app, framework], + aggregateTargets: [aggregate], + options: options + ) + + let pbxProj = try project.generatePbxProj() + let targetNames = pbxProj.projects.first?.targets.map { $0.name } + try expect(targetNames) == ["Agg", "App", "Framework"] + } + } + } + func testDefaultLastUpgradeCheckWhenUserDidSpecifyInvalidValue() throws { let lastUpgradeKey = "LastUpgradeCheck" let attributes: [String: Any] = [lastUpgradeKey: 1234] From 5f3fea4b6c00d0e2fea4c911f98fffb98bbe281e Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Thu, 23 Apr 2026 12:55:51 +0200 Subject: [PATCH 2/8] Preserve target order --- CHANGELOG.md | 4 +- Docs/ProjectSpec.md | 1 - Sources/ProjectSpec/Project.swift | 40 +++++++- Sources/ProjectSpec/SpecFile.swift | 47 ++++++++- Sources/ProjectSpec/SpecLoader.swift | 6 +- Sources/ProjectSpec/SpecOptions.swift | 7 -- Sources/ProjectSpec/SpecValidation.swift | 13 --- Sources/ProjectSpec/SpecValidationError.swift | 6 -- Sources/ProjectSpec/Yaml.swift | 13 +++ Sources/XcodeGenKit/PBXProjGenerator.swift | 13 +-- .../Project.xcodeproj/project.pbxproj | 2 +- .../SPM/SPM.xcodeproj/project.pbxproj | 4 +- .../Project.xcodeproj/project.pbxproj | 64 ++++++------- Tests/Fixtures/target_ordering_test.yml | 14 +++ Tests/ProjectSpecTests/ProjectSpecTests.swift | 26 ----- Tests/ProjectSpecTests/SpecLoadingTests.swift | 96 +++++++++---------- .../PBXProjGeneratorTests.swift | 38 ++++---- 17 files changed, 218 insertions(+), 176 deletions(-) create mode 100644 Tests/Fixtures/target_ordering_test.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8c138e..21c45b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## Next Version -### Added -- Added `targetOrdering` option to control the order of targets in the generated project (sidebar in Xcode, `xcodebuild -list` output). Unlisted targets fall back to the existing alphabetical order, so behavior is unchanged when the option is not set. @mirkokg +### Changed +- Targets in the generated project now follow the declaration order from the YAML spec (Xcode sidebar, `xcodebuild -list` output). Previously they were always sorted alphabetically. JSON specs and projects built programmatically continue to sort alphabetically, since key order isn't recoverable. @mirkokg ## 2.45.4 diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index 49569716..17f6c5b3 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -151,7 +151,6 @@ Note that target names can also be changed by adding a `name` property to a targ - `top` - at the top, before files - `bottom` (default) - at the bottom, after other files - [ ] **groupOrdering**: **[[GroupOrdering]](#groupOrdering)** - An order of groups. -- [ ] **targetOrdering**: **[String]** - An ordered list of target names. Listed targets appear in this order in the generated project (sidebar in Xcode, `xcodebuild -list` output); unlisted targets follow, sorted alphabetically. If a name in this list doesn't match any target or aggregate target, a validation error is raised. Defaults to an empty list, in which case all targets are sorted alphabetically. - [ ] **transitivelyLinkDependencies**: **Bool** - If this is `true` then targets will link to the dependencies of their target dependencies. If a target should embed its dependencies, such as application and test bundles, it will embed these transitive dependencies as well. Some complex setups might want to set this to `false` and explicitly specify dependencies at every level. Targets can override this with [Target](#target).transitivelyLinkDependencies. Defaults to `false`. - [ ] **generateEmptyDirectories**: **Bool** - If this is `true` then empty directories will be added to project too else will be missed. Defaults to `false`. - [ ] **findCarthageFrameworks**: **Bool** - When this is set to `true`, all the individual frameworks for Carthage framework dependencies will automatically be found. This property can be overridden individually for each carthage dependency - for more details see See **findFrameworks** in the [Dependency](#dependency) section. Defaults to `false`. diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index e872a27b..70feb051 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -164,12 +164,19 @@ extension Project { } public init(spec: SpecFile) throws { - try self.init(basePath: spec.basePath, jsonDictionary: spec.resolvedDictionary()) + try self.init( + basePath: spec.basePath, + jsonDictionary: spec.resolvedDictionary(), + targetDeclarationOrder: spec.resolvedTargetDeclarationOrder() + ) } - public init(basePath: Path = "", jsonDictionary: JSONDictionary) throws { + public init(basePath: Path = "", jsonDictionary: JSONDictionary, targetDeclarationOrder: [String] = []) throws { self.basePath = basePath + let rawTargets = jsonDictionary["targets"] as? JSONDictionary ?? [:] + let expandedTargetOrder = Project.expandedTargetOrder(targetDeclarationOrder, rawTargets: rawTargets) + let jsonDictionary = Project.resolveProject(jsonDictionary: jsonDictionary) let buildSettingsParser = BuildSettingsParser(jsonDictionary: jsonDictionary) @@ -181,7 +188,16 @@ extension Project { let configs: [String: String] = jsonDictionary.json(atKeyPath: "configs") ?? [:] self.configs = configs.isEmpty ? Config.defaultConfigs : configs.map { Config(name: $0, type: ConfigType(rawValue: $1)) }.sorted { $0.name < $1.name } - targets = try jsonDictionary.json(atKeyPath: "targets", parallel: true).sorted { $0.name < $1.name } + let parsedTargets: [Target] = try jsonDictionary.json(atKeyPath: "targets", parallel: true) + let targetOrderIndex = Dictionary(uniqueKeysWithValues: expandedTargetOrder.enumerated().map { ($1, $0) }) + targets = parsedTargets.sorted { lhs, rhs in + let lhsIndex = targetOrderIndex[lhs.name] ?? Int.max + let rhsIndex = targetOrderIndex[rhs.name] ?? Int.max + if lhsIndex != rhsIndex { + return lhsIndex < rhsIndex + } + return lhs.name < rhs.name + } aggregateTargets = try jsonDictionary.json(atKeyPath: "aggregateTargets").sorted { $0.name < $1.name } projectReferences = try jsonDictionary.json(atKeyPath: "projectReferences").sorted { $0.name < $1.name } schemes = try jsonDictionary.json(atKeyPath: "schemes") @@ -218,6 +234,24 @@ extension Project { projectReferencesMap = Dictionary(uniqueKeysWithValues: projectReferences.map { ($0.name, $0) }) } + static func expandedTargetOrder(_ declarationOrder: [String], rawTargets: JSONDictionary) -> [String] { + var result: [String] = [] + for key in declarationOrder { + guard let target = rawTargets[key] as? JSONDictionary else { continue } + if let platforms = target["platform"] as? [String] { + for platform in platforms { + let prefix = target["platformPrefix"] as? String ?? "" + let suffix = target["platformSuffix"] as? String ?? "_\(platform)" + result.append(prefix + key + suffix) + } + } else { + let resolvedName = target["name"] as? String ?? key + result.append(resolvedName) + } + } + return result + } + static func resolveProject(jsonDictionary: JSONDictionary) -> JSONDictionary { var jsonDictionary = jsonDictionary diff --git a/Sources/ProjectSpec/SpecFile.swift b/Sources/ProjectSpec/SpecFile.swift index 18ea1562..98978277 100644 --- a/Sources/ProjectSpec/SpecFile.swift +++ b/Sources/ProjectSpec/SpecFile.swift @@ -9,6 +9,7 @@ public struct SpecFile { public let basePath: Path public let jsonDictionary: JSONDictionary public let subSpecs: [SpecFile] + public let targetDeclarationOrder: [String] /// The relative path to use when resolving paths in the json dictionary. Is an empty path when /// included with relativePaths disabled. @@ -68,12 +69,13 @@ public struct SpecFile { } /// Memberwise initializer for SpecFile - public init(filePath: Path, jsonDictionary: JSONDictionary, basePath: Path = "", relativePath: Path = "", subSpecs: [SpecFile] = []) { + public init(filePath: Path, jsonDictionary: JSONDictionary, basePath: Path = "", relativePath: Path = "", subSpecs: [SpecFile] = [], targetDeclarationOrder: [String] = []) { self.basePath = basePath self.relativePath = relativePath self.jsonDictionary = jsonDictionary self.subSpecs = subSpecs self.filePath = filePath + self.targetDeclarationOrder = targetDeclarationOrder } private init(include: Include, basePath: Path, relativePath: Path, cachedSpecFiles: inout [Path: SpecFile], variables: [String: String]) throws { @@ -91,6 +93,7 @@ public struct SpecFile { } let jsonDictionary = try SpecFile.loadDictionary(path: path).expand(variables: variables) + let targetDeclarationOrder = try SpecFile.loadTargetDeclarationOrder(path: path) let includes = Include.parse(json: jsonDictionary["include"]) let subSpecs: [SpecFile] = try includes @@ -99,10 +102,17 @@ public struct SpecFile { return try SpecFile(include: include, basePath: basePath, relativePath: relativePath, cachedSpecFiles: &cachedSpecFiles, variables: variables) } - self.init(filePath: filePath, jsonDictionary: jsonDictionary, basePath: basePath, relativePath: relativePath, subSpecs: subSpecs) + self.init(filePath: filePath, jsonDictionary: jsonDictionary, basePath: basePath, relativePath: relativePath, subSpecs: subSpecs, targetDeclarationOrder: targetDeclarationOrder) cachedSpecFiles[path] = self } + static func loadTargetDeclarationOrder(path: Path) throws -> [String] { + if path.extension?.lowercased() == "json" { + return [] + } + return try loadOrderedTargetNames(path: path) + } + static func loadDictionary(path: Path) throws -> JSONDictionary { // Depending on the extension we will either load the file as YAML or JSON if path.extension?.lowercased() == "json" { @@ -121,6 +131,14 @@ public struct SpecFile { resolvedDictionaryWithUniqueTargets() } + public func resolvedTargetDeclarationOrder() -> [String] { + var cachedSpecFiles: [Path: SpecFile] = [:] + let resolvedSpec = resolvingPaths(cachedSpecFiles: &cachedSpecFiles) + + var mergedSpecPaths = Set() + return resolvedSpec.mergedTargetDeclarationOrder(set: &mergedSpecPaths) + } + private func resolvedDictionaryWithUniqueTargets() -> JSONDictionary { var cachedSpecFiles: [Path: SpecFile] = [:] let resolvedSpec = resolvingPaths(cachedSpecFiles: &cachedSpecFiles) @@ -140,6 +158,28 @@ public struct SpecFile { .reduce([:]) { $1.merged(onto: $0) }) } + private func mergedTargetDeclarationOrder(set mergedSpecPaths: inout Set) -> [String] { + let path = basePath + filePath + + guard mergedSpecPaths.insert(path).inserted else { return [] } + + var order: [String] = [] + var seen = Set() + for subSpec in subSpecs { + for name in subSpec.mergedTargetDeclarationOrder(set: &mergedSpecPaths) { + if seen.insert(name).inserted { + order.append(name) + } + } + } + for name in targetDeclarationOrder { + if seen.insert(name).inserted { + order.append(name) + } + } + return order + } + private func resolvingPaths(cachedSpecFiles: inout [Path: SpecFile], relativeTo basePath: Path = Path()) -> SpecFile { let path = basePath + filePath if let cachedSpecFile = cachedSpecFiles[path] { @@ -157,7 +197,8 @@ public struct SpecFile { jsonDictionary: jsonDictionary, basePath: self.basePath, relativePath: self.relativePath, - subSpecs: subSpecs.map { $0.resolvingPaths(cachedSpecFiles: &cachedSpecFiles, relativeTo: relativePath) } + subSpecs: subSpecs.map { $0.resolvingPaths(cachedSpecFiles: &cachedSpecFiles, relativeTo: relativePath) }, + targetDeclarationOrder: targetDeclarationOrder ) cachedSpecFiles[path] = specFile return specFile diff --git a/Sources/ProjectSpec/SpecLoader.swift b/Sources/ProjectSpec/SpecLoader.swift index fe93e840..56c31a5c 100644 --- a/Sources/ProjectSpec/SpecLoader.swift +++ b/Sources/ProjectSpec/SpecLoader.swift @@ -19,7 +19,11 @@ public class SpecLoader { let projectRoot = projectRoot?.absolute() let spec = try SpecFile(path: path, projectRoot: projectRoot, variables: variables) let resolvedDictionary = spec.resolvedDictionary() - let project = try Project(basePath: projectRoot ?? spec.basePath, jsonDictionary: resolvedDictionary) + let project = try Project( + basePath: projectRoot ?? spec.basePath, + jsonDictionary: resolvedDictionary, + targetDeclarationOrder: spec.resolvedTargetDeclarationOrder() + ) self.project = project projectDictionary = resolvedDictionary diff --git a/Sources/ProjectSpec/SpecOptions.swift b/Sources/ProjectSpec/SpecOptions.swift index a3557d13..07582d42 100644 --- a/Sources/ProjectSpec/SpecOptions.swift +++ b/Sources/ProjectSpec/SpecOptions.swift @@ -30,7 +30,6 @@ public struct SpecOptions: Equatable { public var transitivelyLinkDependencies: Bool public var groupSortPosition: GroupSortPosition public var groupOrdering: [GroupOrdering] - public var targetOrdering: [String] public var fileTypes: [String: FileType] public var generateEmptyDirectories: Bool public var findCarthageFrameworks: Bool @@ -97,7 +96,6 @@ public struct SpecOptions: Equatable { transitivelyLinkDependencies: Bool = transitivelyLinkDependenciesDefault, groupSortPosition: GroupSortPosition = groupSortPositionDefault, groupOrdering: [GroupOrdering] = [], - targetOrdering: [String] = [], fileTypes: [String: FileType] = [:], generateEmptyDirectories: Bool = generateEmptyDirectoriesDefault, findCarthageFrameworks: Bool = findCarthageFrameworksDefault, @@ -126,7 +124,6 @@ public struct SpecOptions: Equatable { self.transitivelyLinkDependencies = transitivelyLinkDependencies self.groupSortPosition = groupSortPosition self.groupOrdering = groupOrdering - self.targetOrdering = targetOrdering self.fileTypes = fileTypes self.generateEmptyDirectories = generateEmptyDirectories self.findCarthageFrameworks = findCarthageFrameworks @@ -163,7 +160,6 @@ extension SpecOptions: JSONObjectConvertible { transitivelyLinkDependencies = jsonDictionary.json(atKeyPath: "transitivelyLinkDependencies") ?? SpecOptions.transitivelyLinkDependenciesDefault groupSortPosition = jsonDictionary.json(atKeyPath: "groupSortPosition") ?? SpecOptions.groupSortPositionDefault groupOrdering = jsonDictionary.json(atKeyPath: "groupOrdering") ?? [] - targetOrdering = jsonDictionary.json(atKeyPath: "targetOrdering") ?? [] generateEmptyDirectories = jsonDictionary.json(atKeyPath: "generateEmptyDirectories") ?? SpecOptions.generateEmptyDirectoriesDefault findCarthageFrameworks = jsonDictionary.json(atKeyPath: "findCarthageFrameworks") ?? SpecOptions.findCarthageFrameworksDefault localPackagesGroup = jsonDictionary.json(atKeyPath: "localPackagesGroup") @@ -222,9 +218,6 @@ extension SpecOptions: JSONEncodable { if schemePathPrefix != SpecOptions.schemePathPrefixDefault { dict["schemePathPrefix"] = schemePathPrefix } - if !targetOrdering.isEmpty { - dict["targetOrdering"] = targetOrdering - } return dict } diff --git a/Sources/ProjectSpec/SpecValidation.swift b/Sources/ProjectSpec/SpecValidation.swift index 1aa7a96b..76ee603f 100644 --- a/Sources/ProjectSpec/SpecValidation.swift +++ b/Sources/ProjectSpec/SpecValidation.swift @@ -77,19 +77,6 @@ extension Project { } } - if !options.targetOrdering.isEmpty { - let knownTargetNames = Set(projectTargets.map { $0.name }) - var seen = Set() - for name in options.targetOrdering { - if !seen.insert(name).inserted { - errors.append(.duplicateTargetOrderingEntry(name)) - } - if !knownTargetNames.contains(name) { - errors.append(.invalidTargetOrderingReference(name)) - } - } - } - for settings in settingGroups.values { errors += validateSettings(settings) } diff --git a/Sources/ProjectSpec/SpecValidationError.swift b/Sources/ProjectSpec/SpecValidationError.swift index 39e13c3d..85fd07e2 100644 --- a/Sources/ProjectSpec/SpecValidationError.swift +++ b/Sources/ProjectSpec/SpecValidationError.swift @@ -43,8 +43,6 @@ public struct SpecValidationError: Error, CustomStringConvertible { case duplicateDependencies(target: String, dependencyReference: String) case invalidPluginPackageReference(plugin: String, package: String) case emptySourcePath(target: String) - case invalidTargetOrderingReference(String) - case duplicateTargetOrderingEntry(String) public var description: String { switch self { @@ -114,10 +112,6 @@ public struct SpecValidationError: Error, CustomStringConvertible { return "Plugin \(plugin) has invalid package reference \(package)" case let .emptySourcePath(target): return "Target \(target.quoted) has an empty source path entry" - case let .invalidTargetOrderingReference(name): - return "Target ordering includes \(name.quoted) which is not a known target or aggregate target" - case let .duplicateTargetOrderingEntry(name): - return "Target ordering contains \(name.quoted) more than once" } } } diff --git a/Sources/ProjectSpec/Yaml.swift b/Sources/ProjectSpec/Yaml.swift index 89014f66..69510b8c 100644 --- a/Sources/ProjectSpec/Yaml.swift +++ b/Sources/ProjectSpec/Yaml.swift @@ -17,6 +17,19 @@ public func loadYamlDictionary(path: Path) throws -> [String: Any] { return yaml as? [String: Any] ?? [:] } +public func loadOrderedTargetNames(path: Path) throws -> [String] { + guard path.extension?.lowercased() != "json" else { return [] } + let string: String = try path.read() + guard !string.isEmpty else { return [] } + guard let node = try Yams.compose(yaml: string), + let rootMapping = node.mapping, + let targetsNode = rootMapping["targets"], + let targetsMapping = targetsNode.mapping else { + return [] + } + return targetsMapping.compactMap { key, _ in key.string } +} + public func dumpYamlDictionary(_ dictionary: [String: Any], path: Path) throws { let uncluttered = (dictionary as [String: Any?]).removingEmptyArraysDictionariesAndNils() let string: String = try Yams.dump(object: uncluttered) diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index b41a3dd7..851e8339 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -322,16 +322,9 @@ public class PBXProjGenerator { pbxProject.remotePackages = packageReferences.sorted { $0.key < $1.key }.map { $1 } pbxProject.localPackages = localPackageReferences.sorted { $0.key < $1.key }.map { $1 } - let allTargets: [PBXTarget] = targetObjects.valueArray + targetAggregateObjects.valueArray - let targetOrdering = project.options.targetOrdering - pbxProject.targets = allTargets.sorted { lhs, rhs in - let lhsIndex = targetOrdering.firstIndex(of: lhs.name) ?? Int.max - let rhsIndex = targetOrdering.firstIndex(of: rhs.name) ?? Int.max - if lhsIndex != rhsIndex { - return lhsIndex < rhsIndex - } - return lhs.name < rhs.name - } + let orderedNativeTargets: [PBXTarget] = project.targets.compactMap { targetObjects[$0.name] } + let orderedAggregateTargets: [PBXTarget] = project.aggregateTargets.compactMap { targetAggregateObjects[$0.name] } + pbxProject.targets = orderedNativeTargets + orderedAggregateTargets pbxProject.attributes = projectAttributes pbxProject.targetAttributes = generateTargetAttributes() return pbxProj diff --git a/Tests/Fixtures/CarthageProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/CarthageProject/Project.xcodeproj/project.pbxproj index b1cbbabc..17d1d720 100644 --- a/Tests/Fixtures/CarthageProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/CarthageProject/Project.xcodeproj/project.pbxproj @@ -340,9 +340,9 @@ projectRoot = ""; targets = ( AE3F93DB94E7208F2F1D9A78 /* Framework_iOS */, - 53A3B531E3947D8A8722745E /* Framework_macOS */, 536ACF18E4603B59207D43CE /* Framework_tvOS */, 71B5187E710718C1A205D4DC /* Framework_watchOS */, + 53A3B531E3947D8A8722745E /* Framework_macOS */, ); }; /* End PBXProject section */ diff --git a/Tests/Fixtures/SPM/SPM.xcodeproj/project.pbxproj b/Tests/Fixtures/SPM/SPM.xcodeproj/project.pbxproj index 2799afcc..8eb4f2d5 100644 --- a/Tests/Fixtures/SPM/SPM.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/SPM/SPM.xcodeproj/project.pbxproj @@ -267,10 +267,10 @@ projectDirPath = ""; projectRoot = ""; targets = ( - ADD3CE771A0D5E996031A193 /* AggTarget */, C99E3C420D63D5219CE57E33 /* App */, - 3F8D94C4EFC431F646AAFB28 /* StaticLibrary */, 339863E54E2D955C00B56802 /* Tests */, + 3F8D94C4EFC431F646AAFB28 /* StaticLibrary */, + ADD3CE771A0D5E996031A193 /* AggTarget */, ); }; /* End PBXProject section */ diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index 8a2c46fc..419fe2e9 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -2509,50 +2509,50 @@ ); projectRoot = ""; targets = ( - D137C04B64B7052419A2DF4E /* App_Clip */, - 63BFF75AA22335E3DDD5E26A /* App_Clip_Tests */, - 91C3E922A8482E07649971B9 /* App_Clip_UITests */, - 0867B0DACEF28C11442DE8F7 /* App_iOS */, - DC2F16BAA6E13B44AB62F888 /* App_iOS_Tests */, - F674B2CFC4738EEC49BAD0DA /* App_iOS_UITests */, - 020A320BB3736FCDE6CC4E70 /* App_macOS */, - 71E2BDAC4B8E8FC2BBF75C55 /* App_macOS_Tests */, - C0570E2FB50D830D8D423396 /* App_supportedDestinations */, - 208179651927D1138D19B5AD /* App_watchOS */, - 307AE3FA155FFD09B74AE351 /* App_watchOS Extension */, DA40AB367B606CCE2FDD398D /* BundleX */, 271CAC331D24F4F7E12C819C /* BundleY */, - 0CE8BE7C80651629EC056066 /* CrossOverlayFramework_iOS */, - 2F4FEAEFD7EE82DC49D899FC /* CrossOverlayFramework_macOS */, - 3BF66A4668DFAF9338791F60 /* CrossOverlayFramework_tvOS */, - C7F90FD0FAAF232B3E015D38 /* CrossOverlayFramework_watchOS */, - 428715FBC1D86458DA70CBDE /* DriverKitDriver */, - 9F551F66949B55E8328EB995 /* EndpointSecuritySystemExtension */, - B61ED4688789B071275E2B7A /* EntitledApp */, E7454C10EA126A93537DD57E /* ExternalTarget */, - CE7D183D3752B5B35D2D8E6D /* Framework2_iOS */, - FC26AF2506D3B2B40DE8A5F8 /* Framework2_macOS */, - 8B9A14DC280CCE013CC86440 /* Framework2_tvOS */, - 6ED01BC471A8C3642258E178 /* Framework2_watchOS */, - AE3F93DB94E7208F2F1D9A78 /* Framework_iOS */, - 53A3B531E3947D8A8722745E /* Framework_macOS */, - 536ACF18E4603B59207D43CE /* Framework_tvOS */, - 71B5187E710718C1A205D4DC /* Framework_watchOS */, 700328EE1570207608D6ADB3 /* IncludedLegacy */, 72C923899DE05F1281872160 /* Legacy */, + 020A320BB3736FCDE6CC4E70 /* App_macOS */, + 0867B0DACEF28C11442DE8F7 /* App_iOS */, + B61ED4688789B071275E2B7A /* EntitledApp */, + C0570E2FB50D830D8D423396 /* App_supportedDestinations */, + 208179651927D1138D19B5AD /* App_watchOS */, + 307AE3FA155FFD09B74AE351 /* App_watchOS Extension */, + 834F55973F05AC8A18144DB0 /* iMessageApp */, + 1C26A6A0BC446191F311D470 /* iMessageExtension */, + 192C574D74079A99AF1AD0B1 /* iMessageStickersExtension */, + 428715FBC1D86458DA70CBDE /* DriverKitDriver */, AD28397BCC984F769EE8A937 /* NetworkSystemExtension */, + 9F551F66949B55E8328EB995 /* EndpointSecuritySystemExtension */, 13E8C5AB873CEE21E18E552F /* StaticLibrary_ObjC_iOS */, - 578C80E461E675508CED5DC3 /* StaticLibrary_ObjC_macOS */, 93542A75A613F00FDB5C9C63 /* StaticLibrary_ObjC_tvOS */, 7D3D92034F4F203C140574F0 /* StaticLibrary_ObjC_watchOS */, + 578C80E461E675508CED5DC3 /* StaticLibrary_ObjC_macOS */, 19BFB84599B0AA1275A9662D /* StaticLibrary_Swift */, - BF3693DCA6182D7AEC410AFC /* SuperTarget */, + AE3F93DB94E7208F2F1D9A78 /* Framework_iOS */, + 536ACF18E4603B59207D43CE /* Framework_tvOS */, + 71B5187E710718C1A205D4DC /* Framework_watchOS */, + 53A3B531E3947D8A8722745E /* Framework_macOS */, + CE7D183D3752B5B35D2D8E6D /* Framework2_iOS */, + 8B9A14DC280CCE013CC86440 /* Framework2_tvOS */, + 6ED01BC471A8C3642258E178 /* Framework2_watchOS */, + FC26AF2506D3B2B40DE8A5F8 /* Framework2_macOS */, 0636AAF06498C336E1CEEDE4 /* TestFramework */, - 090315A24AC8E8B8F318A709 /* Tool */, + 0CE8BE7C80651629EC056066 /* CrossOverlayFramework_iOS */, + 3BF66A4668DFAF9338791F60 /* CrossOverlayFramework_tvOS */, + C7F90FD0FAAF232B3E015D38 /* CrossOverlayFramework_watchOS */, + 2F4FEAEFD7EE82DC49D899FC /* CrossOverlayFramework_macOS */, + DC2F16BAA6E13B44AB62F888 /* App_iOS_Tests */, + F674B2CFC4738EEC49BAD0DA /* App_iOS_UITests */, + 71E2BDAC4B8E8FC2BBF75C55 /* App_macOS_Tests */, E7815F2F0D9CDECF9185AAF3 /* XPC Service */, - 834F55973F05AC8A18144DB0 /* iMessageApp */, - 1C26A6A0BC446191F311D470 /* iMessageExtension */, - 192C574D74079A99AF1AD0B1 /* iMessageStickersExtension */, + 090315A24AC8E8B8F318A709 /* Tool */, + D137C04B64B7052419A2DF4E /* App_Clip */, + 63BFF75AA22335E3DDD5E26A /* App_Clip_Tests */, + 91C3E922A8482E07649971B9 /* App_Clip_UITests */, + BF3693DCA6182D7AEC410AFC /* SuperTarget */, ); }; /* End PBXProject section */ diff --git a/Tests/Fixtures/target_ordering_test.yml b/Tests/Fixtures/target_ordering_test.yml new file mode 100644 index 00000000..55f67940 --- /dev/null +++ b/Tests/Fixtures/target_ordering_test.yml @@ -0,0 +1,14 @@ +name: TargetOrdering +targets: + Zebra: + type: framework + platform: iOS + Apple: + type: framework + platform: iOS + Mango: + type: framework + platform: iOS + Banana: + type: framework + platform: iOS diff --git a/Tests/ProjectSpecTests/ProjectSpecTests.swift b/Tests/ProjectSpecTests/ProjectSpecTests.swift index 237a6234..c308df96 100644 --- a/Tests/ProjectSpecTests/ProjectSpecTests.swift +++ b/Tests/ProjectSpecTests/ProjectSpecTests.swift @@ -153,32 +153,6 @@ class ProjectSpecTests: XCTestCase { try expectValidationError(project, .invalidBuildSettingConfig("invalidSettingGroupConfig")) } - $0.it("fails with unknown target in targetOrdering") { - var project = baseProject - project.targets = [Target(name: "App", type: .application, platform: .iOS)] - project.options = SpecOptions(targetOrdering: ["App", "Ghost"]) - try expectValidationError(project, .invalidTargetOrderingReference("Ghost")) - } - - $0.it("fails with duplicate entry in targetOrdering") { - var project = baseProject - project.targets = [ - Target(name: "App", type: .application, platform: .iOS), - Target(name: "Tests", type: .unitTestBundle, platform: .iOS), - ] - project.options = SpecOptions(targetOrdering: ["App", "Tests", "App"]) - try expectValidationError(project, .duplicateTargetOrderingEntry("App")) - } - - $0.it("accepts a valid targetOrdering referencing native and aggregate targets") { - var project = baseProject - project.targets = [Target(name: "App", type: .application, platform: .iOS)] - project.aggregateTargets = [AggregateTarget(name: "Agg", targets: ["App"])] - project.options = SpecOptions(targetOrdering: ["Agg", "App"]) - try expectNoValidationError(project, .invalidTargetOrderingReference("Agg")) - try expectNoValidationError(project, .invalidTargetOrderingReference("App")) - } - $0.it("fails with duplicate dependencies") { var project = baseProject project.targets = [ diff --git a/Tests/ProjectSpecTests/SpecLoadingTests.swift b/Tests/ProjectSpecTests/SpecLoadingTests.swift index d2d3ab69..c1c1ad7f 100644 --- a/Tests/ProjectSpecTests/SpecLoadingTests.swift +++ b/Tests/ProjectSpecTests/SpecLoadingTests.swift @@ -25,6 +25,17 @@ class SpecLoadingTests: XCTestCase { } } + func testTargetDeclarationOrder() { + describe { + $0.it("preserves target declaration order from YAML") { + let path = fixturePath + "target_ordering_test.yml" + let project = try loadSpec(path: path) + + try expect(project.targets.map { $0.name }) == ["Zebra", "Apple", "Mango", "Banana"] + } + } + } + func testSpecLoader() { describe { $0.it("merges includes") { @@ -116,6 +127,41 @@ class SpecLoadingTests: XCTestCase { ] try expect(project.targets) == [ + Target( + name: "RecursiveTarget", + type: .application, + platform: .macOS, + configFiles: ["Config": "paths_test/recursive_test/config"], + sources: ["paths_test/recursive_test/source"], + dependencies: [Dependency(type: .framework, reference: "paths_test/recursive_test/Framework")], + info: Plist(path: "paths_test/recursive_test/info"), + entitlements: Plist(path: "paths_test/recursive_test/entitlements"), + preBuildScripts: [BuildScript(script: .path("paths_test/recursive_test/prebuildScript"))], + postCompileScripts: [BuildScript(script: .path("paths_test/recursive_test/postCompileScript"))], + postBuildScripts: [BuildScript(script: .path("paths_test/recursive_test/postBuildScript"))] + ), + Target( + name: "target1", + type: .framework, + platform: .macOS, + sources: ["paths_test/same_relative_path_test/parent1/same/target1/source"] + ), + Target( + name: "target2", + type: .framework, + platform: .macOS, + sources: ["paths_test/same_relative_path_test/parent2/same/target2/source"] + ), + Target( + name: "app", + type: .application, + platform: .macOS, + sources: ["paths_test/same_relative_path_test/source"], + dependencies: [ + Dependency(type: .target, reference: "target1"), + Dependency(type: .target, reference: "target2") + ] + ), Target( name: "IncludedTarget", type: .application, @@ -149,41 +195,6 @@ class SpecLoadingTests: XCTestCase { postCompileScripts: [BuildScript(script: .path("postCompileScript"))], postBuildScripts: [BuildScript(script: .path("postBuildScript"))] ), - Target( - name: "RecursiveTarget", - type: .application, - platform: .macOS, - configFiles: ["Config": "paths_test/recursive_test/config"], - sources: ["paths_test/recursive_test/source"], - dependencies: [Dependency(type: .framework, reference: "paths_test/recursive_test/Framework")], - info: Plist(path: "paths_test/recursive_test/info"), - entitlements: Plist(path: "paths_test/recursive_test/entitlements"), - preBuildScripts: [BuildScript(script: .path("paths_test/recursive_test/prebuildScript"))], - postCompileScripts: [BuildScript(script: .path("paths_test/recursive_test/postCompileScript"))], - postBuildScripts: [BuildScript(script: .path("paths_test/recursive_test/postBuildScript"))] - ), - Target( - name: "app", - type: .application, - platform: .macOS, - sources: ["paths_test/same_relative_path_test/source"], - dependencies: [ - Dependency(type: .target, reference: "target1"), - Dependency(type: .target, reference: "target2") - ] - ), - Target( - name: "target1", - type: .framework, - platform: .macOS, - sources: ["paths_test/same_relative_path_test/parent1/same/target1/source"] - ), - Target( - name: "target2", - type: .framework, - platform: .macOS, - sources: ["paths_test/same_relative_path_test/parent2/same/target2/source"] - ) ] try expect(project.schemes) == [ @@ -1483,21 +1494,6 @@ class SpecLoadingTests: XCTestCase { try expect(parsedSpec) == expected } - $0.it("parses targetOrdering option") { - let options = SpecOptions(targetOrdering: ["App", "Tests"]) - let expected = Project(name: "test", options: options) - let dictionary: [String: Any] = ["options": [ - "targetOrdering": ["App", "Tests"], - ]] - let parsedSpec = try getProjectSpec(dictionary) - try expect(parsedSpec) == expected - } - - $0.it("defaults targetOrdering to empty when not specified") { - let parsedSpec = try getProjectSpec(["options": [String: Any]()]) - try expect(parsedSpec.options.targetOrdering) == [] - } - $0.it("parses packages") { let project = Project(name: "spm", packages: [ "package1": .remote(url: "package.git", versionRequirement: .exact("1.2.2")), diff --git a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift index 6788444d..80b083e1 100644 --- a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift @@ -399,48 +399,48 @@ class PBXProjGeneratorTests: XCTestCase { func testTargetOrdering() { describe { - $0.it("honors targetOrdering in the generated pbxproj") { - var options = SpecOptions() - options.targetOrdering = ["C", "A"] - - let a = Target(name: "A", type: .framework, platform: .iOS) - let b = Target(name: "B", type: .framework, platform: .iOS) - let c = Target(name: "C", type: .framework, platform: .iOS) - let project = Project(name: "Test", targets: [a, b, c], options: options) + $0.it("honors declaration order in the generated pbxproj") { + let project = try Project( + jsonDictionary: [ + "name": "Test", + "targets": [ + "A": ["type": "framework", "platform": "iOS"], + "B": ["type": "framework", "platform": "iOS"], + "C": ["type": "framework", "platform": "iOS"], + ], + ], + targetDeclarationOrder: ["C", "A", "B"] + ) let pbxProj = try project.generatePbxProj() let targetNames = pbxProj.projects.first?.targets.map { $0.name } try expect(targetNames) == ["C", "A", "B"] } - $0.it("falls back to alphabetical when targetOrdering is empty") { + $0.it("preserves the order of targets in a programmatically-built project") { + let c = Target(name: "C", type: .framework, platform: .iOS) let a = Target(name: "A", type: .framework, platform: .iOS) let b = Target(name: "B", type: .framework, platform: .iOS) - let c = Target(name: "C", type: .framework, platform: .iOS) - let project = Project(name: "Test", targets: [a, b, c]) + let project = Project(name: "Test", targets: [c, a, b]) let pbxProj = try project.generatePbxProj() let targetNames = pbxProj.projects.first?.targets.map { $0.name } - try expect(targetNames) == ["A", "B", "C"] + try expect(targetNames) == ["C", "A", "B"] } - $0.it("orders aggregate targets alongside native targets") { - var options = SpecOptions() - options.targetOrdering = ["Agg", "App"] - + $0.it("places aggregate targets after native targets") { let app = Target(name: "App", type: .application, platform: .iOS) let framework = Target(name: "Framework", type: .framework, platform: .iOS) let aggregate = AggregateTarget(name: "Agg", targets: ["App"]) let project = Project( name: "Test", targets: [app, framework], - aggregateTargets: [aggregate], - options: options + aggregateTargets: [aggregate] ) let pbxProj = try project.generatePbxProj() let targetNames = pbxProj.projects.first?.targets.map { $0.name } - try expect(targetNames) == ["Agg", "App", "Framework"] + try expect(targetNames) == ["App", "Framework", "Agg"] } } } From f449abfaa38d6128793255bc3225c925826d264c Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Thu, 23 Apr 2026 13:10:30 +0200 Subject: [PATCH 3/8] Fix json parsing --- Sources/ProjectSpec/SpecFile.swift | 9 +------- Sources/ProjectSpec/Yaml.swift | 1 - Tests/Fixtures/target_ordering_test.json | 21 +++++++++++++++++++ Tests/ProjectSpecTests/SpecLoadingTests.swift | 7 +++++++ 4 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 Tests/Fixtures/target_ordering_test.json diff --git a/Sources/ProjectSpec/SpecFile.swift b/Sources/ProjectSpec/SpecFile.swift index 98978277..dfa5ec40 100644 --- a/Sources/ProjectSpec/SpecFile.swift +++ b/Sources/ProjectSpec/SpecFile.swift @@ -93,7 +93,7 @@ public struct SpecFile { } let jsonDictionary = try SpecFile.loadDictionary(path: path).expand(variables: variables) - let targetDeclarationOrder = try SpecFile.loadTargetDeclarationOrder(path: path) + let targetDeclarationOrder = try loadOrderedTargetNames(path: path) let includes = Include.parse(json: jsonDictionary["include"]) let subSpecs: [SpecFile] = try includes @@ -106,13 +106,6 @@ public struct SpecFile { cachedSpecFiles[path] = self } - static func loadTargetDeclarationOrder(path: Path) throws -> [String] { - if path.extension?.lowercased() == "json" { - return [] - } - return try loadOrderedTargetNames(path: path) - } - static func loadDictionary(path: Path) throws -> JSONDictionary { // Depending on the extension we will either load the file as YAML or JSON if path.extension?.lowercased() == "json" { diff --git a/Sources/ProjectSpec/Yaml.swift b/Sources/ProjectSpec/Yaml.swift index 69510b8c..f681e906 100644 --- a/Sources/ProjectSpec/Yaml.swift +++ b/Sources/ProjectSpec/Yaml.swift @@ -18,7 +18,6 @@ public func loadYamlDictionary(path: Path) throws -> [String: Any] { } public func loadOrderedTargetNames(path: Path) throws -> [String] { - guard path.extension?.lowercased() != "json" else { return [] } let string: String = try path.read() guard !string.isEmpty else { return [] } guard let node = try Yams.compose(yaml: string), diff --git a/Tests/Fixtures/target_ordering_test.json b/Tests/Fixtures/target_ordering_test.json new file mode 100644 index 00000000..42746f8f --- /dev/null +++ b/Tests/Fixtures/target_ordering_test.json @@ -0,0 +1,21 @@ +{ + "name": "TargetOrdering", + "targets": { + "Zebra": { + "type": "framework", + "platform": "iOS" + }, + "Apple": { + "type": "framework", + "platform": "iOS" + }, + "Mango": { + "type": "framework", + "platform": "iOS" + }, + "Banana": { + "type": "framework", + "platform": "iOS" + } + } +} diff --git a/Tests/ProjectSpecTests/SpecLoadingTests.swift b/Tests/ProjectSpecTests/SpecLoadingTests.swift index c1c1ad7f..a0eb0185 100644 --- a/Tests/ProjectSpecTests/SpecLoadingTests.swift +++ b/Tests/ProjectSpecTests/SpecLoadingTests.swift @@ -33,6 +33,13 @@ class SpecLoadingTests: XCTestCase { try expect(project.targets.map { $0.name }) == ["Zebra", "Apple", "Mango", "Banana"] } + + $0.it("preserves target declaration order from JSON") { + let path = fixturePath + "target_ordering_test.json" + let project = try loadSpec(path: path) + + try expect(project.targets.map { $0.name }) == ["Zebra", "Apple", "Mango", "Banana"] + } } } From 99433a1eb876ab5bf605c11f634bc46ad6a0b203 Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Thu, 23 Apr 2026 13:15:54 +0200 Subject: [PATCH 4/8] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c45b74..657a2b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Next Version ### Changed -- Targets in the generated project now follow the declaration order from the YAML spec (Xcode sidebar, `xcodebuild -list` output). Previously they were always sorted alphabetically. JSON specs and projects built programmatically continue to sort alphabetically, since key order isn't recoverable. @mirkokg +- Targets in the generated project now follow the declaration order from the source spec (Xcode sidebar, `xcodebuild -list` output). Previously they were always sorted alphabetically. Applies to both YAML and JSON specs. @mirkokg ## 2.45.4 From 575edee341a34c610e4cb62cc185733a87bccfad Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Thu, 23 Apr 2026 13:22:38 +0200 Subject: [PATCH 5/8] Read file once --- Sources/ProjectSpec/SpecFile.swift | 15 ++++++++++----- Sources/ProjectSpec/Yaml.swift | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Sources/ProjectSpec/SpecFile.swift b/Sources/ProjectSpec/SpecFile.swift index dfa5ec40..8d70062a 100644 --- a/Sources/ProjectSpec/SpecFile.swift +++ b/Sources/ProjectSpec/SpecFile.swift @@ -92,8 +92,9 @@ public struct SpecFile { return } - let jsonDictionary = try SpecFile.loadDictionary(path: path).expand(variables: variables) - let targetDeclarationOrder = try loadOrderedTargetNames(path: path) + let contents: String = try path.read() + let jsonDictionary = try SpecFile.loadDictionary(path: path, contents: contents).expand(variables: variables) + let targetDeclarationOrder = try loadOrderedTargetNames(contents: contents) let includes = Include.parse(json: jsonDictionary["include"]) let subSpecs: [SpecFile] = try includes @@ -107,16 +108,20 @@ public struct SpecFile { } static func loadDictionary(path: Path) throws -> JSONDictionary { + let contents: String = try path.read() + return try loadDictionary(path: path, contents: contents) + } + + static func loadDictionary(path: Path, contents: String) throws -> JSONDictionary { // Depending on the extension we will either load the file as YAML or JSON if path.extension?.lowercased() == "json" { - let data: Data = try path.read() - let jsonData = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + let jsonData = try JSONSerialization.jsonObject(with: Data(contents.utf8), options: .allowFragments) guard let jsonDictionary = jsonData as? [String: Any] else { fatalError("Invalid JSON at path \(path)") } return jsonDictionary } else { - return try loadYamlDictionary(path: path) + return try loadYamlDictionary(contents: contents) } } diff --git a/Sources/ProjectSpec/Yaml.swift b/Sources/ProjectSpec/Yaml.swift index f681e906..b1a8384b 100644 --- a/Sources/ProjectSpec/Yaml.swift +++ b/Sources/ProjectSpec/Yaml.swift @@ -4,14 +4,18 @@ import Yams public func loadYamlDictionary(path: Path) throws -> [String: Any] { let string: String = try path.read() - if string == "" { + return try loadYamlDictionary(contents: string) +} + +public func loadYamlDictionary(contents: String) throws -> [String: Any] { + if contents.isEmpty { return [:] } let resolver = Resolver.default .removing(.null) // remove rule so that empty quotes are treated as empty strings - guard let yaml = try Yams.load(yaml: string, resolver) else { + guard let yaml = try Yams.load(yaml: contents, resolver) else { return [:] } return yaml as? [String: Any] ?? [:] @@ -19,8 +23,12 @@ public func loadYamlDictionary(path: Path) throws -> [String: Any] { public func loadOrderedTargetNames(path: Path) throws -> [String] { let string: String = try path.read() - guard !string.isEmpty else { return [] } - guard let node = try Yams.compose(yaml: string), + return try loadOrderedTargetNames(contents: string) +} + +public func loadOrderedTargetNames(contents: String) throws -> [String] { + guard !contents.isEmpty else { return [] } + guard let node = try Yams.compose(yaml: contents), let rootMapping = node.mapping, let targetsNode = rootMapping["targets"], let targetsMapping = targetsNode.mapping else { From 050ad70572046aee8fdc164bbe260d2671aa0378 Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Thu, 23 Apr 2026 13:44:02 +0200 Subject: [PATCH 6/8] Update SpecFile.swift --- Sources/ProjectSpec/SpecFile.swift | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/Sources/ProjectSpec/SpecFile.swift b/Sources/ProjectSpec/SpecFile.swift index 8d70062a..e4633bfa 100644 --- a/Sources/ProjectSpec/SpecFile.swift +++ b/Sources/ProjectSpec/SpecFile.swift @@ -107,11 +107,6 @@ public struct SpecFile { cachedSpecFiles[path] = self } - static func loadDictionary(path: Path) throws -> JSONDictionary { - let contents: String = try path.read() - return try loadDictionary(path: path, contents: contents) - } - static func loadDictionary(path: Path, contents: String) throws -> JSONDictionary { // Depending on the extension we will either load the file as YAML or JSON if path.extension?.lowercased() == "json" { @@ -161,21 +156,9 @@ public struct SpecFile { guard mergedSpecPaths.insert(path).inserted else { return [] } - var order: [String] = [] + let fromSubSpecs = subSpecs.flatMap { $0.mergedTargetDeclarationOrder(set: &mergedSpecPaths) } var seen = Set() - for subSpec in subSpecs { - for name in subSpec.mergedTargetDeclarationOrder(set: &mergedSpecPaths) { - if seen.insert(name).inserted { - order.append(name) - } - } - } - for name in targetDeclarationOrder { - if seen.insert(name).inserted { - order.append(name) - } - } - return order + return (fromSubSpecs + targetDeclarationOrder).filter { seen.insert($0).inserted } } private func resolvingPaths(cachedSpecFiles: inout [Path: SpecFile], relativeTo basePath: Path = Path()) -> SpecFile { From 3a331e086fd63d1ed4312fd19831425dcce15927 Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Thu, 23 Apr 2026 13:47:48 +0200 Subject: [PATCH 7/8] Extract multiplatform target name helper --- Sources/ProjectSpec/Project.swift | 17 +++-------------- Sources/ProjectSpec/Target.swift | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index 70feb051..38dc2b6d 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -235,21 +235,10 @@ extension Project { } static func expandedTargetOrder(_ declarationOrder: [String], rawTargets: JSONDictionary) -> [String] { - var result: [String] = [] - for key in declarationOrder { - guard let target = rawTargets[key] as? JSONDictionary else { continue } - if let platforms = target["platform"] as? [String] { - for platform in platforms { - let prefix = target["platformPrefix"] as? String ?? "" - let suffix = target["platformSuffix"] as? String ?? "_\(platform)" - result.append(prefix + key + suffix) - } - } else { - let resolvedName = target["name"] as? String ?? key - result.append(resolvedName) - } + declarationOrder.flatMap { key -> [String] in + guard let target = rawTargets[key] as? JSONDictionary else { return [] } + return Target.resolvedNames(forRawTarget: target, key: key) } - return result } static func resolveProject(jsonDictionary: JSONDictionary) -> JSONDictionary { diff --git a/Sources/ProjectSpec/Target.swift b/Sources/ProjectSpec/Target.swift index 781994f4..2043ba29 100644 --- a/Sources/ProjectSpec/Target.swift +++ b/Sources/ProjectSpec/Target.swift @@ -180,9 +180,7 @@ extension Target { platformTarget = platformTarget.expand(variables: ["platform": platform]) platformTarget["platform"] = platform - let platformSuffix = platformTarget["platformSuffix"] as? String ?? "_\(platform)" - let platformPrefix = platformTarget["platformPrefix"] as? String ?? "" - let newTargetName = platformPrefix + targetName + platformSuffix + let newTargetName = multiplatformTargetName(fromExpanded: platformTarget, key: targetName, platform: platform) var settings = platformTarget["settings"] as? JSONDictionary ?? [:] if settings["configs"] != nil || settings["groups"] != nil || settings["base"] != nil { @@ -212,6 +210,22 @@ extension Target { merged["targets"] = crossPlatformTargets return merged } + + static func resolvedNames(forRawTarget dict: JSONDictionary, key: String) -> [String] { + if let platforms = dict["platform"] as? [String] { + return platforms.map { platform in + let expanded = dict.expand(variables: ["platform": platform]) + return multiplatformTargetName(fromExpanded: expanded, key: key, platform: platform) + } + } + return [dict["name"] as? String ?? key] + } + + static func multiplatformTargetName(fromExpanded expanded: JSONDictionary, key: String, platform: String) -> String { + let prefix = expanded["platformPrefix"] as? String ?? "" + let suffix = expanded["platformSuffix"] as? String ?? "_\(platform)" + return prefix + key + suffix + } } extension Target: Equatable { From f83c9751211c4a601675d228559845dbd9c2d9cb Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Thu, 23 Apr 2026 13:51:49 +0200 Subject: [PATCH 8/8] Simplify target sort with tuple comparison --- Sources/ProjectSpec/Project.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index 38dc2b6d..1ed768b3 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -190,14 +190,7 @@ extension Project { configs.map { Config(name: $0, type: ConfigType(rawValue: $1)) }.sorted { $0.name < $1.name } let parsedTargets: [Target] = try jsonDictionary.json(atKeyPath: "targets", parallel: true) let targetOrderIndex = Dictionary(uniqueKeysWithValues: expandedTargetOrder.enumerated().map { ($1, $0) }) - targets = parsedTargets.sorted { lhs, rhs in - let lhsIndex = targetOrderIndex[lhs.name] ?? Int.max - let rhsIndex = targetOrderIndex[rhs.name] ?? Int.max - if lhsIndex != rhsIndex { - return lhsIndex < rhsIndex - } - return lhs.name < rhs.name - } + targets = parsedTargets.sorted { (targetOrderIndex[$0.name] ?? .max, $0.name) < (targetOrderIndex[$1.name] ?? .max, $1.name) } aggregateTargets = try jsonDictionary.json(atKeyPath: "aggregateTargets").sorted { $0.name < $1.name } projectReferences = try jsonDictionary.json(atKeyPath: "projectReferences").sorted { $0.name < $1.name } schemes = try jsonDictionary.json(atKeyPath: "schemes")