Add ability to define templateAttributes within a target

This allows parameterizing templates. Also change
placeholder syntax to `${placeholderName}` also for
existing placeholders `$target_name`and `$platform`
and generate warnings when using the old placeholder
syntax.
This commit is contained in:
Tom Quist 2019-03-17 14:20:01 +01:00
parent 8dd2ec89cc
commit aaae772d0e
8 changed files with 197 additions and 20 deletions

View File

@ -7,6 +7,10 @@
- Added ability to automatically include Carthage related dependencies via `includeRelated: true` [#506](https://github.com/yonaskolb/XcodeGen/pull/506) @rpassis
- Added ability to define a per-platform `deploymentTarget` for Multi-Platform targets. [#510](https://github.com/yonaskolb/XcodeGen/pull/510) @ainopara
- Added support for nested target templates [#534](https://github.com/yonaskolb/XcodeGen/pull/534) @tomquist
- Added ability to define `templateAttributes` within a target to be able to parameterize templates. [#533](https://github.com/yonaskolb/XcodeGen/pull/533) @tomquist
#### Changed
- **DEPRECATION**: Placeholders `$target_name` and `$platform` have been deprecated in favour of `${target_name}` and `${platform}`. Support for the old placeholders will be removed in a future version [#533](https://github.com/yonaskolb/XcodeGen/pull/533) @tomquist
#### Fixed
- Sources outside a project spec's directory will be correctly referenced as relative paths in the project file. [#524](https://github.com/yonaskolb/XcodeGen/pull/524)

View File

@ -203,7 +203,8 @@ Settings are merged in the following order: groups, base, configs.
- `CFBundleVersion`
- `CFBundlePackageType`
- [ ] **entitlements**: **[Plist](#plist)** - If defined this will generate and write a `.entitlements` file, and use it by setting `CODE_SIGN_ENTITLEMENTS` build setting for every configuration. All properties must be provided
- [ ] **templates**: **[String]** - A list of [Target Templates](#target-template) referenced by name that will be merged with the target in order. Any instances of `$target_name` within these templates will be replaced with the target name.
- [ ] **templates**: **[String]** - A list of [Target Templates](#target-template) referenced by name that will be merged with the target in order. Any instances of `${target_name}` within these templates will be replaced with the target name.
- [ ] **templateAttributes**: **[String: String]** - A list of attributes where each instance of `${attributeName}` within the templates listed in `templates` will be replaced with the value specified.
- [ ] **transitivelyLinkDependencies**: **Bool** - If this is not specified the value from the project set in [Options](#options)`.transitivelyLinkDependencies` will be used.
- [ ] **directlyEmbedCarthageDependencies**: **Bool** - If this is `true` Carthage dependencies will be embedded using an `Embed Frameworks` build phase instead of the `copy-frameworks` script. Defaults to `true` for all targets except iOS/tvOS/watchOS Applications.
- [ ] **requiresObjCLinking**: **Bool** - If this is `true` any targets that link to this target will have `-ObjC` added to their `OTHER_LDFLAGS`. This is required if a static library has any catagories or extensions on Objective-C code. See [this guide](https://pewpewthespells.com/blog/objc_linker_flags.html#objc) for more details. Defaults to `true` if `type` is `library.static`. If you are 100% sure you don't have catagories or extensions on Objective-C code (pure Swift with no use of Foundation/UIKit) you can set this to `false`, otherwise it's best to leave it alone.
@ -257,9 +258,9 @@ This will provide default build settings for a certain platform. It can be any o
You can also specify an array of platforms. This will generate a target for each platform.
If `deploymenTarget` is specified for a multi platform target, it can have different values per platform similar to how it's defined in [Options](#options). See below for an example.
If you reference the string `$platform` anywhere within the target spec, that will be replaced with the platform.
If you reference the string `${platform}` anywhere within the target spec, that will be replaced with the platform.
The generated targets by default will have a suffix of `_$platform` applied, you can change this by specifying a `platformSuffix` or `platformPrefix`.
The generated targets by default will have a suffix of `_${platform}` applied, you can change this by specifying a `platformSuffix` or `platformPrefix`.
If no `PRODUCT_NAME` build setting is specified for a target, this will be set to the target name, so that this target can be imported under a single name.
@ -276,9 +277,9 @@ targets:
base:
INFOPLIST_FILE: MyApp/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.myapp
MY_SETTING: platform $platform
MY_SETTING: platform ${platform}
groups:
- $platform
- ${platform}
```
The above will generate 2 targets named `MyFramework_iOS` and `MyFramework_tvOS`, with all the relevant platform build settings. They will both have a `PRODUCT_NAME` of `MyFramework`
@ -583,7 +584,8 @@ This is used to override settings or run build scripts in specific targets
## Target Template
This is a template that can be referenced from a normal target using the `templates` property. The properties of this template are the same as a [Target](#target)].
Any instances of `$target_name` within each template will be replaced by the final target name which references the template.
Any instances of `${target_name}` within each template will be replaced by the final target name which references the template.
Any attributes defined within a targets `templateAttributes` will be used to replace any attribute references in the template using the syntax `${attribute_name}`.
```yaml
@ -591,6 +593,8 @@ targets:
MyFramework:
templates:
- Framework
templateAttributes:
frameworkName: AwesomeFramework
sources:
- SomeSources
targetTemplates:
@ -598,7 +602,7 @@ targetTemplates:
platform: iOS
type: framework
sources:
- $target_name/Sources
- ${frameworkName}/${target_name}
```
## Scheme

View File

@ -1,4 +1,5 @@
import Foundation
import JSONUtilities
import PathKit
extension Project {

View File

@ -4,6 +4,10 @@ public struct SpecValidationError: Error, CustomStringConvertible {
public var errors: [ValidationError]
public init(errors: [ValidationError]) {
self.errors = errors
}
public enum ValidationError: Error, CustomStringConvertible {
case invalidXcodeGenVersion(minimumVersion: Version, version: Version)
case invalidSDKDependency(target: String, dependency: String)
@ -23,6 +27,7 @@ public struct SpecValidationError: Error, CustomStringConvertible {
case missingConfigForTargetScheme(target: String, configType: ConfigType)
case missingDefaultConfig(configName: String)
case invalidPerConfigSettings
case deprecatedUsageOfPlaceholder(placeholderName: String)
public var description: String {
switch self {
@ -62,6 +67,8 @@ public struct SpecValidationError: Error, CustomStringConvertible {
return "Default configuration \(name) doesn't exist"
case .invalidPerConfigSettings:
return "Settings that are for a specific config must go in \"configs\". \"base\" can be used for common settings"
case let .deprecatedUsageOfPlaceholder(placeholderName: placeholderName):
return "Usage of $\(placeholderName) is deprecated and will stop working in an upcoming version. Use ${\(placeholderName)} instead."
}
}
}

View File

@ -168,7 +168,13 @@ extension Target {
}
}
target = target.merged(onto: mergedDictionary)
target = target.replaceString("$target_name", with: targetName)
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
}
@ -192,7 +198,8 @@ extension Target {
for platform in platforms {
var platformTarget = target
platformTarget = platformTarget.replaceString("$platform", with: platform)
platformTarget = platformTarget.replaceString("$platform", with: platform) // Will be removed in upcoming version
platformTarget = platformTarget.replaceString("${platform}", with: platform)
platformTarget["platform"] = platform
let platformSuffix = platformTarget["platformSuffix"] as? String ?? "_\(platform)"

View File

@ -64,6 +64,13 @@ class GenerateCommand: Command {
throw GenerationError.projectSpecParsingError(error)
}
// validate project dictionary
do {
try specLoader.validateProjectDictionaryWarnings()
} catch {
warning("\(error)")
}
let projectPath = projectDirectory + "\(project.name).xcodeproj"
let cacheFilePath = self.cacheFilePath.value ??
@ -143,6 +150,12 @@ class GenerateCommand: Command {
}
}
func warning(_ string: String) {
if !quiet.value {
stdout.print(string.yellow)
}
}
func success(_ string: String) {
if !quiet.value {
stdout.print(string.green)

View File

@ -26,6 +26,10 @@ public class SpecLoader {
return project
}
public func validateProjectDictionaryWarnings() throws {
try projectDictionary?.validateWarnings()
}
public func generateCacheFile() throws -> CacheFile? {
guard let projectDictionary = projectDictionary,
let project = project else {
@ -38,3 +42,36 @@ public class SpecLoader {
)
}
}
private extension Dictionary where Key == String, Value: Any {
func validateWarnings() throws {
var errors: [SpecValidationError.ValidationError] = []
if hasValueContaining("$target_name") {
errors.append(.deprecatedUsageOfPlaceholder(placeholderName: "target_name"))
}
if hasValueContaining("$platform") {
errors.append(.deprecatedUsageOfPlaceholder(placeholderName: "platform"))
}
if !errors.isEmpty {
throw SpecValidationError(errors: errors)
}
}
func hasValueContaining(_ needle: String) -> Bool {
return values.contains { value in
switch value {
case let dictionary as JSONDictionary:
return dictionary.hasValueContaining(needle)
case let string as String:
return string.contains(needle)
case let array as [JSONDictionary]:
return array.contains { $0.hasValueContaining(needle) }
case let array as [String]:
return array.contains { $0.contains(needle) }
default:
return false
}
}
}
}

View File

@ -5,6 +5,7 @@ import Spectre
import XcodeGenKit
import xcodeproj
import XCTest
import Yams
class SpecLoadingTests: XCTestCase {
@ -205,6 +206,79 @@ class SpecLoadingTests: XCTestCase {
}
}
func testSpecWarningValidation() {
describe {
var path: Path!
$0.before {
path = Path(components: [NSTemporaryDirectory(), "\(NSUUID().uuidString).yaml"])
}
$0.after {
try? FileManager.default.removeItem(atPath: path.string)
}
$0.it("fails validating warnings for deprecated placeholder usage") {
let dictionary: [String: Any] = [
"name": "TestSpecWarningValidation",
"templates": [
"Framework": [
"type": "framework",
"sources": ["$target_name/$platform/Sources"],
],
],
"targets": [
"Framework": [
"type": "framework",
"platform": "iOS",
"templates": ["Framework"],
],
],
]
try? Yams.dump(object: dictionary).write(toFile: path.string, atomically: true, encoding: .utf8)
let specLoader = SpecLoader(version: "1.1.0")
do {
_ = try specLoader.loadProject(path: path)
} catch {
throw failure("\(error)")
}
try expectError(SpecValidationError(errors: [
.deprecatedUsageOfPlaceholder(placeholderName: "target_name"),
.deprecatedUsageOfPlaceholder(placeholderName: "platform"),
]), { try specLoader.validateProjectDictionaryWarnings() })
}
$0.it("successfully validates warnings for new placeholder usage") {
let dictionary: [String: Any] = [
"name": "TestSpecWarningValidation",
"templates": [
"Framework": [
"type": "framework",
"sources": ["${target_name}/${platform}/Sources"],
],
],
"targets": [
"Framework": [
"type": "framework",
"platform": "iOS",
"templates": ["Framework"],
],
],
]
try? Yams.dump(object: dictionary).write(toFile: path.string, atomically: true, encoding: .utf8)
let specLoader = SpecLoader(version: "1.1.0")
do {
_ = try specLoader.loadProject(path: path)
} catch {
throw failure("\(error)")
}
do {
try specLoader.validateProjectDictionaryWarnings()
} catch {
throw failure("Expected to not throw a validation error. Got: \(error)")
}
}
}
}
func testProjectSpecParser() {
let validTarget: [String: Any] = ["type": "application", "platform": "iOS"]
let invalid = "invalid"
@ -315,7 +389,7 @@ class SpecLoadingTests: XCTestCase {
"platform": ["iOS", "tvOS"],
"deploymentTarget": ["iOS": 9.0, "tvOS": "10.0"],
"type": "framework",
"sources": ["Framework", "Framework $platform"],
"sources": ["Framework", "Framework ${platform}"],
"settings": ["SETTING": "value_$platform"],
]
@ -340,6 +414,9 @@ class SpecLoadingTests: XCTestCase {
"deploymentTarget": "1.2.0",
"sources": ["targetSource"],
"templates": ["temp2", "temp"],
"templateAttributes": [
"source": "replacedSource"
]
]
let project = try getProjectSpec([
@ -353,7 +430,11 @@ class SpecLoadingTests: XCTestCase {
"type": "framework",
"platform": "tvOS",
"deploymentTarget": "1.1.0",
"configFiles": ["debug": "Configs/$target_name/debug.xcconfig"],
"configFiles": [
"debug": "Configs/$target_name/debug.xcconfig",
"release": "Configs/${target_name}/release.xcconfig"
],
"sources": ["${source}"]
],
],
])
@ -362,8 +443,9 @@ class SpecLoadingTests: XCTestCase {
try expect(target.type) == .framework // uses value
try expect(target.platform) == .iOS // uses latest value
try expect(target.deploymentTarget) == Version("1.2.0") // keeps value
try expect(target.sources) == ["templateSource", "targetSource"] // merges array in order
try expect(target.sources) == ["replacedSource", "templateSource", "targetSource"] // merges array in order
try expect(target.configFiles["debug"]) == "Configs/Framework/debug.xcconfig" // replaces $target_name
try expect(target.configFiles["release"]) == "Configs/Framework/release.xcconfig" // replaces ${target_name}
}
$0.it("parses nested target templates") {
@ -411,6 +493,11 @@ class SpecLoadingTests: XCTestCase {
"platform": "iOS",
"templates": ["temp"],
"sources": ["target"],
"templateAttributes": [
"temp": "temp-by-target",
"a": "a-by-target",
"b": "b-by-target" // This should win over attributes defined in template "temp"
]
]
let project = try getProjectSpec([
@ -418,33 +505,50 @@ class SpecLoadingTests: XCTestCase {
"targetTemplates": [
"temp": [
"templates": ["a", "d"],
"sources": ["temp"],
"sources": ["temp", "${temp}"],
"templateAttributes": [
"b": "b-by-temp",
"c": "c-by-temp",
"d": "d-by-temp"
]
],
"a": [
"templates": ["b", "c"],
"sources": ["a"],
"sources": ["a", "${a}"],
"templateAttributes": [
"c": "c-by-a"
]
],
"b": [
"sources": ["b"],
"sources": ["b", "${b}"],
],
"c": [
"sources": ["c"],
"sources": ["c", "${c}"],
],
"d": [
"sources": ["d"],
"sources": ["d", "${d}"],
"templates": ["e"],
"templateAttributes": [
"e": "e-by-d"
]
],
"e": [
"sources": ["e"],
"sources": ["e", "${e}"],
],
],
])
])
let target = project.targets.first!
try expect(target.type) == .framework // uses value of last nested template
try expect(target.platform) == .iOS // uses latest value
try expect(target.sources) == ["b", "c", "a", "e", "d", "temp", "target"] // merges array in order
try expect(target.sources) == ["b", "b-by-target",
"c", "c-by-temp",
"a", "a-by-target",
"e", "e-by-d",
"d", "d-by-temp",
"temp", "temp-by-target",
"target"] // merges array in order
}
$0.it("parses nested target templates with cycle") {