Save time and avoid bugs with Swift metaprogramming
As much fun as it is writing Swift code yourself, some incredible tools exist that can write code for you – and do so in a way that takes away pain points, bugs, and more.
One such tool is Sourcery, written by prolific contributor and speaker Krzysztof Zabłocki. Its job is to write Swift code for you in places where you need certain repetitive behavior. It does this by reading template files (Swift source code with special annotations) and generating output that you can then add to your project.
In this tutorial I’m going to walk you through installing and configuring Sourcery with an Xcode project so you can see it for yourself. I’ve tried to choose a simple and practical example so you can see exactly what it’s doing – there’s no magic!
First you’ll need to install Sourcery. I find the easiest thing to do is run brew install sourcery. If you don’t already have Homebrew installed you should follow these instructions first.
Next, create a new Single View App in Xcode, calling it SourceryTest. We won’t be writing much in there, but it’s more representative of how many folks will actually use Sourcery.
Although you can call Sourcery directly from the command line, I find it easiest to create a configuration file in your project directory – something you can put into source control that guarantees the same environment everywhere.
This file is written in YAML, which is a simple language for configuration files. This needs to say where your source files are stored, where your template files are stored, and where Sourcery should write its generated output.
We just made an Xcode project called SourceryTest, so you should have a directory called SourceryTest that contains two things: another directory called SourceryTest and an Xcode project called SourceryTest.xcodeproj.
You need to create your Sourcery configuration file there, alongside the Xcode project. So, open up your text editor of choice and give it this content:
sources:
- SourceryTest
templates:
- Templates
output: SourceryTest/Generated
Now save that as .sourcery.yml. Make sure you save it in the same directory as the Xcode project, and not inside the SourceryTest subdirectory.
Note: Because this filename starts with a leading dot it will be invisible in macOS unless you show hidden files.
The final piece of set up is creating a Templates directory. Again, this should be created in the same directory as SourceryTest.xcodeproj, so please do that now.
SPONSORED AppSweep by Guardsquare helps developers automate the mobile app security testing process with fast, free scans. By using AppSweep’s actionable recommendations, developers can improve the security posture of their apps in accordance with security standards like OWASP.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Before we can run Sourcery for the first time we need at least one template to work with. Let’s start with something you’ll find immediately useful today: Swift 4.2 introduces a new CaseIterable
protocol for enums, which automatically synthesizes an array of all enum cases at compile time.
So, add this enum to ViewController.swift in your Xcode project, before the class ViewController
line:
enum Direction: CaseIterable {
case north, south, east, west
}
In Swift 4.2 that will compile cleanly and you can write Direction.allCases
to get back an array of [.north, .south, .east, and .west]
, but if you’re stuck on Swift 4.1 or earlier you’ll get a compile error because CaseIterable
doesn’t exist – you either need to write that code by hand or just use Sourcery to solve the problem.
As this is a Sourcery tutorial it won’t surprise you to learn we’ll be using the second option, so open your preferred text editor now: we’re going to write a Sourcery template.
Templates blend source code with a small amount of logic – things like conditions and loops – and you can write them in one of three different languages based on your needs. Here we’re going to use Stencil, which is a well-known template language used in a number of Swift projects. This comes bundled with Sourcery, so you don’t need to install it separately.
In Stencil you place variables into your output by writing their names inside double braces, {{ like_this }}
, and your write conditions and other code flow inside brace percents, like {% for value in values %}
. You can embed loops inside other loops as much as you need, although at first it’s easy to feel “brace blindness” – the syntax can be tricky to read until you’re used to it.
Stencil works in tandem with Sourcery to provide a number of built-in values to work with. In our instance we want to generate cases for all enums that implement the CaseIterable
protocol, and Sourcery does that hard work for us – there’s a types.enums.implementing.CaseIterable
variable we can read that does exactly that.
Let’s dive in with some real code so you can see how it looks; I’ll break it down afterwards.
Add this to your template file, then save it as CaseIterable.stencil in the Templates directory:
protocol CaseIterable { }
{% for enum in types.enums.implementing.CaseIterable %}
extension {{ enum.name }} {
static let allCases: [{{ enum.name }}] = [
{% for case in enum.cases %} .{{ case.name }},
{% endfor %}]
}
{% endfor %}
Now open a terminal window and change into the SourceryTest folder – again, not the SourceryTest subdirectory, but the directory containing the Xcode project. We want Sourcery to automatically watch our project and generate new code every time we change either the templates or source code, so please run this command:
sourcery --watch
That will immediately read the CaseIterable.stencil template and convert it into Generated/CaseIterable.generated.swift. You shouldn’t change that file by hand because it will be rewritten every time the template files, however you need to drag it into your Xcode project so it gets compiled with the rest of your code.
With that change your code should now compile: CaseIterable.generated.swift declares the CaseIterable
protocol and makes the Direction
enum contain an allCases
property. You should see code like this:
protocol CaseIterable { }
extension Direction {
static let allCases: [Direction] = [
.north,
.south,
.east,
.west,
]
}
Now that everything works, let me break down what the Stencil code does:
protocol CaseIterable { }
That declares the CaseIterable
protocol. It’s not inside any sort of condition, because we always need it to exist. This protocol doesn’t do anything – it’s empty! – but it gives us something our enums can conform to just like in Swift 4.2.
{% for enum in types.enums.implementing.CaseIterable %}
This loops over all enums in our source code, looking for those that conform to CaseIterable
. Just like a Swift for
loop that will place each enum into the enum
variable.
extension {{ enum.name }} {
This will write an extension for each enum name that was found. In our Direction
example this will generate extension Direction {
. Remember, the double braces are replaced with the value of whatever is inside, which in this case is the enum name.
static let allCases: [{{ enum.name }}] = [
This generates a static property called allCases
, marking it as an array of whatever the enum name is. So, [Direction]
in this example.
{% for case in enum.cases %} .{{ case.name }},
{% endfor %}]
This creates an inner loop for all the enum cases, generating one array element for each case then ending the loop.
}
{% endfor %}
Finally, that ends the extension then ends the enum loop.
Now that we have a working template, it’s time to make it a little smarter. More specifically, it has three flaws:
CaseIterable
protocol will be declared in all versions of Swift, which means if we try to compile with Swift 4.2 we’ll hit problems.CaseIterable
.allCases
array has a comma. Swift doesn’t mind that, but it would be nice to make it more beautiful, right? Right.Let’s fix those one by one. We ran Sourcery with the --watch parameter, which means it’s running in daemon mode – as we make and save changes to the template file you’ll see your generated code update a second or two later.
The first problem is that we’re generating CaseIterable
for our enums even if we’re using a Swift 4.2 compiler. This can be fixed by having Sourcery place an #if swift(>=4.2)
check in our generated file, which instructs the Swift compiler to read the following code only if it is Swift 4.2 or later.
Sadly there is no #if swift(<4.2)
, however we can just use an #else
block for that. Modify CaseIterable.stencil to this:
#if swift(>=4.2)
// do thing here
#else
protocol CaseIterable { }
{% for enum in types.enums.implementing.CaseIterable %}
extension {{ enum.name }} {
static let allCases: [{{ enum.name }}] = [
{% for case in enum.cases %} .{{ case.name }},
{% endfor %}]
}
{% endfor %}
#endif
That’s the first problem solved.
The second problem is that our Sourcery template will attempt to generate allCases
for enums that have associated types, which doesn’t make sense. We can exclude this by adding a condition: {% if not enum.hasAssociatedValues %}
– if we place that inside our loop then Sourcery will skip any invalid enums.
Here’s how that looks in Stencil:
#if swift(>=4.2)
// do thing here
#else
protocol CaseIterable { }
{% for enum in types.enums.implementing.CaseIterable %}
{% if not enum.hasAssociatedValues %}
extension {{ enum.name }} {
static let allCases: [{{ enum.name }}] = [
{% for case in enum.cases %} .{{ case.name }},
{% endfor %}]
}
{% endif %}
{% endfor %}
#endif
The last problem is that our array of enum cases has a comma after each value even though humans would normally skip the last one. Swift doesn’t mind either way, but I want to address this problem because it lets me show you a useful Stencil feature: inside a loop you can use forloop.first
, forloop.last
, forloop.counter
, and forloop.counter0
to check if this is the first or last item, or get the 1-based or 0-based index of the current item.
In our case we want to use forloop.last
to check if this is the last item, and skip adding the comma if it is. Here’s how that looks in Stencil:
{% for case in enum.cases %} .{{ case.name }}{% if not forloop.last %},{% endif %}
So, here’s the final template:
#if swift(>=4.2)
// do thing here
#else
protocol CaseIterable { }
{% for enum in types.enums.implementing.CaseIterable %}
{% if not enum.hasAssociatedValues %}
extension {{ enum.name }} {
static let allCases: [{{ enum.name }}] = [
{% for case in enum.cases %} .{{ case.name }}{% if not forloop.last %},{% endif %}
{% endfor %}]
}
{% endif %}
{% endfor %}
#endif
This has been only a brief introduction to using Sourcery, but I hope you can see how it lets you automate writing important changes to your code without much work. Using CaseIterable
above means you don’t have to worry about adding allCases
for new enums, and – more importantly – you don’t run the risk of adding a new case then forgetting to update the allCases
array.
You can find lots more example code in the Sourcery documentation, but you might also want to watch a talk that Krzysztof Zabłocki delivered at #Pragma Conference 2017, Metaprogramming in Swift.
SPONSORED AppSweep by Guardsquare helps developers automate the mobile app security testing process with fast, free scans. By using AppSweep’s actionable recommendations, developers can improve the security posture of their apps in accordance with security standards like OWASP.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.