UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Introduction to Sourcery

Save time and avoid bugs with Swift metaprogramming

Paul Hudson       @twostraws

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.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Working with enum cases

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,
    ]
}

What does it all do?

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.

Improving our template

Now that we have a working template, it’s time to make it a little smarter. More specifically, it has three flaws:

  1. Our 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.
  2. Trying to generate all cases of an enum with associated values doesn’t make sense. This is strictly disallowed in Swift 4.2’s CaseIterable.
  3. The last item in our 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 

Where next?

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.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 3.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.