From c0f5b32b36799ca05df583e32fb2a419fa3a5e3c Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Fri, 17 Apr 2026 12:25:18 +0200 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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") From a79f622f1f6412c49284ebf389835e32c81ddb41 Mon Sep 17 00:00:00 2001 From: Mirko Tomic Date: Wed, 15 Jul 2026 21:12:48 +0200 Subject: [PATCH 9/9] Carry target declaration order through spec resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration order was computed from raw spec keys before resolveProject, so it missed platform/name values inherited from target templates, and keys containing ${VARIABLES} never matched the expanded target dictionary — both cases silently fell back to alphabetical order. Instead of pre-computing final names, stamp each raw target with a __declarationIndex before resolution. The index rides through the variable, template, rename and multiplatform transformations along with the rest of the target dictionary, and is read back off the resolved targets (keyed by resolved name) to sort them. Declaration keys are expanded with the same variables as the dictionary keys so ${VAR} target names stay in the order map. Order extraction no longer throws: a spec whose contents can't be re-parsed for ordering just falls back to alphabetical instead of failing generation. Adds regression tests for a template-supplied platform array, a template-supplied name, and an environment-expanded target key. --- CHANGELOG.md | 2 +- Sources/ProjectSpec/Project.swift | 27 ++++++++++--------- Sources/ProjectSpec/SpecFile.swift | 8 +++--- Sources/ProjectSpec/Target.swift | 14 +++------- Sources/ProjectSpec/Yaml.swift | 9 ++----- .../Project.xcodeproj/project.pbxproj | 2 +- .../Project.xcodeproj/project.pbxproj | 8 +++--- .../target_ordering_template_name_test.yml | 12 +++++++++ .../target_ordering_template_test.yml | 11 ++++++++ .../target_ordering_variable_test.yml | 8 ++++++ Tests/ProjectSpecTests/SpecLoadingTests.swift | 25 +++++++++++++++++ 11 files changed, 85 insertions(+), 41 deletions(-) create mode 100644 Tests/Fixtures/target_ordering_template_name_test.yml create mode 100644 Tests/Fixtures/target_ordering_template_test.yml create mode 100644 Tests/Fixtures/target_ordering_variable_test.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 657a2b59..772e380c 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 source spec (Xcode sidebar, `xcodebuild -list` output). Previously they were always sorted alphabetically. Applies to both YAML and JSON specs. @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. Declaration order is now also preserved for targets whose `platform`/`name` come from a target template and for targets whose key is a `${VARIABLE}`. @mirkokg ## 2.45.4 diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index 1ed768b3..ffb2016d 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -174,10 +174,15 @@ extension Project { 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) + var jsonDictionary = jsonDictionary + if var rawTargets = jsonDictionary["targets"] as? [String: JSONDictionary] { + for (index, key) in targetDeclarationOrder.enumerated() where rawTargets[key] != nil { + rawTargets[key]?[Target.declarationIndexKey] = index + } + jsonDictionary["targets"] = rawTargets + } - let jsonDictionary = Project.resolveProject(jsonDictionary: jsonDictionary) + jsonDictionary = Project.resolveProject(jsonDictionary: jsonDictionary) let buildSettingsParser = BuildSettingsParser(jsonDictionary: jsonDictionary) name = try jsonDictionary.json(atKeyPath: "name") @@ -189,8 +194,13 @@ extension Project { self.configs = configs.isEmpty ? Config.defaultConfigs : 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 { (targetOrderIndex[$0.name] ?? .max, $0.name) < (targetOrderIndex[$1.name] ?? .max, $1.name) } + let resolvedTargets = jsonDictionary["targets"] as? [String: JSONDictionary] ?? [:] + var targetOrder: [String: Int] = [:] + for (key, target) in resolvedTargets { + let name = target["name"] as? String ?? key + targetOrder[name] = (target[Target.declarationIndexKey] as? Int) ?? .max + } + targets = parsedTargets.sorted { (targetOrder[$0.name] ?? .max, $0.name) < (targetOrder[$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") @@ -227,13 +237,6 @@ extension Project { projectReferencesMap = Dictionary(uniqueKeysWithValues: projectReferences.map { ($0.name, $0) }) } - static func expandedTargetOrder(_ declarationOrder: [String], rawTargets: JSONDictionary) -> [String] { - declarationOrder.flatMap { key -> [String] in - guard let target = rawTargets[key] as? JSONDictionary else { return [] } - return Target.resolvedNames(forRawTarget: target, key: key) - } - } - static func resolveProject(jsonDictionary: JSONDictionary) -> JSONDictionary { var jsonDictionary = jsonDictionary diff --git a/Sources/ProjectSpec/SpecFile.swift b/Sources/ProjectSpec/SpecFile.swift index e4633bfa..43764526 100644 --- a/Sources/ProjectSpec/SpecFile.swift +++ b/Sources/ProjectSpec/SpecFile.swift @@ -94,7 +94,8 @@ public struct SpecFile { 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 targetDeclarationOrder = loadOrderedTargetNames(contents: contents) + .map { [$0: ""].expand(variables: variables).keys.first ?? $0 } let includes = Include.parse(json: jsonDictionary["include"]) let subSpecs: [SpecFile] = try includes @@ -125,11 +126,8 @@ public struct SpecFile { } public func resolvedTargetDeclarationOrder() -> [String] { - var cachedSpecFiles: [Path: SpecFile] = [:] - let resolvedSpec = resolvingPaths(cachedSpecFiles: &cachedSpecFiles) - var mergedSpecPaths = Set() - return resolvedSpec.mergedTargetDeclarationOrder(set: &mergedSpecPaths) + return mergedTargetDeclarationOrder(set: &mergedSpecPaths) } private func resolvedDictionaryWithUniqueTargets() -> JSONDictionary { diff --git a/Sources/ProjectSpec/Target.swift b/Sources/ProjectSpec/Target.swift index 2043ba29..9d94c7d8 100644 --- a/Sources/ProjectSpec/Target.swift +++ b/Sources/ProjectSpec/Target.swift @@ -161,6 +161,8 @@ extension Target: PathContainer { extension Target { + static let declarationIndexKey = "__declarationIndex" + static func resolveMultiplatformTargets(jsonDictionary: JSONDictionary) -> JSONDictionary { guard let targetsDictionary: [String: JSONDictionary] = jsonDictionary["targets"] as? [String: JSONDictionary] else { return jsonDictionary @@ -172,7 +174,7 @@ extension Target { if let platforms = target["platform"] as? [String] { for platform in platforms { var platformTarget = target - + /// This value is set to help us to check, in Target init, that there are no conflicts in the definition of the platforms. We want to ensure that the user didn't define, at the same time, /// the new Xcode 14 supported destinations and the XcodeGen generation of Multiple Platform Targets (when you define the platform field as an array). platformTarget["isMultiPlatformTarget"] = true @@ -211,16 +213,6 @@ extension Target { 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)" diff --git a/Sources/ProjectSpec/Yaml.swift b/Sources/ProjectSpec/Yaml.swift index b1a8384b..6dcb5966 100644 --- a/Sources/ProjectSpec/Yaml.swift +++ b/Sources/ProjectSpec/Yaml.swift @@ -21,14 +21,9 @@ public func loadYamlDictionary(contents: String) throws -> [String: Any] { return yaml as? [String: Any] ?? [:] } -public func loadOrderedTargetNames(path: Path) throws -> [String] { - let string: String = try path.read() - return try loadOrderedTargetNames(contents: string) -} - -public func loadOrderedTargetNames(contents: String) throws -> [String] { +public func loadOrderedTargetNames(contents: String) -> [String] { guard !contents.isEmpty else { return [] } - guard let node = try Yams.compose(yaml: contents), + guard let node = try? Yams.compose(yaml: contents), let rootMapping = node.mapping, let targetsNode = rootMapping["targets"], let targetsMapping = targetsNode.mapping else { diff --git a/Tests/Fixtures/CarthageProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/CarthageProject/Project.xcodeproj/project.pbxproj index 17d1d720..b1cbbabc 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/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index 419fe2e9..bbfb8e41 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -2527,23 +2527,23 @@ 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 */, AE3F93DB94E7208F2F1D9A78 /* Framework_iOS */, + 53A3B531E3947D8A8722745E /* Framework_macOS */, 536ACF18E4603B59207D43CE /* Framework_tvOS */, 71B5187E710718C1A205D4DC /* Framework_watchOS */, - 53A3B531E3947D8A8722745E /* Framework_macOS */, CE7D183D3752B5B35D2D8E6D /* Framework2_iOS */, + FC26AF2506D3B2B40DE8A5F8 /* Framework2_macOS */, 8B9A14DC280CCE013CC86440 /* Framework2_tvOS */, 6ED01BC471A8C3642258E178 /* Framework2_watchOS */, - FC26AF2506D3B2B40DE8A5F8 /* Framework2_macOS */, 0636AAF06498C336E1CEEDE4 /* TestFramework */, 0CE8BE7C80651629EC056066 /* CrossOverlayFramework_iOS */, + 2F4FEAEFD7EE82DC49D899FC /* CrossOverlayFramework_macOS */, 3BF66A4668DFAF9338791F60 /* CrossOverlayFramework_tvOS */, C7F90FD0FAAF232B3E015D38 /* CrossOverlayFramework_watchOS */, - 2F4FEAEFD7EE82DC49D899FC /* CrossOverlayFramework_macOS */, DC2F16BAA6E13B44AB62F888 /* App_iOS_Tests */, F674B2CFC4738EEC49BAD0DA /* App_iOS_UITests */, 71E2BDAC4B8E8FC2BBF75C55 /* App_macOS_Tests */, diff --git a/Tests/Fixtures/target_ordering_template_name_test.yml b/Tests/Fixtures/target_ordering_template_name_test.yml new file mode 100644 index 00000000..8eb971f1 --- /dev/null +++ b/Tests/Fixtures/target_ordering_template_name_test.yml @@ -0,0 +1,12 @@ +name: TargetOrderingTemplateName +targets: + Zebra: + type: framework + platform: iOS + templates: [Renamed] + Apple: + type: framework + platform: iOS +targetTemplates: + Renamed: + name: Zulu diff --git a/Tests/Fixtures/target_ordering_template_test.yml b/Tests/Fixtures/target_ordering_template_test.yml new file mode 100644 index 00000000..2b6b1cee --- /dev/null +++ b/Tests/Fixtures/target_ordering_template_test.yml @@ -0,0 +1,11 @@ +name: TargetOrderingTemplate +targets: + Zebra: + type: framework + templates: [CrossPlatform] + Apple: + type: framework + platform: iOS +targetTemplates: + CrossPlatform: + platform: [iOS, tvOS] diff --git a/Tests/Fixtures/target_ordering_variable_test.yml b/Tests/Fixtures/target_ordering_variable_test.yml new file mode 100644 index 00000000..02be5052 --- /dev/null +++ b/Tests/Fixtures/target_ordering_variable_test.yml @@ -0,0 +1,8 @@ +name: TargetOrderingVariable +targets: + ${FIRST_TARGET}: + type: framework + platform: iOS + Apple: + type: framework + platform: iOS diff --git a/Tests/ProjectSpecTests/SpecLoadingTests.swift b/Tests/ProjectSpecTests/SpecLoadingTests.swift index a0eb0185..6b7a3a71 100644 --- a/Tests/ProjectSpecTests/SpecLoadingTests.swift +++ b/Tests/ProjectSpecTests/SpecLoadingTests.swift @@ -40,6 +40,31 @@ class SpecLoadingTests: XCTestCase { try expect(project.targets.map { $0.name }) == ["Zebra", "Apple", "Mango", "Banana"] } + + $0.it("preserves declaration order for a template-supplied platform array") { + let path = fixturePath + "target_ordering_template_test.yml" + let project = try loadSpec(path: path) + + try expect(project.targets.map { $0.name }) == ["Zebra_iOS", "Zebra_tvOS", "Apple"] + } + + $0.it("returns an empty order for contents that are not valid YAML") { + try expect(loadOrderedTargetNames(contents: "\ttargets:\n\tZebra:")) == [] + } + + $0.it("preserves declaration order for a template-supplied target name") { + let path = fixturePath + "target_ordering_template_name_test.yml" + let project = try loadSpec(path: path) + + try expect(project.targets.map { $0.name }) == ["Zulu", "Apple"] + } + + $0.it("preserves declaration order for a variable-expanded target key") { + let path = fixturePath + "target_ordering_variable_test.yml" + let project = try loadSpec(path: path, variables: ["FIRST_TARGET": "Zebra"]) + + try expect(project.targets.map { $0.name }) == ["Zebra", "Apple"] + } } }