diff --git a/CHANGELOG.md b/CHANGELOG.md index a9606ccc..3b3069e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next Version +#### Added + +- Scheme Templates [#672](https://github.com/yonaskolb/XcodeGen/pull/672) @bclymer + #### Fixed - Fixed macOS unit test setting preset [#665](https://github.com/yonaskolb/XcodeGen/pull/665) @yonaskolb - Add `rcproject` files to sources build phase instead of resources [#669](https://github.com/yonaskolb/XcodeGen/pull/669) @Qusic diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index 0321f222..204cd230 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -23,6 +23,7 @@ - [Aggregate Target](#aggregate-target) - [Target Template](#target-template) - [Scheme](#scheme) + - [Scheme Template](#scheme-template) - [Swift Package](#swift-package) ## General @@ -781,6 +782,39 @@ schemes: revealArchiveInOrganizer: false ``` +### Scheme Template + +This is a template that can be referenced from a normal scheme using the `templates` property. The properties of this template are the same as a [Scheme](#scheme). This functions identically in practice to [Target Template](#target-template). +Any instances of `${scheme_name}` within each template will be replaced by the final scheme name which references the template. +Any attributes defined within a scheme's `templateAttributes` will be used to replace any attribute references in the template using the syntax `${attribute_name}`. + +```yaml +schemes: + MyModule: + templates: + - FeatureModuleScheme + templateAttributes: + testTargetName: MyModuleTests + +schemeTemplates: + FeatureModuleScheme: + templates: + - TestScheme + build: + targets: + ${scheme_name}: build + + TestScheme: + test: + gatherCoverageData: true + targets: + - name: ${testTargetName} + parallelizable: true + randomExecutionOrder: true +``` + +The result will be a scheme that builds `MyModule` when you request a build, and will test against `MyModuleTests` when you request to run tests. This is particularly useful when you work in a very modular application and each module has a similar structure. + ## Swift Package Swift packages are defined at a project level, and then linked to individual targets via a [Dependency](#dependency). @@ -804,4 +838,4 @@ targets: App: dependencies: - package: Yams -``` \ No newline at end of file +``` diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index 3e2f1e0d..da092574 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -153,7 +153,7 @@ extension Project { public init(basePath: Path = "", jsonDictionary: JSONDictionary) throws { self.basePath = basePath - let jsonDictionary = try Project.resolveProject(jsonDictionary: jsonDictionary) + let jsonDictionary = Project.resolveProject(jsonDictionary: jsonDictionary) name = try jsonDictionary.json(atKeyPath: "name") settings = jsonDictionary.json(atKeyPath: "settings") ?? .empty @@ -184,14 +184,15 @@ extension Project { aggregateTargetsMap = Dictionary(uniqueKeysWithValues: aggregateTargets.map { ($0.name, $0) }) } - static func resolveProject(jsonDictionary: JSONDictionary) throws -> JSONDictionary { + static func resolveProject(jsonDictionary: JSONDictionary) -> JSONDictionary { var jsonDictionary = jsonDictionary // resolve multiple times so that we support both multi-platform templates, // as well as platform specific templates in multi-platform targets - jsonDictionary = try Target.resolveMultiplatformTargets(jsonDictionary: jsonDictionary) - jsonDictionary = try Target.resolveTargetTemplates(jsonDictionary: jsonDictionary) - jsonDictionary = try Target.resolveMultiplatformTargets(jsonDictionary: jsonDictionary) + jsonDictionary = Target.resolveMultiplatformTargets(jsonDictionary: jsonDictionary) + jsonDictionary = Target.resolveTargetTemplates(jsonDictionary: jsonDictionary) + jsonDictionary = Scheme.resolveSchemeTemplates(jsonDictionary: jsonDictionary) + jsonDictionary = Target.resolveMultiplatformTargets(jsonDictionary: jsonDictionary) return jsonDictionary } diff --git a/Sources/ProjectSpec/Scheme.swift b/Sources/ProjectSpec/Scheme.swift index cc53b13a..f7ea7627 100644 --- a/Sources/ProjectSpec/Scheme.swift +++ b/Sources/ProjectSpec/Scheme.swift @@ -238,7 +238,7 @@ public struct Scheme: Equatable { } } - public struct BuildTarget: Equatable { + public struct BuildTarget: Equatable, Hashable { public var target: String public var buildTypes: [BuildType] diff --git a/Sources/ProjectSpec/SpecFile.swift b/Sources/ProjectSpec/SpecFile.swift index 6983f0ab..ef2ba36d 100644 --- a/Sources/ProjectSpec/SpecFile.swift +++ b/Sources/ProjectSpec/SpecFile.swift @@ -158,7 +158,11 @@ extension Dictionary where Key == String, Value: Any { func replaceString(_ template: String, with replacement: String) -> JSONDictionary { var replaced: JSONDictionary = self for (key, value) in self { - replaced[key] = replace(value: value, template, with: replacement) + let newKey = key.replacingOccurrences(of: template, with: replacement) + if newKey != key { + replaced.removeValue(forKey: key) + } + replaced[newKey] = replace(value: value, template, with: replacement) } return replaced } diff --git a/Sources/ProjectSpec/Target.swift b/Sources/ProjectSpec/Target.swift index e305cf40..8fa85a66 100644 --- a/Sources/ProjectSpec/Target.swift +++ b/Sources/ProjectSpec/Target.swift @@ -134,59 +134,7 @@ extension Target: PathContainer { extension Target { - static func resolveTargetTemplates(jsonDictionary: JSONDictionary) throws -> JSONDictionary { - guard var targetsDictionary: [String: JSONDictionary] = jsonDictionary["targets"] as? [String: JSONDictionary] else { - return jsonDictionary - } - - let targetTemplatesDictionary: [String: JSONDictionary] = jsonDictionary["targetTemplates"] as? [String: JSONDictionary] ?? [:] - - // Recursively collects all nested template names of a given dictionary. - func collectTemplates(of jsonDictionary: JSONDictionary, - into allTemplates: inout [String], - insertAt insertionIndex: inout Int) { - guard let templates = jsonDictionary["templates"] as? [String] else { - return - } - for template in templates where !allTemplates.contains(template) { - guard let templateDictionary = targetTemplatesDictionary[template] else { - continue - } - allTemplates.insert(template, at: insertionIndex) - collectTemplates(of: templateDictionary, into: &allTemplates, insertAt: &insertionIndex) - insertionIndex += 1 - } - } - - for (targetName, var target) in targetsDictionary { - var templates: [String] = [] - var index: Int = 0 - collectTemplates(of: target, into: &templates, insertAt: &index) - if !templates.isEmpty { - var mergedDictionary: JSONDictionary = [:] - for template in templates { - if let templateDictionary = targetTemplatesDictionary[template] { - mergedDictionary = templateDictionary.merged(onto: mergedDictionary) - } - } - target = target.merged(onto: mergedDictionary) - target = target.replaceString("$target_name", with: targetName) // Will be removed in upcoming version - target = target.replaceString("${target_name}", with: targetName) - if let templateAttributes = target["templateAttributes"] as? [String: String] { - for (templateAttribute, value) in templateAttributes { - target = target.replaceString("${\(templateAttribute)}", with: value) - } - } - } - targetsDictionary[targetName] = target - } - - var jsonDictionary = jsonDictionary - jsonDictionary["targets"] = targetsDictionary - return jsonDictionary - } - - static func resolveMultiplatformTargets(jsonDictionary: JSONDictionary) throws -> JSONDictionary { + static func resolveMultiplatformTargets(jsonDictionary: JSONDictionary) -> JSONDictionary { guard let targetsDictionary: [String: JSONDictionary] = jsonDictionary["targets"] as? [String: JSONDictionary] else { return jsonDictionary } diff --git a/Sources/ProjectSpec/Template.swift b/Sources/ProjectSpec/Template.swift new file mode 100644 index 00000000..974fe3d4 --- /dev/null +++ b/Sources/ProjectSpec/Template.swift @@ -0,0 +1,78 @@ +import Foundation +import JSONUtilities + +struct TemplateStructure { + let baseKey: String + let templatesKey: String + let nameToReplace: String +} + +extension Target { + static func resolveTargetTemplates(jsonDictionary: JSONDictionary) -> JSONDictionary { + return resolveTemplates(jsonDictionary: jsonDictionary, + templateStructure: TemplateStructure(baseKey: "targets", + templatesKey: "targetTemplates", + nameToReplace: "target_name")) + } +} + +extension Scheme { + static func resolveSchemeTemplates(jsonDictionary: JSONDictionary) -> JSONDictionary { + return resolveTemplates(jsonDictionary: jsonDictionary, + templateStructure: TemplateStructure(baseKey: "schemes", + templatesKey: "schemeTemplates", + nameToReplace: "scheme_name")) + } +} + +private func resolveTemplates(jsonDictionary: JSONDictionary, templateStructure: TemplateStructure) -> JSONDictionary { + guard var baseDictionary: [String: JSONDictionary] = jsonDictionary[templateStructure.baseKey] as? [String: JSONDictionary] else { + return jsonDictionary + } + + let templatesDictionary: [String: JSONDictionary] = jsonDictionary[templateStructure.templatesKey] as? [String: JSONDictionary] ?? [:] + + // Recursively collects all nested template names of a given dictionary. + func collectTemplates(of jsonDictionary: JSONDictionary, + into allTemplates: inout [String], + insertAt insertionIndex: inout Int) { + guard let templates = jsonDictionary["templates"] as? [String] else { + return + } + for template in templates where !allTemplates.contains(template) { + guard let templateDictionary = templatesDictionary[template] else { + continue + } + allTemplates.insert(template, at: insertionIndex) + collectTemplates(of: templateDictionary, into: &allTemplates, insertAt: &insertionIndex) + insertionIndex += 1 + } + } + + for (referenceName, var reference) in baseDictionary { + var templates: [String] = [] + var index: Int = 0 + collectTemplates(of: reference, into: &templates, insertAt: &index) + if !templates.isEmpty { + var mergedDictionary: JSONDictionary = [:] + for template in templates { + if let templateDictionary = templatesDictionary[template] { + mergedDictionary = templateDictionary.merged(onto: mergedDictionary) + } + } + reference = reference.merged(onto: mergedDictionary) + reference = reference.replaceString("$\(templateStructure.nameToReplace)", with: referenceName) // Will be removed in upcoming version + reference = reference.replaceString("${\(templateStructure.nameToReplace)}", with: referenceName) + if let templateAttributes = reference["templateAttributes"] as? [String: String] { + for (templateAttribute, value) in templateAttributes { + reference = reference.replaceString("${\(templateAttribute)}", with: value) + } + } + } + baseDictionary[referenceName] = reference + } + + var jsonDictionary = jsonDictionary + jsonDictionary[templateStructure.baseKey] = baseDictionary + return jsonDictionary +} diff --git a/Tests/XcodeGenKitTests/SpecLoadingTests.swift b/Tests/XcodeGenKitTests/SpecLoadingTests.swift index 0b80fc2a..a01a4bf9 100644 --- a/Tests/XcodeGenKitTests/SpecLoadingTests.swift +++ b/Tests/XcodeGenKitTests/SpecLoadingTests.swift @@ -853,6 +853,121 @@ class SpecLoadingTests: XCTestCase { try expect(scheme.profile?.environmentVariables.isEmpty) == true } + $0.it("parses scheme templates") { + let targetDictionary: [String: Any] = [ + "deploymentTarget": "1.2.0", + "sources": ["targetSource"], + "templates": ["temp2", "temp"], + "templateAttributes": [ + "source": "replacedSource", + ], + ] + + let project = try getProjectSpec([ + "targets": ["Framework": targetDictionary], + "targetTemplates": [ + "temp": [ + "platform": "iOS", + "sources": [ + "templateSource", + ["path": "Sources/${target_name}"] + ], + ], + "temp2": [ + "type": "framework", + "platform": "tvOS", + "deploymentTarget": "1.1.0", + "configFiles": [ + "debug": "Configs/$target_name/debug.xcconfig", + "release": "Configs/${target_name}/release.xcconfig", + ], + "sources": ["${source}"], + ], + ], + "schemeTemplates": [ + "base_scheme": [ + "build": [ + "parallelizeBuild": false, + "buildImplicitDependencies": false, + "targets": [ + "Target${name_1}": "all", + "Target2": "testing", + "Target${name_3}": "none", + "Target4": ["testing": true], + "Target5": ["testing": false], + "Target6": ["test", "analyze"], + ], + "preActions": [ + [ + "script": "${pre-action-name}", + "name": "Before Build ${scheme_name}", + "settingsTarget": "Target${name_1}", + ], + ], + ], + "test": [ + "config": "debug", + "targets": [ + "Target${name_1}", + [ + "name": "Target2", + "parallelizable": true, + "randomExecutionOrder": true, + "skippedTests": ["Test/testExample()"], + ], + ], + "gatherCoverageData": true, + "disableMainThreadChecker": true, + ], + ], + ], + "schemes": [ + "temp2": [ + "templates": ["base_scheme"], + "templateAttributes": [ + "pre-action-name": "modified-name", + "name_1": "FirstTarget", + "name_3": "ThirdTarget", + ], + ], + ], + ]) + + let scheme = project.schemes.first! + let expectedTargets: [Scheme.BuildTarget] = [ + Scheme.BuildTarget(target: "TargetFirstTarget", buildTypes: BuildType.all), + Scheme.BuildTarget(target: "Target2", buildTypes: [.testing, .analyzing]), + Scheme.BuildTarget(target: "TargetThirdTarget", buildTypes: []), + Scheme.BuildTarget(target: "Target4", buildTypes: [.testing]), + Scheme.BuildTarget(target: "Target5", buildTypes: []), + Scheme.BuildTarget(target: "Target6", buildTypes: [.testing, .analyzing]), + ] + try expect(scheme.name) == "temp2" + try expect(Set(scheme.build.targets)) == Set(expectedTargets) + try expect(scheme.build.preActions.first?.script) == "modified-name" + try expect(scheme.build.preActions.first?.name) == "Before Build temp2" + try expect(scheme.build.preActions.first?.settingsTarget) == "TargetFirstTarget" + + try expect(scheme.build.parallelizeBuild) == false + try expect(scheme.build.buildImplicitDependencies) == false + + let expectedTest = Scheme.Test( + config: "debug", + gatherCoverageData: true, + disableMainThreadChecker: true, + targets: [ + "TargetFirstTarget", + Scheme.Test.TestTarget( + name: "Target2", + randomExecutionOrder: true, + parallelizable: true, + skippedTests: ["Test/testExample()"] + ), + ] + ) + try expect(scheme.test) == expectedTest + } + $0.it("parses settings") { let project = try Project(path: fixturePath + "settings_test.yml") let buildSettings: BuildSettings = ["SETTING": "value"]