How to generate Swift interfaces from Pkl configuration files using SPM plugins
Codemagic is the first CI/CD to make Apple M2 machines available to everyone (including the free tier!). This is a free upgrade from M1 machines with no price change.
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:
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:
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:
# 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:
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:
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:
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:
// 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:
// 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:
- We declare that the package is only available for macOS 13 and later to satisfy the requirement of pkl-swift.
- We declare a new product of type
plugin
that will be used to expose theswift-pkl
command. - 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.
- We declare a new target for the
swift-pkl
command plugin. We also declare the dependencies of the plugin, which are thepkl-gen-swift
executable and thePkl
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 thepkl
command-line tool. - We declare a new binary target for the
pkl
command-line tool’s artifact bundle. - 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:
{
"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:
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:
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:
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.