In past few episodes we explored how Swift doesn’t always place structs and enums on equal footing, and in particular, we identified how struct data access is far easier and more ergonomic than enum data access: struct properties can be accessed via succinct dot-syntax, while enums require some very gnarly pattern matching code that results in a lot of extra noise, ceremony, and typing.
Luckily, we found that we can fix this problem ourselves by writing what we call “enum properties”: computed properties per enum case that optionally return associated data when the case matches. This simple bit of boilerplate made working with enum data just as ergonomic as working with struct data. But it’s quite inconvenient to write by hand, and it’s a tall ask to do so in code bases that may contain dozens or even hundreds of enum cases.
So in order to fully embrace enum properties as something we can use in our code bases, we looked to another tool: code generation. We have been building a own code generation tool to solve this problem. It’s a Swift package that uses Swift Syntax, Apple’s relatively new library for parsing and inspecting Swift source code. Swift Syntax lets us walk over and inspect every token of a given Swift source file, so we used it to look for enums and enum cases in order to automatically generate enum properties for each case!
Our code is almost ready to go, but it’s still stuck in a playground. We’ll now take the final steps needed to make this tool usable in our applications. We will extract our playground code to a library, snapshot test it in a very interesting way, and create an executable that can be installed and used in other code bases.
We’ve been working on our library entirely from within a playground. We love playground driven development at Point-Free and have talked about it at length in a previous episode. Playgrounds give us an environment with a rapid feedback loop that let us iterate on problems quickly. It helped us build the bulk of our code generation tool, but now it’s time to extract things to a standalone library.
Here’s what we have so far:
import Foundation
import SwiftSyntax
let url = Bundle.main.url(
forResource: "Enums", withExtension: "swift"
)!
let tree = try SyntaxTreeParser.parse(url)
class Visitor: SyntaxVisitor {
override func visit(
_ node: EnumDeclSyntax
) -> SyntaxVisitorContinueKind {
print("extension \(node.identifier.withoutTrivia()) {")
return .visitChildren
}
override func visit(
_ node: EnumCaseElementSyntax
) -> SyntaxVisitorContinueKind {
let propertyType: String
let pattern: String
let returnValue: String
if let associatedValue = node.associatedValue {
propertyType = associatedValue.parameterList.count == 1
? "\(associatedValue.parameterList[0].type!)"
: "\(associatedValue)"
pattern = "let .\(node.identifier)(value)"
returnValue = "value"
} else {
propertyType = "Void"
pattern = ".\(node.identifier)"
returnValue = "()"
}
print(" var \(node.identifier): \(propertyType)? {")
print(" guard case \(pattern) = self else { return nil }")
print(" return \(returnValue)")
print(" }")
let identifier = "\(node.identifier)"
let capitalizedIdentifier =
"\(identifier.first!.uppercased())\(identifier.dropFirst())"
print(" var is\(capitalizedIdentifier): Bool {")
print(" return self.\(node.identifier) != nil")
print(" }")
return .skipChildren
}
override func visitPost(_ node: Syntax) {
if node is EnumDeclSyntax {
print("}")
}
}
}
let visitor = Visitor()
tree.walk(visitor)
Our playground is quite simple: we first load up a URL for an Enums.swift
fixture that contains a bunch of enums that we generate properties for. Then we feed it to SwiftSyntax’s SyntaxTreeParser
, which returns a parsed tree of Swift source code tokens.
After that we defined a Visitor
class, which is a subclass of SwiftSyntax’s SyntaxVisitor
and it provides an API for inspecting every different kind of syntax Swift has. We hooked into the visit
methods of the few bits of syntax we care about, which includes enums and enum cases, and using these nodes we were able to generate source code for enum properties along the way.
Finally, we instantiated a visitor and passed it to the tree parser’s walk
method, which tells the visitor to walk over each node so that it can print out our properties.
The real workhorse of everything we’ve done so far is the Visitor
class. If we were to extract that into its own library, we could maybe invoke it from our playground or some kind of CLI tool in order to get the code generation output, and then save that source code somewhere.
Swift packages are organized by directory. All of a package’s libraries and executables live inside the top-level Sources
directory as subdirectories that have the same name as the library or executable. When initializing a package, the Swift package manager uses the name of the current directory to generate a package and library of the same name. So when we ran swift package init
in a directory called EnumProperties
, it generated an EnumProperties
library, and corresponding Sources
directory with a single placeholder file, in this case EnumProperties.swift
.
struct EnumProperties {
var text = "Hello, World!"
}
Let’s replace the contents of this file with the core part of the work we’ve done so far: the Visitor
class. And let’s make it public, so we can import it into other modules.
import SwiftSyntax
public class Visitor: SyntaxVisitor {
override public func visit(
_ node: EnumDeclSyntax
) -> SyntaxVisitorContinueKind {
print("extension \(node.identifier.withoutTrivia()) {")
return .visitChildren
}
override public func visit(
_ node: EnumCaseElementSyntax
) -> SyntaxVisitorContinueKind {
let propertyType: String
let pattern: String
let returnValue: String
if let associatedValue = node.associatedValue {
propertyType = associatedValue.parameterList.count == 1
? "\(associatedValue.parameterList[0].type!)"
: "\(associatedValue)"
pattern = "let .\(node.identifier)(value)"
returnValue = "value"
} else {
propertyType = "Void"
pattern = ".\(node.identifier)"
returnValue = "()"
}
print(" var \(node.identifier): \(propertyType)? {")
print(" guard case \(pattern) = self else { return nil }")
print(" return \(returnValue)")
print(" }")
let identifier = "\(node.identifier)"
let capitalizedIdentifier =
"\(identifier.first!.uppercased())\(identifier.dropFirst())"
print(" var is\(capitalizedIdentifier): Bool {")
print(" return self.\(node.identifier) != nil")
print(" }")
return .skipChildren
}
override public func visitPost(_ node: Syntax) {
if node is EnumDeclSyntax {
print("}")
}
}
}
Everything builds just fine, and we can clean up our playground to use the updated library implementation.
import EnumProperties
import Foundation
import SwiftSyntax
let url = Bundle.main.url(
forResource: "Enums", withExtension: "swift"
)!
let tree = try SyntaxTreeParser.parse(url)
let visitor = Visitor()
tree.walk(visitor)
The Swift Package Manager also generates a Tests
directory with a subdirectory per test target. Before going any further, let’s try to write a test for our Visitor
so that we can ensure nothing breaks during future refactors.
Here we have the contents of EnumPropertiesTests.swift
:
import XCTest
@testable import EnumProperties
final class EnumPropertiesTests: XCTestCase {
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests
// produce the correct results.
XCTAssertEqual(EnumProperties().text, "Hello, World!")
}
static var allTests = [
("testExample", testExample),
]
}
It has an example test and a static allTests
property, which is needed for Linux.
Let’s reindent the file and replace testExample
with some code that exercises our syntax visitor. We want to perform the same work that we were previously performing in the playground, so let’s copy and paste it to get started.
import SwiftSyntax
final class EnumPropertiesTests: XCTestCase {
func testExample() {
let url = Bundle.main.url(
forResource: "Enums", withExtension: "swift"
)!
let tree = try SyntaxTreeParser.parse(url)
let visitor = Visitor()
tree.walk(visitor)
}
We’re going to have to make a few changes from the work we did before because we no longer have a bundle with an “Enums.swift” resource to load.
Instead of adding this file as a resource, let’s include the source as a fixture that can be compiled alongside our tests. We can start by adding a Fixtures
group to EnumPropertiesTests
to keep things organized. And then we can drag Enums.swift
over
Now we’re ready to load this fixture in our test. The location of this fixture is relative to our test file, so we can use the magic #file
identifier, which is a static string representation of the current file URL (in this case our test), and we can use some URL
methods to get the fixture URL.
func testExample() {
let url = URL(fileURLWithPath: String(#file))
.deletingLastPathComponent()
.appendingPathComponent("Fixtures")
.appendingPathComponent("Enums.swift")
Our next line has a problem:
let tree = try SyntaxTreeParser.parse(url)
Errors thrown from here are not handled
And in order for XCTest to handle this error, we can update our test to be throwing.
func testExample() throws {
Now this is an oft-overlooked feature of XCTest that is quite nice. Any test function can be made to be throwing and, should something in the test throw an error, the test will fail using all of the XCTest machinery. This is a nice alternative to introducing a do
scope with all of its ceremony, or force-try!
-ing, which can cause the test process to crash.
We can now run our test and confirm in the console that it’s printing out some enum properties.
Alright, we have a test that builds and runs and prints out some enum properties, but we’re not really testing anything! We can’t write any assertions against our syntax visitor because it’s living completely in the land of side effects: it’s printing everything immediately to the console. Instead we could maybe build up a value over time, and then only at the end print it.
This is a common theme in how we like to structure our code on Point-Free: we try to push side effects to the boundary of our programs. By building up a value over time, we’ll have something we can inspect and assert against, and then at the end, we can also choose to print it to the console!
Let’s start by adding an output
property to our visitor that starts as an empty string. We can even use private(set)
to make the user-facing interface immutable.
public class Visitor: SyntaxVisitor {
public private(set) var output = ""
We don’t have to change much more to get things working! Swift provides an overload of the print
function that takes a TextOutputStream
.
public func print<Target: TextOutputStream>(
_ items: Any...,
separator: String = " ",
terminator: String = "\n",
to output: inout Target
)
TextOutputStream
is a Swift protocol with a single mutating write
method that appends a given string to a stream.
public protocol TextOutputStream {
mutating func write(_ string: String)
}
Swift’s String
type conforms to TextOutputStream
already, so we can capture the output from print
in a string like self.output
instead of letting it log to standard output.
override public func visit(
_ node: EnumDeclSyntax
) -> SyntaxVisitorContinueKind {
print(
"extension \(node.identifier.withoutTrivia()) {",
to: &self.output
)
All of our print
s just need to add that extra to
parameter, and we’ll build up a string of enum properties instead of immediately printing them to the console.
Things are still building, so we can hop over to our tests, run them, and confirm that our generated code is no longer printing directly to the console.
Since we’ve exposed the output
property, we can write an honest test that asserts against the output of our syntax visitor. Let’s start with an empty string that will cause the test to fail just to see what we’re working with.
func testExample() throws {
let url = URL(fileURLWithPath: String(#file))
.deletingLastPathComponent()
.appendingPathComponent("Fixtures")
.appendingPathComponent("Enums.swift")
let tree = try SyntaxTreeParser.parse(url)
let visitor = Visitor()
tree.walk(visitor)
XCTAssertEqual("", visitor.output)
}
XCTAssertEqual failed: (””) is not equal to (“extension Validated {…”)
It failed, as we expected. In order to get it passing, we can now copy and paste the failure output back into our test to get it passing.
XCTAssertEqual(
"""
extension Validated {
var valid: Valid? {
guard case let .valid(value) = self else { return nil }
return value
}
var isValid: Bool {
return self.valid != nil
}
…
var cancelled: Void? {
guard case .cancelled = self else { return nil }
return ()
}
var isCancelled: Bool {
return self.cancelled != nil
}
}
""",
visitor.output
)
XCTAssertEqual failed: (“extension Validated {…}”) is not equal to (“extension Validated {…}”)
Hm, it’s still failing, and with a huge, unreadable assertion message. The content should be the same, so what’s different? The issue here is very subtle, and it even took us a bit of time to debug it the first time around. Our assertion string needs to have a trailing newline to match the output of our visitor.
}
+
""", visitor.output)
And now the test passes! But the failure we got illustrates just how bad this test is. When we assert against such a huge string, it’s really hard to figure out what’s wrong when anything changes.
For example, we might change how one of our lines prints. Maybe we accidentally introduce some whitespace:
pattern = "guard case let .\(node.identifier)(value)"
XCTAssertEqual failed: (“extension Validated {…}”) is not equal to (“extension Validated {…}”)
We’re back to having a huge failure and no way to reason about what changed.
While this error messaging isn’t so nice, what is nice that we were able to extract our playground-driven code to our package’s library with very little work, update our playground to use the library, and get a passing test written. We’re well on our way to have a working command line tool that uses our library.
But before we go any further, let’s address the shortcoming of our test code, because we have a tool that excels at writing tests for very large blobs of data: SnapshotTesting! Rather than assert against a huge blob of text directly in the test file, we can take a snapshot of that text to disk and let the SnapshotTesting library automatically compare new snapshots against this reference on future test runs. And when the test does fail, we get a much better debugging experience: a line diff highlighting specific lines that have changed.
In order to gain access to SnapshotTesting we need to add it as a root-level dependency of our package using its Package.swift
file.
.package(
url: "https://github.com/pointfreeco/swift-snapshot-testing.git",
from: "1.5.0"
),
And we need to add the SnapshotTesting
module as a dependency of the EnumPropertiesTests
test target.
.testTarget(
name: "EnumPropertiesTests",
dependencies: ["EnumProperties", "SnapshotTesting"]),
Finally, we need to re-run swift package generate-xcodeproj
in order to fetch the SnapshotTesting dependency and regenerate our project file in order to make it available to our test target.
$ swift package generate-xcodeproj
Updating https://github.com/apple/swift-syntax.git
Fetching https://github.com/pointfreeco/swift-snapshot-testing.git
Completed resolution in 9.66s
Cloning https://github.com/pointfreeco/swift-snapshot-testing.git
Resolving https://github.com/pointfreeco/swift-snapshot-testing.git at 1.5.0
generated: ./EnumProperties.xcodeproj
When we return to our project, we can see that SnapshotTesting
is now listed in the Dependencies
group.
And we can import it in our tests!
import SnapshotTesting
Let’s comment out our enormous, difficult-to-debug assertion and replace it with something better. The entry point to the SnapshotTesting library is the assertSnapshot
function:
assertSnapshot(matching: <#Value#>, as: <#Snapshotting<Value, Format>#>)
The assertSnapshot
helper takes an input value, a snapshot strategy that describes what format that input value should snapshot into, and how those snapshot references should be compared during future test runs. We want to snapshot the string output of our visitor, so we’re going to use the lines
strategy. The lines
strategy directly snapshots a given string to a text file and on future test runs it compares updated snapshots to those references using plain old equality and a line diffing algorithm.
assertSnapshot(matching: visitor.output, as: .lines)
When we run the tests we get a failure.
failed - No reference was found on disk. Automatically recorded snapshot: …
open “…/EnumProperties/Tests/EnumPropertiesTests/Snapshots/EnumPropertiesTests/testExample.1.txt”
Re-run “testExample” to test against the newly-recorded snapshot.
This is the expected behavior of snapshot testing: we always fail when recording new snapshots. This ensures that continuous integration will fail if we commit new snapshot tests but forget to commit the corresponding snapshots.
When we re-run the test, it passes! And should the output ever change, we’ll get a much nicer failure.
For example, to make sure it’s working let’s introduce some pesky trailing whitespace:
print(" \(pattern) = self else { return nil } ", to: &self.output)
Snapshot does not match reference.
We get a failure, which, once expanded:
@−
"…/EnumProperties/Tests/EnumPropertiesTests/__Snapshots__/EnumPropertiesTests/testExample.1.txt"
@+
"/var/folders/…/T/EnumPropertiesTests/testExample.1.txt"
@@ −1,64 +1,64 @@
extension Validated {
extension Validated {
var valid: Valid? {
- guard case let .valid(value) = self else { return nil }
+ guard case let .valid(value) = self else { return nil } ¬
return value
}
Highlights with a line diff the exact difference. Even here, where the difference is quite subtle, we get a symbolic indicator that one line has trailing whitespace.
And when we remove that trailing whitespace, the tests once again pass!
With just a little bit of work we improved the testing capabilities of our code generation tool. We got rid of a gigantic assert that led to difficult-to-decipher failures and replaced with a small, easy-to-troubleshoot snapshot test. It’s already a huge improvement, but maybe we can do better.
We’re currently using the lines
snapshotting strategy, which is one of the most basic strategies that comes with the library. One of the powerful things about SnapshotTesting is that it’s super transformable, and we can build entirely new snapshot testing strategies out of old ones. We might even say that any direct use of the lines
strategy is probably an indicator that a custom strategy could generalize some extra work. I think if we maybe define a custom snapshot strategy for our generator we’ll unlock some interesting things.
Snapshot strategies are values of Snapshotting
, and they’re best defined on the Snapshotting
type as static members. This lets us hook into Swift’s dot-prefix syntax like we did with the .lines
strategy.
assertSnapshot(matching: visitor.output, as: .lines)
We can start by reopening the Snapshotting
type to define our static strategy.
extension Snapshotting {
}
Snapshotting
is generic over two parameters, Value
and Format
.
public struct Snapshotting<Value, Format> {
The Value
is what gets passed to assertSnapshot(matching:)
, and the Format
describes how the value should be serialized and diffed. The lines
strategy is of type Snapshotting<String, String>
, where both Value
and Format
are plain old String
s.
We know we want to constrain our Format
to String
since we’re working with strings of source code.
extension Snapshotting where Format == String {
And the input that gets our enum properties generating are the file URLs that point to the Swift source code in question.
extension Snapshotting where Value == URL, Format == String {
Now we need to define our strategy. We can call this one enumProperties
, since we’ll be snapshotting our enum properties as Swift source code.
extension Snapshotting where Value == URL, Format == String {
static let enumProperties: Snapshotting
}
Creating this value from scratch with an initializer takes a bit of work, so instead, we can build it from the existing lines
strategy. To do so we can use the pullback
method, which allows us to derive a whole new snapshotting strategy from an existing one, but it does so in a slightly weird way. We provide a function that goes from the type that we want to snapshot, URL
, into the type we know how to snapshot, String
, and that allows us to pull back the lines
strategy on String
to a whole new strategy on URL
.
static var enumProperties: Snapshotting =
Snapshotting<String, String>.lines
.pullback(<#transform: (NewValue) -> String#>
Our new strategy works with URLs, so the transform function should go from (URL) -> String
. This transform is responsible for taking a URL
and returning a String
of our generated enum property source code.
extension Snapshotting where Value == URL, Format == String {
static var enumProperties: Snapshotting =
Snapshotting<String, String>.lines.pullback { url in
}
}
We’ve already done this work a bunch of times before, including in our test. We can copy that code, paste it in, and we should have a working snapshot strategy.
extension Snapshotting where Value == URL, Format == String {
static var enumProperties: Snapshotting =
Snapshotting<String, String>.lines.pullback { url in
let tree = try SyntaxTreeParser.parse(url)
let visitor = Visitor()
tree.walk(visitor)
return visitor.output
}
}
Invalid conversion from throwing function of type ‘() throws -> String’ to non-throwing function type ’() -> String’
Oh, this error message is a bit difficult to parse, but it just means that pullback
doesn’t handle throw
ing closures. We can force try!
to get things working again.
let tree = try! SyntaxTreeParser.parse(url)
That’s all there is to it!
Assuming all is working, we should be able to simplify our existing test by deleting our ad hoc code and using our brand new snapshot strategy instead.
func testExample() throws {
let url = URL(fileURLWithPath: String(#file))
.deletingLastPathComponent()
.appendingPathComponent("Enums.swift")
assertSnapshot(matching: url, as: .enumProperties)
Our test is now super direct in describing that: given a URL I want to assert that a snapshot of its source as enum properties exists and matches our reference. We can even re-use this strategy with other fixtures.
This is looking really cool, but one thing I noticed is that the text-based snapshot of our code was saved as a .txt
file. The SnapshotTesting library supports custom file extensions, so it might be nice to update our custom strategy to correctly identify our references as .swift
files.
For example. Let’s take a look at our current reference. Because this is a .txt
file we don’t get source code highlighting or any indicator that this is valid Swift.
What we can do is modify our strategy and change its path extension to swift
so that the reference can be loaded by default as Swift source rather than text. In order to mutate this property, we’ll need to update our assignment to do a bit more work. We can do so by opening up a closure, which allows us to temporarily assign the strategy to a mutable variable, mutate its file extension, and then return the result from the closure. And we can call this closure immediately so that it gets assigned to our static property.
static let enumProperties: Snapshotting = {
var snapshotting: Snapshotting =
Snapshotting<String, String>.lines.pullback { url -> String in
let tree = try! SyntaxTreeParser.parse(url)
let visitor = Visitor()
tree.walk(visitor)
return visitor.output
}
snapshotting.pathExtension = "swift"
return snapshotting
}()
Now when we re-run our tests, we should get a newly-recorded reference with a .swift
path extension.
failed - No reference was found on disk. Automatically recorded snapshot: …
open “…/EnumProperties/Tests/EnumPropertiesTests/Snapshots/EnumPropertiesTests/testExample.1.swift”
Re-run “testExample” to test against the newly-recorded snapshot.
And we do! We get a failure that indicates we recorded a new snapshot, and we can see right there in the error message that the file saved has a .swift
extension. We can even open up this reference and see that as a Swift file it’s much easier to read, with editor affordances like syntax highlighting.
By outputting our snapshot as a Swift file we’ve also unlocked something kinda amazing: we’ve saved a valid Swift source file into our test target’s directory, so if we can bring it into our Xcode project, we can use the Swift compiler to guarantee that we have valid Swift.
Let’s hop back over to the terminal and regenerate our Xcode project.
$ swift package generate-xcodeproj
generated: ./EnumProperties.xcodeproj
When we open the project navigator and expand the Tests
group we can see that the Swift Package Manager has included our snapshot in the test target. And because both the fixture defining our enums and the snapshot defining our enum properties are included in the test target, we now have a compile-time guarantee that, if our tests build and pass, then our generated code is completely valid Swift!
This is incredibly powerful! And it’s a use case we never even dreamed of when we first started snapshot testing: we get compiler verification of our generated code, almost accidentally, and for free!
To show just how powerful this is, let’s break our generator by forgetting to close our enum extensions.
// print("}", to: &self.output)
If we run our tests, they fail, since the snapshot has changed.
Snapshot does not match reference.
But maybe we’re not paying attention and we record over this snapshot with invalid Swift.
record=true
assertSnapshot(matching: url, as: .enumProperties)
Record mode is on. Turn record mode off and re-run “testExample” to test against the newly-recorded snapshot.
This time we get a failure because we’re in record mode, which is expected.
What’s incredible is if we try to build and run our tests again, we get a compiler failure! Our snapshot has a bunch of enums that aren’t being closed:
Expected ‘}’ at end of extension
This is very cool stuff, and we believe that any kind of source code generation tool should take advantage of this kind of feature, where you get compiler verification that the generated code is correct.
To get things building again, we can delete the contents of our fixture.
And re-comment in the code that generates those closing braces.
print("}", to: &self.output)
And finally re-record the snapshot.
record=true
assertSnapshot(matching: url, as: .enumProperties)
Record mode is on. Turn record mode off and re-run “testExample” to test against the newly-recorded snapshot.
open “…/EnumProperties/Tests/EnumPropertiesTests/Snapshots/EnumPropertiesTests/testExample.1.swift”
And once we leave record
mode…
// record=true
We have a passing test again! And we can hop on over to the fixture to verify.
Alright, so far we’ve taken our experimental playground code generator, extracted it to a library, and written some really impressive snapshot tests that not only verify the output of our code generator, but give us a compile-time guarantee that the generated code builds!
There’s just one thing left to do: we need a way of running our generator outside of our playground and tests. After all, the whole point of writing this tool is to be able to use it and benefit from enum properties everywhere, but it’s not quite there yet. What we want is a command line tool that we can point at a bunch of Swift source files and generate enum properties for any enums it finds. The Swift Package Manager makes it incredibly easy to build executable targets, so let’s do just that.
We can hop on over to Package.swift
and add another product to our array of products. This time it’s an executable
target. While libraries are compiled as modules that can be imported by other targets, executables are compiled to programs that can be invoked from the command line.
.executable(
name: "generate-enum-properties",
targets: ["generate-enum-properties"]),
We gave our executable a lowercase name to match the convention of most command line tools.
We also need to add a new target of the same name to our array of targets. Our executable will depend on both EnumProperties
and SwiftSyntax
.
.target(
name: "generate-enum-properties",
dependencies: ["EnumProperties", "SwiftSyntax"]),
If we try to build our package right now it’s going to fail because there’s no corresponding generate-enum-properties
directory and source code in the Sources
directory.
$ swift package generate-xcodeproj
error: could not find source files for target(s): generate-enum-properties; use the 'path' property in the Swift 4 manifest to set a custom target path
We can start to fix this by hopping over to a terminal and make the expected directory.
$ mkdir Sources/generate-enum-properties
But we’re not quite there yet.
$ swift package generate-xcodeproj
warning: target 'generate-enum-properties' in package 'EnumProperties' contains no valid source files
error: target 'generate-enum-properties' referenced in product 'generate-enum-properties' could not be found
We need a single source file, so let’s stub out an empty main.swift
file, which executable targets need. They act as an entryway into the application.
$ touch Sources/generate-enum-properties/main.swift
Running swift package generate-xcodeproj
will update our project to include the new executable target.
$ swift package generate-xcodeproj
generated: ./EnumProperties.xcodeproj
Now our project file includes a new Sources
directory with its empty main.swift
file.
And we have a brand new target, generate-enum-properties
, which we can build and run, but it doesn’t do much yet.
What we want to do in main.swift
is build the logic for our command line tool. We want to be able to pass in URLs that point to Swift source code so that we can run our code generation library against it before outputting code that can be saved to disk.
To get our feet wet, let’s look at how we can get access to the command line arguments that get passed to our executable. Command line arguments are available on a static member of a standard library CommandLine
type, which is an enum that has no cases and acts as a kind of namespace. We can print out these arguments whenever our command line tool is invoked.
print(CommandLine.arguments)
And to invoke our tool we can call swift run generate-enum-properties
.
$ swift run generate-enum-properties
[4/4] Linking ./.build/x86_64-apple-macosx/debug/generate-enum-properties
[".build/x86_64-apple-macosx/debug/generate-enum-properties"]
It prints an array with a string that represents the path to the executable.
We can pass along more arguments and see how it affects the output.
$ swift run generate-enum-properties ./Tests/EnumPropertiesTests/Fixtures/Enums.swift
[4/4] Linking ./.build/x86_64-apple-macosx/debug/generate-enum-properties
[".build/x86_64-apple-macosx/debug/generate-enum-properties", "./Tests/EnumPropertiesTests/Fixtures/Enums.swift"]
Now we get a second string in that array of output that represents that argument. So now we have a way of accessing the inputs we need for our tool.
Now we can drop the first element of this input and we’re left with all of the URLs passed as arguments to the main executable.
let urls = CommandLine.arguments.dropFirst()
Now we are working with an array of URL strings, but we need to transform them into actual URL
s that can be passed to SwiftSyntax’s syntax tree parser.
import Foundation
let urls = CommandLine.arguments.dropFirst()
.map { URL(fileURLWithPath: $0) }
All that’s left is the work that we’ve done a bunch before: parsing and visiting URL
s in order to build up our generated source. This time we’re working with more than one URL, so we can loop over them, parse them, and have the same visitor
walk each one before finally printing the combined output.
import Foundation
import EnumProperties
import SwiftSyntax
let urls = CommandLine.arguments.dropFirst()
.map { URL(fileURLWithPath: $0) }
let visitor = Visitor()
try urls.forEach { url in
let tree = try SyntaxTreeParser.parse(url)
tree.walk(visitor)
}
print(visitor.output)
And now when we swift run
our executable.
$ swift run generate-enum-properties Tests/EnumPropertiesTests/Enums.swift
[2/2] Linking ./.build/x86_64-apple-macosx/debug/generate-enum-properties
extension Validated {
var valid: Valid? {
guard case let .valid(value) = self else { return nil }
return value
}
var isValid: Bool {
return self.valid != nil
}
var invalid: [Invalid]? {
guard case let .invalid(value) = self else { return nil }
return value
}
var isInvalid: Bool {
return self.invalid != nil
}
}
extension Node {
var element: (tag: String, attributes: [String: String], children: [Node])? {
guard case let .element(value) = self else { return nil }
return value
}
var isElement: Bool {
return self.element != nil
}
var text: String? {
guard case let .text(value) = self else { return nil }
return value
}
var isText: Bool {
return self.text != nil
}
}
extension Loading {
var loading: Void? {
guard case .loading = self else { return nil }
return ()
}
var isLoading: Bool {
return self.loading != nil
}
var loaded: A? {
guard case let .loaded(value) = self else { return nil }
return value
}
var isLoaded: Bool {
return self.loaded != nil
}
var cancelled: Void? {
guard case .cancelled = self else { return nil }
return ()
}
var isCancelled: Bool {
return self.cancelled != nil
}
}
We get all of the output we expected!
We now have a tool that we can run against valid Swift source code and end up with output that we can integrate into our projects. For instance, we can redirect that output to a specific file:
$ swift run generate-enum-properties \
Tests/EnumPropertiesTests/Enums.swift \
> output.swift
And all of the generated code has been saved to disk, which we can verify by opening it, and which we can from here import into the project that needs it.
Alright, we did it! We have a mostly-working library for generating enum properties. It took a few episodes to get there: we first identified that enum properties are important because it makes enum data access as ergonomic as struct data access. Then we sought out to use Swift Syntax to code generate enum properties, because it’s a lot of boilerplate that you wouldn’t want to write by hand. Finally we packaged it into a CLI tool that can be pointed to Swift source code and output all of that code.
It’s not quite production-ready yet, though. It doesn’t handle nested enums. It doesn’t (not can it with its current design) handle private enums. And it doesn’t know how to work with associated values that must be imported from another module. So even though we’ll get a lot of use out of this tool as is, it will never be the same as if the Swift compiler did it for us.
While the compiler could handle all of these edge cases, who knows how long we’ll have to wait. In the meantime, we can use this tool for many of our use cases today, and perhaps there could be an open source tool that others can use.
Code generation is a valuable tool to have in our tool set and lets us close the gap between things the compiler can accomplish for us in the future, today. Till next time!