How to generate Swift interfaces from Pkl configuration files using SPM plugins

Sponsored

Zenitizer logo
Zenitizer - Meditation Timer

Enjoy clutter-free meditation with Zenitizer's clean UI and soothing sounds. For Apple geeks: Shortcuts, Apple Health, widgets and fully-featured watchOS and visionOS apps! Get 25% OFF with code: POL24

Pkl (pronounced Pickle) is a new programming language from Apple designed specifically for configuration. It allows developers to design data models safely and expressively through the use of types and built-in validation.

A feature that sets it apart for Apple developers and, as it couldn’t be any other way with Pkl being an Apple language, is that it has a suite of tools available for generating Swift interfaces from .pkl configuration files.

In this article, you will learn how to install and use the pkl-gen-swift command-line tool and how to integrate it into your Swift Package Manager (SPM) project through the use of SPM plugins.

Something you must note is that, at this point, Pkl is only available for macOS.

An example Pkl config

Let’s get started by creating a simple Pkl module called Config with a set of properties that will define the configuration of a small macOS Swift Package library:

Config.pkl
module Config

baseUrl: String
retryCount: Int(isBetween(0, 3))
timeout: Duration

As you can see in the snippet above, we are making use of types and ranges to constrain the values that can be assigned to the properties and reduce the likelihood of errors.

These types will be used by the Pkl CLI tools to both validate the configuration files and help generate Swift interfaces.

Let’s now write a separate .pkl file that amends the module file we created earlier and provides configuration values for local development:

local.pkl
amends "Config.pkl"

baseUrl = "https://localhost:8080"
retryCount = 0
timeout = 30.s

And just like that, we have written a small configuration and we have specified some types and constraints that we can enforce.

Let’s now install the pkl command line tool and evaluate the module that defines the actual values:

Terminal
# Install pkl
curl -L -o pkl https://github.com/apple/pkl/releases/download/0.25.2/pkl-macos-aarch64
chmod +x pkl

# Evaluate the local file
./pkl eval Sources/ClientExample/Resources/local.pkl

The output of running the commands above prints the correct values, which means the configurations can be validated correctly:

Terminal
baseUrl = "https://localhost:8080"
retryCount = 0
timeout = 30.s

Generating Swift bindings

As I mentioned at the beginning of the article, one of the most powerful features of defining your configuration using Pkl is that you can generate Swift interfaces for your apps.

To generate Swift interfaces from pkl files, you need to install the pkl and pkl-gen-swift command-line tools.

Installing and using pkl-gen-swift manually

First, let’s install the pkl-gen-swift command-line tool:

Terminal
curl -L https://github.com/apple/pkl-swift/releases/download/0.2.3/pkl-gen-swift-macos.bin -o pkl-gen-swift
chmod +x pkl-gen-swift

Let’s now generate Swift interfaces from the .pkl files by running the following command in the terminal:

Terminal
PKL_EXEC=./pkl
./pkl-gen-swift Sources/ClientExample/Resources/*.pkl -o Sources/ClientExample/Generated

Note that pkl-gen-swift relies on the pkl command-line tool, which needs to either be available in your PATH or be specified using the PKL_EXEC environment variable.

The output of the command will be a single Swift file containing the generated interface:

Sources/ClientExample/Generated/Config.pkl.swift
// Code generated from Pkl module `Config`. DO NOT EDIT.
import PklSwift

public enum Config {}

extension Config {
    public struct Module: PklRegisteredType, Decodable, Hashable {
        public static var registeredIdentifier: String = "Config"

        public var baseUrl: String

        public var retryCount: Int

        public var timeout: Duration

        public init(baseUrl: String, retryCount: Int, timeout: Duration) {
            self.baseUrl = baseUrl
            self.retryCount = retryCount
            self.timeout = timeout
        }
    }

    /// Load the Pkl module at the given source and evaluate it into `Config.Module`.
    ///
    /// - Parameter source: The source of the Pkl module.
    public static func loadFrom(source: ModuleSource) async throws -> Config.Module {
        try await PklSwift.withEvaluator { evaluator in
            try await loadFrom(evaluator: evaluator, source: source)
        }
    }

    /// Load the Pkl module at the given source and evaluate it with the given evaluator into
    /// `Config.Module`.
    ///
    /// - Parameter evaluator: The evaluator to use for evaluation.
    /// - Parameter source: The module to evaluate.
    public static func loadFrom(
        evaluator: PklSwift.Evaluator,
        source: PklSwift.ModuleSource
    ) async throws -> Config.Module {
        try await evaluator.evaluateModule(source: source, as: Module.self)
    }
}

Creating a SPM command plugin

Let’s say you don’t want everyone who actively works on your Swift Package to have to install all required tools manually to generate code when they modify the configurations.

You can instead create a Swift Package Manager command plugin that will wrap both command-line tools and expose a client-friendly command that finds all configuration files and generates Swift interfaces from them.

Let’s consider the following Swift Package:

Package.swift
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "PklSwiftPlugin",
    platforms: [
        // 1
        .macOS(.v13),
    ],
    products: [
        // 2
        .plugin(name: "PklSwiftCommand", targets: ["PklSwiftCommand"])
    ],
    dependencies: [
        // 3
        .package(url: "https://github.com/apple/pkl-swift.git", exact: "0.2.3")
    ],
    targets: [
        // 4
        .plugin(name: "PklSwiftCommand",
                capability: .command(intent: .custom(verb: "swift-pkl", description: ""),
                                     permissions: [.writeToPackageDirectory(reason: "Write pkl to pkg")]),
                dependencies: [.product(name: "pkl-gen-swift", package: "pkl-swift"), "Pkl"]),
        // 5
        .binaryTarget(name: "Pkl",
                      path: "Pkl.artifactbundle"),
        // 6
        .target(name: "ClientExample",
                dependencies: [.product(name: "PklSwift", package: "pkl-swift")])
    ]
)

Let’s break down what’s going on above step by step:

  1. We declare that the package is only available for macOS 13 and later to satisfy the requirement of pkl-swift.
  2. We declare a new product of type plugin that will be used to expose the swift-pkl command.
  3. We declare Apple’s pkl-swift as the package’s only depdency. pkl-swift provides the Swift bindings for the Pkl language and the executable to generate the Swift interfaces.
  4. We declare a new target for the swift-pkl command plugin. We also declare the dependencies of the plugin, which are the pkl-gen-swift executable and the Pkl command line tool in the form of an artifact bundle. Luckily enough, we can rely on the executable product from the pkl-swift package to add the Swift generator as a dependency but we need to manually create an artifact bundle for the pkl command-line tool.
  5. We declare a new binary target for the pkl command-line tool’s artifact bundle.
  6. We declare a new target for the library that will be used for testing. This is the target that will contain the .pkl configuration files.

To create an artifact bundle that wraps the pkl command line tool, you just need to create a directory with the same name you have declared in the package manifest followed by the .artifactbundle extension. In this directory, create the following folder structure:

Pkl.artifactbundle
├── info.json
├── pkl-0.25.2-macos
│   └── bin
│       └── pkl

The info.json file should contain the following:

info.json
{
  "schemaVersion": "1.0",
  "artifacts": {
    "pkl": {
      "version": "0.2.3",
      "type": "executable",
      "variants": [
        {
          "path": "pkl-0.25.2-macos/bin/pkl",
          "supportedTriples": ["arm64-apple-macosx"]
        }
      ]
    }
  }
}

Let’s now write the code for the command plugin, which will retrieve the command line tools from the context, iterate through the targets to find all .pkl files and then finally run the pkl-gen-swift executable to generate the Swift interfaces:

Sources/PklSwiftCommand/main.swift
import PackagePlugin
import Foundation

@main
struct PklSwiftCommandPlugin: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        let pklGenSwift = try context.tool(named: "pkl-gen-swift")
        let pkl = try context.tool(named: "pkl")
        let pklGenSwiftURL = URL(filePath: pklGenSwift.path.string)
        
        for target in context.package.targets {
            let dirEnum = FileManager.default.enumerator(atPath: target.directory.string)
            var pklFiles = [Path]()
            while let file = dirEnum?.nextObject() as? String {
                if file.hasSuffix(".pkl") {
                    pklFiles.append(target.directory.appending(subpath: file))
                }
            }
            
            let process = Process()
            process.executableURL = pklGenSwiftURL
            process.arguments = pklFiles.map { $0.string } + ["-o", target.directory.appending(subpath: "Generated").string]
            process.environment = ["PKL_EXEC": pkl.path.string]
            
            try process.run()
            process.waitUntilExit()
            
            let gracefulExit = process.terminationReason == .exit && process.terminationStatus == 0
            if !gracefulExit {
                throw "🛑 The plugin execution failed with reason: \(process.terminationReason.rawValue) and status: \(process.terminationStatus) "
            }
        }
    }
}

extension String: Error {}

The command plugin can now be run like so:

Terminal
swift package --disable-sandbox swift-pkl --allow-writing-to-package-directory

Note that you will need to use the --disable-sandbox flag otherwise the plugin will hang indefinitely. I have not been able to find a workaround for this issue yet so if you have any ideas, please let me know.

The output of the command will yield the same results as before.

Loading a Pkl configuration

Now that we have generated the Swift interfaces, we can load them into our app with the following code:

Sources/ClientExample/main.swift
import PklSwift
import Foundation

func load() async throws {
    let pklGenSwift = Bundle.module.bundleURL.deletingLastPathComponent().appending(path: "pkl-gen-swift").path
    let pklFile = Bundle.module.url(forResource: "local", withExtension: "pkl")!
    setenv("PKL_EXEC", pklGenSwift, 1)
    let config = try await SomeConfig.loadFrom(source: .path(pklFile.path))
    print(config.baseUrl)
    print(config.timeout)
    print(config.retryCount)
}

After attempting to execute the same code as in the documentation I ran into an issue where PklSwift was not able to find pkl in the path. For this reason, I had to set the PKL_EXEC environment variable manually in the sample executable.

Even after getting past this hurdle, running the executable seems to throw a bunch of errors that I have not been able to resolve yet. Will update this article once I have a solution.