Sourcery Swift Package command line plugin

Sourcery Swift Package command line plugin

What is Sourcery?

Sourcery is one of the most popular Swift code generation tools. It uses SwiftSyntax[1] and aims to save developers time by automatically generating boilerplate code. Sourcery scans a set of input files and then, with the help of templates, automatically generates the Swift code defined in the templates.

Example

Consider a protocol that provides a public API for a camera session service:

 protocol Camera {
func start ()
func stop ()
func capture ( _ completion : @ escaping ( UIImage ? ) - > Void )
func rotate ()
}

When unit testing this new Camera service, we want to make sure that the AVCaptureSession is not actually created. We just want to confirm that the camera service is called correctly by the system under test (SUT), rather than testing the camera service itself.

Therefore, it makes sense to create a mock implementation of the protocol with empty methods and a set of variables to help us with unit testing and assert that the correct calls were made. This is a very common scenario in software development and can also be a bit tedious if you have ever maintained a large code base with a lot of unit tests.

Well, don’t worry! Sourcery has you covered! ⭐️ It has a template called AutoMockable[2] that will generate mock implementations for any protocol that conforms to the AutoMockable protocol in the input file.

Note: Throughout this article, I use the term mock loosely because it aligns with the terminology used by the Sourcery templates. Mock is a fairly overloaded term, but typically, if I’m creating a test double[3], I’ll further specify the name of the type (perhaps Spy, Fake, Stub, etc.) depending on its purpose. If you’re interested in learning more about test doubles, Martin Fowler has a great article explaining the differences.

Now, we make Camera conform to AutoMockable. The only purpose of this interface is to act as a target for Sourcery to find and generate code from.

 import UIKit

// Protocol to be matched
protocol AutoMockable {}

public protocol Camera : AutoMockable {
func start ()
func stop ()
func capture ( _ completion : @ escaping ( UIImage ? ) - > Void )
func rotate ()
}

At this point, you can run the Sourcery command on the above input file, specifying the path to the AutoMockable template:

 sourcery -- sources Camera . swift -- templates AutoMockable . stencil -- output .

This article configures the Sourcery plugin by providing a .sourcery.yml file. If a configuration file is provided or Sourcery can find one, any command line arguments that conflict with its values ​​will be ignored. If you want to learn more about configuration files, the Sourcery repo has a section on the topic [4].

After the command is executed, a file with the template name plus .generated.swift as the suffix will be generated in the output directory. In this example, it is ./AutoMockable.generated.swift:

 // Generated using Sourcery 1.8.2 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
// swiftlint:disable line_length
// swiftlint:disable variable_name

import Foundation
#if os ( iOS ) || os ( tvOS ) || os ( watchOS )
import UIKit
#elseif os ( OSX )
import AppKit
#endif

class CameraMock : Camera {

//MARK: - start

var startCallsCount = 0
var startCalled : Bool {
return startCallsCount > 0
}
var startClosure : (() - > Void ) ?

func start () {
startCallsCount += 1
startClosure ? ()
}

//MARK: - stop

var stopCallsCount = 0
var stopCalled : Bool {
return stopCallsCount > 0
}
var stopClosure : (() - > Void ) ?

func stop () {
stopCallsCount += 1
stopClosure ? ()
}

//MARK: - capture

var captureCallsCount = 0
var captureCalled : Bool {
return captureCallsCount > 0
}
var captureReceivedCompletion : (( UIImage ? ) - > Void ) ?
var captureReceivedInvocations : [(( UIImage ? ) - > Void )] = []
var captureClosure : (( @ escaping ( UIImage ? ) - > Void ) - > Void ) ?

func capture ( _ completion : @ escaping ( UIImage ? ) - > Void ) {
captureCallsCount += 1
captureReceivedCompletion = completion
captureReceivedInvocations.append (completion )
captureClosure ? ( completion )
}

//MARK: -rotate

var rotateCallsCount = 0
var rotateCalled : Bool {
return rotateCallsCount > 0
}
var rotateClosure : (() - > Void ) ?

func rotate () {
rotateCallsCount += 1
rotateClosure ? ()
}

}

The file above (AutoMockable.generated.swift) contains what you would expect from a mock: empty methods to implement conformance to the target protocol, and a set of variables to check if those protocol methods were called. And the best part is… Sourcery wrote it all for you! 🎉

How to run Sourcery?

How to run Sourcery with Swift package?

At this point you might be wondering how and what to run Sourcery from within a Swift package. You can do it manually and drag the files into the package, or run the script from the command line in the package directory. But there are two built-in ways to run executables from a Swift package:

  1. Through the command line plug-in, it can be run arbitrarily according to user input
  2. With a build tool plugin, the plugin is run as part of the build process.

In this article, I'll introduce the Sourcery command-line plugin, but I'm already working on a second part where I'll create a build tool plugin, which presents a number of interesting challenges.

Creating a plugin package

Let's first create an empty package and get rid of the test and other folders we don't need right now. Then we can create a new plugin ​Target​​​ and add Sourcery's binaries as its dependencies.

In order for consumers to use this plugin, it also needs to be defined as a product:

 // swift-tools-version: 5.6
import PackageDescription

let package = Package (
name : "SourceryPlugins" ,
products : [
. plugin ( name : "SourceryCommand" , targets : [ "SourceryCommand" ])
],
targets : [
// 1
. plugin (
name : "SourceryCommand" ,
// 2
capability : . command (
intent : . custom ( verb : "sourcery-code-generation" , description : "Generates Swift files from a given set of inputs" ),
// 3
permissions : [. writeToPackageDirectory ( reason : "Need access to the package directory to generate files" )]
),
dependencies : [ "Sourcery" ]
),
// 4
.binaryTarget (
name : "Sourcery" ,
path : "Sourcery.artifactbundle"
)
]
)

Let's look at the code above step by step:

  1. Define plugin goals.
  2. With custom as the intent, a .command capability is defined because none of the default capabilities ( documentationGeneration and sourceCodeFormatting ) match the use case for this command. It is important to give the verb a sensible name because this is how the plugin will be called from the command line.
  3. The plugin needs to ask the user for permission to write to the package directory, as the generated files will be dumped into that directory.
  4. A binary target is defined for the plugin. This will allow the plugin to access the executable through its context.

I know I didn’t cover some of the concepts above in detail, but if you want to learn more about command plugins, here’s a great article by Tibor Bödecs⭐. If you also want to learn more about binary targets (files) in Swift Packages, I also have a post ​​Binary Targets in Swift Packages Today​​.

Writing plugins

Now that the package is created, it’s time to write some code! We start by creating a file called SourceryCommand.swift under Plugins/SourceryCommand and then adding a struct of the CommandPlugin protocol, which will serve as the entry point for the plugin:

 import PackagePlugin
import Foundation

@main
struct SourceryCommand : CommandPlugin {
func performCommand ( context : PluginContext , arguments : [ String ]) async throws {

}
}

Then we write the implementation for the command:

 func performCommand ( context : PluginContext , arguments : [ String ]) async throws {
// 1
let configFilePath = context . package . directory . appending ( subpath : ".sourcery.yml" ). string
guard FileManager . default . fileExists ( atPath : configFilePath ) else {
Diagnostics . error ( "Could not find config at: \(configFilePath)" )
return
}
//2
let sourceryExecutable = try context . tool ( named : "sourcery" )
let sourceryURL = URL ( fileURLWithPath : sourceryExecutable . path . string )

// 3
let process = Process ()
process . executableURL = sourceURL

// 4
process . arguments = [
"--disableCache"
]
// 5
try process . run ()
process.waitUntilExit ( )

// 6
let gracefulExit = process . terminationReason == . exit && process . terminationStatus == 0
if ! gracefulExit {
Diagnostics . error ( "🛑 The plugin execution failed" )
}
}

Let's take a closer look at the code above:

  1. First of all, the .sourcery.yml file must be in the root directory of the package, otherwise you will get an error. This will make Sourcery work magically and make the package configurable.
  2. The URL of the executable path is retrieved from the context of the command.
  3. Create a process and set the URL of Sourcery's executable file as its executable path.
  4. This step is a bit cumbersome. Sourcery uses caches to reduce code generation time on subsequent runs, but the problem is that these caches are files that are read and written outside of the package folder. The plugin's sandbox rules don't allow this, so the --disableCache flag is used to disable this behavior and allow the command to run.
  5. Processes run synchronously and wait.
  6. Finally, the process termination status and code are checked to ensure that the process exited normally. In any other case, the user is informed of the error via the Diagnostics API.

That's it! Now let's use it

Using the (plugin) package

Consider a user who is using a plugin that pulls in a dependency into their Package.swift file:

 // swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package (
name : "SourceryPluginSample" ,
products : [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library (
name : "SourceryPluginSample" ,
targets : [ "SourceryPluginSample" ]),
],
dependencies : [
. package ( url : "https://github.com/pol-piella/sourcery-plugins.git" , branch : "main" )
],
targets : [
. target (
name : "SourceryPluginSample" ,
dependencies : [],
exclude : [ "SourceryTemplates" ]
),
]
)

Note that unlike build tool plugins, command plugins do not need to be applied to any target, as they need to be run manually.

The user simply used the AutoMockable template above (which can be found under Sources/SourceryPluginSample/SourceryTemplates ), matching the example shown earlier in this article:

 protocol AutoMockable {}

protocol Camera : AutoMockable {
func start ()
func stop ()
func capture ( _ completion : @ escaping ( UIImage ? ) - > Void )
func rotate ()
}

As required by the plugin, the user also provides a .sourcery.yml configuration file located in the SourceryPluginSample directory:

 sources :
- Sources / SourceryPluginSample
templates :
- Sources / SourceryPluginSample / SourceryTemplates
output : Sources / SourceryPluginSample

Run Command

The user is all set up, but how do they run the package now? 🤔 There are two ways:

Command Line

One way to run plugins is from the command line. A list of available plugins for a particular package can be retrieved by running swift package plugin --list from the package directory. A package can then be selected from the list and executed by running swift package <command's verb> , in this particular case, running: swift package sourcery-code-generation .

Note that because this package requires special permissions, --allow-writing-to-package-directory must be used with the command.

At this point, you might be thinking, why would I bother writing a plugin that I still have to run from the command line when I can do the same job in a few lines of bash with a simple script? Well, let’s take a look at what’s coming in Xcode 14 and you’ll see why I’m an advocate for writing plugins 📦.

Xcode

This is the most exciting way to run the command plugin, but unfortunately, it is only available in Xcode 14. So if you need to run commands but are not using Xcode 14 yet, see the Command Line section.

If you happen to be using Xcode 14, you can execute any command of a package by right-clicking the package in File Explorer, finding the plugin you want to execute from the list, and clicking it.

Next step

This is the initial implementation of the plugin. I will be looking into how to improve it and make it more robust. As always, I am very committed to building publicly and making everything in my articles open source so anyone can submit issues or create PRs with any improvements or fixes. This is no different 😀, here is the link to the public repository.

Also, if you liked this post, stay tuned for the upcoming Part 2, where I will make a Sourcery build tool plugin. I know it doesn’t sound like much, but it’s not an easy task!

References

[1] SwiftSyntax: https://github.com/apple/swift-syntax.​​

[2] AutoMockable: https://github.com/krzysztofzablocki/Sourcery/blob/master/Templates/Templates/AutoMockable.stencil.​​

[3] Double test: https://en.wikipedia.org/wiki/Test_double.

[4] repo: https://github.com/krzysztofzablocki/Sourcery/blob/master/guides/Usage.md#configuration-file.​​

<<:  Apple iOS 16.2 / iPadOS 16.2 Developer Preview Beta Released: New Borderless Notes App

>>:  Let's talk about the three new font width styles in iOS 16

Recommend

How to optimize image traffic on mobile devices

[[171480]] In addition to script style files, mos...

How to start private domain marketing? (Case included)

Looking at the development history of the daily c...

How can network optimization help you rank high?

Optimizing your website must start from the basic...

Guangdiantong advertising details + optimization techniques

Today, we have specially compiled this article fo...

This little bird definitely knows fashion, how beautiful the kingfisher is

China Science and Technology News Network, Decemb...

Don’t worry if you lose your phone! Apple Pay explained

After nearly two years of release and various twis...

Integrated marketing, how to achieve “product and effect integration”?

When companies are doing brand marketing , it is ...

How to name your website? How to name a Google website?

The name and domain name of the website cannot be...