How to provide configurations in Swift

Issue #522

Sometimes ago I created Puma, which is a thin wrapper around Xcode commandline tools, for example xcodebuild

There’s lots of arguments to pass in xcodebuild, and there are many tasks like build, test and archive that all uses this command.

Use Options struct to encapsulate parameters

To avoid passing many parameters into a class, I tend to make an Options struct to encapsulate all passing parameters. I also use composition, where Build.Options and Test.Options contains Xcodebuild.Options

This ensures that the caller must provide all needed parameters, when you can compile you are ensured that all required parameters are provided.

This is OK, but a bit rigid in a way that there are many more parameters we can pass into xcodebuild command, so we must provide a way for user to alter or add more parameters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
let xcodebuildOptions = Xcodebuild.Options(
workspace: nil,
project: "TestApp",
scheme: "TestApp",
configuration: Configuration.release,
sdk: Sdk.iPhone,
signing: .auto(automaticSigning),
usesModernBuildSystem: true
)

run {
SetVersionNumber(options: .init(buildNumber: "1.1"))
SetBuildNumber(options: .init(buildNumber: "2"))
Build(options: .init(
buildOptions: xcodebuildOptions,
buildsForTesting: true
))

Test(options: .init(
buildOptions: xcodebuildOptions,
destination: Destination(
platform: Destination.Platform.iOSSimulator,
name: Destination.Name.iPhoneXr,
os: Destination.OS.os12_2
)
))
}

Here is how to convert from Options to arguments to pass to our command. Because each parameter has different specifiers, like with double hyphens --flag=true, single hyphen -flag=true or just hyphen with a space between parameter key and value -flag true, we need to manually specify that, and concat them with string. Luckily, the order of parameters is not important

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public struct Xcodebuild {
public struct Options {
/// build the workspace NAME
public let workspace: String?
/// build the project NAME
public let project: String
/// build the scheme NAME
public let scheme: String
/// use the build configuration NAME for building each target
public let configuration: String
/// use SDK as the name or path of the base SDK when building the project
public let sdk: String?
public let signing: Signing?
public let usesModernBuildSystem: Bool

public init(
workspace: String? = nil,
project: String,
scheme: String,
configuration: String = Configuration.debug,
sdk: String? = Sdk.iPhoneSimulator,
signing: Signing? = nil,
usesModernBuildSystem: Bool = true) {

self.workspace = workspace
self.project = project
self.scheme = scheme
self.configuration = configuration
self.sdk = sdk
self.signing = signing
self.usesModernBuildSystem = usesModernBuildSystem
}
}
}

extension Xcodebuild.Options {
func toArguments() -> [String?] {
return [
workspace.map{ "-workspace \($0.addingFileExtension("xcworkspace"))" },
"-project \(project.addingFileExtension("xcodeproj"))",
"-scheme \(scheme)",
"-configuration \(configuration)",
sdk.map { "-sdk \($0)" },
"-UseModernBuildSystem=\(usesModernBuildSystem ? "YES": "NO")"

]
}
}

Use convenient methods

Another way is to have a Set<String> as a container of parameters, and provide common method via protocol extension

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/// Any task that uses command line
public protocol UsesCommandLine: AnyObject {
var program: String { get }
var arguments: Set<String> { get set }
}

public extension UsesCommandLine {
func run() throws {
let command = "\(program) \(arguments.joined(separator: " "))"
Log.command(command)
_ = try Process().run(command: command)
}
}

/// Any task that uses xcodebuild
public protocol UsesXcodeBuild: UsesCommandLine {}

public extension UsesXcodeBuild {
var program: String { "xcodebuild" }

func `default`(project: String, scheme: String) {
self.project(project)
self.scheme(scheme)
self.configuration(Configuration.debug)
self.sdk(Sdk.iPhoneSimulator)
self.usesModernBuildSystem(enabled: true)
}

func project(_ name: String) {
arguments.insert("-project \(name.addingFileExtension("xcodeproj"))")
}

func workspace(_ name: String) {
arguments.insert("-workspace \(name.addingFileExtension("xcworkspace"))")
}

func scheme(_ name: String) {
arguments.insert("-scheme \(name)")
}

func configuration(_ configuration: String) {
arguments.insert("-configuration \(configuration)")
}

func sdk(_ sdk: String) {
arguments.insert("-sdk \(sdk)")
}

func usesModernBuildSystem(enabled: Bool) {
arguments.insert("-UseModernBuildSystem=\(enabled ? "YES": "NO")")
}
}

class Build: Task, UsesXcodeBuild {}
class Test: Task, UsesXcodeBuild {}

Now the call site looks like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
run {
SetVersionNumber {
$0.versionNumberForAllTargets("1.1")
}

SetBuildNumber {
$0.buildNumberForAllTargets("2")
}

Build {
$0.default(project: "TestApp", scheme: "TestApp")
$0.buildsForTesting(enabled: true)
}

Test {
$0.default(project: "TestApp", scheme: "TestApp")
$0.testsWithoutBuilding(enabled: true)
$0.destination(Destination(
platform: Destination.Platform.iOSSimulator,
name: Destination.Name.iPhoneXr,
os: Destination.OS.os12_2
))
}
}

Comments