Color Mode


    Language

Automating tasks with SPM plugins

November 16, 2022

Swift Package Manager plugins are a great way of automating tasks, like, enforcing code conventions and style.

In this tutorial we're going to learn what are package plugins and implement 2 plugins; one that outputs code statistics and another that generates type-safe UserDefaults preferences. Plugins require swift tools version 5.6 and up.

There are 2 types of plugins, Command plugins and Build Tool plugins. Let's start with command plugins.

Command Plugins

Command Plugins are tasks that are manually triggered by the developer. As an example, we're going to create a plugin to generate code statistics, such as, how many source code files and lines of code your package has.

First, let's create a package.

$ mkdir CodeStats && cd CodeStats
$ swift package init
$ mkdir -p Plugins/GenerateCodeStats && touch Plugins/GenerateCodeStats/GenerateCodeStats.swift
$ open Package.swift

Note: Plugins go into the Plugins folder, as opposed to Source.

In the manifest file let's create a target and a product.

let package = Package(
    name: "CodeStats",
    products: [
        .plugin(
            name: "GenerateCodeStats",
            targets: ["GenerateCodeStats"]
        )
    ],
    targets: [
        .plugin(
            name: "GenerateCodeStats",
            capability: .command(
                intent: .custom(
                    verb: "code-stats", // Verb used from the command line
                    description: "Generates code statistics"),
                permissions: [
                    .writeToPackageDirectory(reason: "Generate code statistics file at root level")
                ])
        ),
    ]
)

We define our plugin as a command-type plugin with a custom intent and writing permissions. We can use code-stats verb to invoke this plugin from the command line (more on than later).

Now let's implement our simple plugin.

// GenerateCodeStats.swift
import Foundation
import PackagePlugin

@main // Plugin's entry point
struct GenerateCodeStats: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        // 1 - Parse all targets from the arguments. These are the targets
        // that the developer has manually chosen
        let targets = try parseTargets(context: context, arguments: arguments)
        let processor = FileStatsProcessor()
        let fm = FileManager.default
        let dirs = targets.isEmpty ? [context.package.directory] : targets.map(\.directory)

        // 2 - Loop through all targets' files
        for dir in dirs {
            guard let files = fm.enumerator(atPath: dir.string) else { continue }

            // 2.1 - Process only swift files
            for case let path as String in files {
                let fullpath = dir.appending([path])
                var isDirectory: ObjCBool = false

                guard
                    fullpath.extension == "swift",
                    fm.fileExists(atPath: fullpath.string, isDirectory: &isDirectory),
                    !isDirectory.boolValue
                else { continue }

                try processor.processFile(at: fullpath)
            }
        }

        let output = context.package.directory.appending(["CodeStats.md"])

        print(processor.stats.description)

        // 3 - Write the stats to a file in the root directory of the package
        try processor.stats.description.write(
            to: URL(fileURLWithPath: output.string),
            atomically: true,
            encoding: .utf8)
    }

    private func parseTargets(
        context: PluginContext,
        arguments: [String]
    ) throws -> [Target] {
        return arguments
            .enumerated()
            .filter { $0.element == "--target" }
            .map { arguments[$0.offset + 1] }
            .compactMap { try? context.package.targets(named: [$0]) }
            .flatMap { $0 }
    }
}

struct CodeStats: CustomStringConvertible {
    var numberOfFiles: Int = 0
    var numberOfLines: Int = 0
    var numberOfClasses: Int = 0
    var numberOfStructs: Int = 0
    var numberOfEnums: Int = 0
    var numberOfProtocols: Int = 0

    var description: String {
        return [
            "## Code statistics\n",
            "Number of files:     \(fmt(numberOfFiles))",
            "Number of lines:     \(fmt(numberOfLines))",
            "Number of classes:   \(fmt(numberOfClasses))",
            "Number of structs:   \(fmt(numberOfStructs))",
            "Number of enums:     \(fmt(numberOfEnums))",
            "Number of protocols: \(fmt(numberOfProtocols))",
        ].joined(separator: "\n")
    }

    private func fmt(_ i: Int) -> String {
        return String(format: "%8d", i)
    }
}

class FileStatsProcessor {
    private(set) var stats = CodeStats()
    private let definitionsRegex: NSRegularExpression = {
        let pattern = #"\b(?<name>protocol|class|struct|enum)\b"#
        return try! NSRegularExpression(pattern: pattern)
    }()
    private let newlinesRegex: NSRegularExpression = {
        return try! NSRegularExpression(pattern: #"$"#, options: [.anchorsMatchLines])
    }()

    func processFile(at path: Path) throws {
        let text = try String(contentsOfFile: path.string)
        let textRange = NSRange(text.startIndex..<text.endIndex, in: text)

        stats.numberOfFiles += 1
        stats.numberOfLines += newlinesRegex.matches(in: text, range: textRange).count

        definitionsRegex.enumerateMatches(in: text, range: textRange) { match, _, _ in
            guard let nsRange = match?.range(withName: "name"),
                  let range = Range(nsRange, in: text)
            else { return }

            switch text[range.lowerBound] {
            case "p": stats.numberOfProtocols += 1
            case "c": stats.numberOfClasses += 1
            case "s": stats.numberOfStructs += 1
            case "e": stats.numberOfEnums += 1
            default: break
            }
        }
    }
}

Essentially, this walks through all the swift files in the package or selected targets and outputs various statistics.

To run it, we must first add it to a package as a dependency. Let's clone SwiftLint and use it as an example.

Add CodeStats to its dependencies.

Note: CodeStats and SwiftLint packages are in the same directory.

    ...
    dependencies: [
        ...,
        .package(path: "../CodeStats")
    ],
    ...

Make sure it shows up under dependencies.

Then, right-click on the package and select GenerateCodeStats.

You should now be able to see a new file named CodeStats.md in the root directory.

It's also possible to run it from the command line using the verb that we've defined earlier.

$ swift package code-stats
## Code statistics

Number of files:         1224
Number of lines:       318508
Number of classes:       2116
Number of structs:       2431
Number of enums:          889
Number of protocols:      452

If you want to run these kinds of plugins on Xcode projects you just have to import XcodeProjectPlugin and make your plugin conform to XcodeCommandPlugin.

Build Tool Plugins

Build tool plugins are plugins that run whenever a target is built. There are 2 different types of build tool commands, pre-build and in-build. The main difference is that in-build commands have a pre-defined set of outputs.

These plugins are specially useful for generating and formatting code.

In-Build Tool Example

Let's build a plugin that auto-generates UserDefaults preferences based on a simple specification file.

Plugins can only depend on binary and executable targets, so let's create an executable target that will implement the code generation logic and then create a plugin that will call this executable.

// Package.swift
    ...
    targets: [
        .executableTarget(
            name: "GenUserDefaultsExec",
            dependencies: []),
    ]

Here's the code generation.

// Sources/GenUserDefaultsExec/Utilities.swift
import Foundation

struct DefaultValue {
    var name: String
    var type: String
    var value: String?
}

extension String {
    subscript(range: NSRange) -> String {
        return String(self[Range(range, in: self)!])
    }
}

func parseSpecification(_ text: String) -> [DefaultValue] {
    let regex = try! NSRegularExpression(
        pattern: #"^\s*([_\w]+[_\d\w]+)\s+(\w[\d\w]*)\s*(.*)"#)

    return text.components(separatedBy: .newlines).compactMap { line in
        let range = NSRange(location: 0, length: line.utf16.count)

        guard let match = regex.firstMatch(in: line, range: range) else {
            return nil
        }

        let value = line[match.range(at: 3)]

        return DefaultValue(
            name: line[match.range(at: 1)],
            type: line[match.range(at: 2)],
            value: value.isEmpty ? nil : value)
    }
}

func generateCode(_ defaultValues: [DefaultValue]) -> String {
    let keys = defaultValues
        .map { "\t\tpublic static let \($0.name) = \"\($0.name)\"" }
        .joined(separator: "\n")
    let values = defaultValues.map { v in
        let defValue = v.value != nil ? " ?? \(v.value!)" : ""

        return """
        \tpublic var \(v.name): \(v.type)\(v.value == nil ? "?" : "") {
        \t\tget {
        \t\t\treturn object(forKey: .Keys.\(v.name)) as? \(v.type)\(defValue)
        \t\t}
        \t\tset {
        \t\t\tset(newValue, forKey: .Keys.\(v.name))
        \t\t}
        \t}
        """
    }.joined(separator: "\n\n")

    return """
    import Foundation\n
    extension String {\n\tpublic enum Keys {\n\(keys)\n\t}\n}\n
    extension UserDefaults {\n\(values)\n}
    """
}

And here's the main script.

// Sources/GenUserDefaultsExec/main.swift
import Foundation

func abortProgram(_ errorCode: Int32) -> Never {
    print("Usage: GenUserDefaultsExec <input spec> <output>")
    exit(errorCode)
}

let arguments = ProcessInfo().arguments

guard arguments.count > 2 else {
    abortProgram(1)
}

let (input, output) = (arguments[1], arguments[2])
let inputURL = URL(fileURLWithPath: input)
let outputURL = URL(fileURLWithPath: output)

do {
    let spec = try String(contentsOf: inputURL)
    let values = parseSpecification(spec)
    let code = generateCode(values)

    try code.write(to: outputURL, atomically: true, encoding: .utf8)
} catch {
    print(error)
    abortProgram(2)
}

The only thing left is implementing the plugin.

// Package.swift
    ...
    targets: [
        .plugin(
            name: "GenUserDefaults",
            capability: .buildTool(),
            dependencies: ["GenUserDefaultsExec"]
        ),
        ...
    ]

Build tool plugins need to conform to BuildToolPlugin.

// Plugins/GenUserDefaults/GenUserDefaults.swift
import Foundation
import PackagePlugin

@main
struct GenUserDefaults: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let tool = try context.tool(named: "GenUserDefaultsExec")
        let input = target.directory.appending(["defaults"])
        let output = context.pluginWorkDirectory
            .appending(["UserDefaultsPreferences.swift"])

        return [
            .buildCommand(
                displayName: "Generating UserDefaults Fields",
                executable: tool.path,
                arguments: [input.string, output.string],
                inputFiles: [input],
                outputFiles: [output])
        ]
    }
}

To test it, let's create a target called AppFeature that implements a simple SwiftUI view.

// Package.swift
    ...
    targets: [
        .target(
            name: "AppFeature",
            plugins: ["GenUserDefaults"]
        ),
        ...
    ]

This plugin looks for a specification file called defaults in the root directory of the target. So let's create that file with the contents bellow.

appLaunchCounter Int 0
showOnboarding Bool true
userId String

Each line declares a preference separated by whitespace where the 3rd value is an optional default value.

Whenever we build this target, it will auto generate a file called UserDefaultsPreferences in the build directory, if needed.

We can now use UserDefaults preferences in a type-safe way.

// Sources/AppFeature/AppFeature.swift
import Foundation
import SwiftUI

struct AppFeatureView: View {
    @AppStorage(.Keys.showOnboarding) var showOnboarding: Bool = false

    var body: some View {
        if showOnboarding {
            Text("Onboarding")
        } else {
            Button {
                print("UserId: \(UserDefaults.standard.userId ?? "")")
            } label: {
                Text("AppFeature")
            }
        }
    }
}

You can check the output of the plugin by going to the Report Navigator which it's very useful for debugging issues.

Xcode Build tool plugins

So far all plugins we've implemented only run on Packages. In order to make them run on Xcode targets you just need to conform to XcodeCommandPlugin for Command type plugins and XcodeBuildToolPlugin for Build tool type plugins. Let's add XcodeBuildToolPlugin support to our previous example.

@main
struct GenUserDefaults: BuildToolPlugin, XcodeBuildToolPlugin {
    ...

    func createBuildCommands(
        context: XcodeProjectPlugin.XcodePluginContext,
        target: XcodeProjectPlugin.XcodeTarget
    ) throws -> [PackagePlugin.Command] {
        let tool = try context.tool(named: "GenUserDefaultsExec")

        guard let input = context.xcodeProject.filePaths
            .first(where: { $0.stem == "defaults" && $0.extension == nil })
        else {
            throw GenUserDefaultsError.specificationFileNotFound
        }

        let output = context.pluginWorkDirectory.appending(["UserDefaultsPreferences.swift"])

        print("ℹ️ Input: \(input)")
        print("ℹ️ Output: \(output)")

        return [
            .buildCommand(
                displayName: "Generate UserDefaults preferences",
                executable: tool.path,
                arguments: [input.string, output.string],
                inputFiles: [input],
                outputFiles: [output])
        ]
    }
}

Add the specification file to your project.

And lastly go to build phases and add a new build tool plugin.

Ta-da! Now whenever your xcode project gets compiled, your UserDefaults fields will be automatically generated, if needed.

Conclusion

Package Plugins is a tool that every iOS and macOS developer should familiarize themselves with. It can be used to generate type-safe representations of assets, localized strings, OpenAPI models, documentation, linting and more.

However, plugins are not without their faults. Implementing plugins can be frustrating at times. Not being able to depend on normal targets tripped me up at first. I found myself closing Xcode 14 frequently because sometimes Xcode wouldn't clear compilation errors, so you didn't know whether they were actual errors or just Xcode acting up. Debugging is also cumbersome, you have to use the good old print. Xcode could be more stable too. Having said that, the benefits outweighs its quirks.

There you go, I hoped this tutorial was useful and that you give plugins a try.

Related Articles

  • Meet Swift Package plugins
  • Create Swift Package plugins
iosswiftspmpluginsbuild tools

Author

Tiago Bras

Tiago Bras

iOS Developer

Code & Gaming

You may also like

November 7, 2024

Introducing Shorebird, code push service for Flutter apps

Update Flutter apps without store review What is Shorebird? Shorebird is a service that allows Flutter apps to be updated directly at runtime. Removing the need to build and submit a new app version to Apple Store Connect or Play Console for review for ev...

Christofer Henriksson

Christofer Henriksson

Flutter

May 27, 2024

Introducing UCL Max AltPlay, a turn-by-turn real-time Football simulation

At this year's MonstarHacks, our goal was to elevate the sports experience to the next level with cutting-edge AI and machine learning technologies. With that in mind, we designed a unique solution for football fans that will open up new dimensions for wa...

Rayhan NabiRokon UddinArman Morshed

Rayhan Nabi, Rokon Uddin, Arman Morshed

MonstarHacks

ServicesCasesAbout Us
CareersThought LeadershipContact
© 2022 Monstarlab
Information Security PolicyPrivacy PolicyTerms of Service