Learn to create command plugins for the Swift Package deal Supervisor to execute customized actions utilizing SPM and different instruments.
Swift
Introduction to Swift Package deal Supervisor plugins
To start with I might like to speak a number of phrases concerning the new SPM plugin infrastructure, that was launched within the Swift 5.6 launch. The very first proposal describes the detailed design of the plugin API with some plugin examples, that are fairly helpful. Truthfully talking I used to be a bit to lazy to rigorously learn by means of your complete documentation, it is fairly lengthy, however lengthy story quick, you may create the next plugin sorts with the presently current APIs:
- Construct instruments – may be invoked through the SPM targets
- pre-build – runs earlier than the construct begins
- construct – runs throughout the construct
- Instructions – may be invoked through the command line
- supply code formatting – modifies the code inside package deal
- documentation technology – generate docs for the package deal
- customized – consumer outlined intentions
For the sake of simplicity on this tutorial I am solely going to write down a bit concerning the second class, aka. the command plugins. These plugins have been a bit extra attention-grabbing for me, as a result of I wished to combine my deployment workflow into SPM, so I began to experiment with the plugin API to see how exhausting it’s to construct such a factor. Seems it is fairly straightforward, however the developer expertise it isn’t that good. 😅
Constructing a supply code formatting plugin
The very very first thing I wished to combine with SPM was SwiftLint, since I used to be not capable of finding a plugin implementation that I might use I began from scratch. As a place to begin I used to be utilizing the instance code from the Package deal Supervisor Command Plugins proposal.
mkdir Instance
cd Instance
swift package deal init --type=library
I began with a model new package deal, utilizing the swift package deal init
command, then I modified the Package deal.swift
file in keeping with the documentation. I’ve additionally added SwiftLint as a package deal dependency so SPM can obtain & construct the and hopefully my customized plugin command can invoke the swiftlint
executable when it’s wanted.
import PackageDescription
let package deal = Package deal(
title: "Instance",
platforms: [
.macOS(.v10_15),
],
merchandise: [
.library(name: "Example", targets: ["Example"]),
.plugin(title: "MyCommandPlugin", targets: ["MyCommandPlugin"]),
],
dependencies: [
.package(url: "https://github.com/realm/SwiftLint", branch: "master"),
],
targets: [
.target(name: "Example", dependencies: []),
.testTarget(title: "ExampleTests", dependencies: ["Example"]),
.plugin(title: "MyCommandPlugin",
functionality: .command(
intent: .sourceCodeFormatting(),
permissions: [
.writeToPackageDirectory(reason: "This command reformats source files")
]
),
dependencies: [
.product(name: "swiftlint", package: "SwiftLint"),
]),
]
)
I’ve created a Plugins
listing with a fundamental.swift file proper subsequent to the Sources
folder, with the next contents.
import PackagePlugin
import Basis
@fundamental
struct MyCommandPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) throws {
let device = strive context.device(named: "swiftlint")
let toolUrl = URL(fileURLWithPath: device.path.string)
for goal in context.package deal.targets {
guard let goal = goal as? SourceModuleTarget else { proceed }
let course of = Course of()
course of.executableURL = toolUrl
course of.arguments = [
"(target.directory)",
"--fix",
]
strive course of.run()
course of.waitUntilExit()
if course of.terminationReason == .exit && course of.terminationStatus == 0 {
print("Formatted the supply code in (goal.listing).")
}
else {
let downside = "(course of.terminationReason):(course of.terminationStatus)"
Diagnostics.error("swift-format invocation failed: (downside)")
}
}
}
}
The snippet above ought to find the swiftlint
device utilizing the plugins context then it will iterate by means of the accessible package deal targets, filter out non source-module targets and format solely these targets that comprises precise Swift supply recordsdata. The method object ought to merely invoke the underlying device, we will wait till the kid (swiftlint invocation) course of exists and hopefully we’re good to go. 🤞
Replace: kalKarmaDev informed me that it’s attainable to go the --in-process-sourcekit
argument to SwiftLint, it will repair the underlying concern and the supply recordsdata are literally fastened.
I wished to checklist the accessible plugins & run my supply code linter / formatter utilizing the next shell instructions, however sadly looks as if the swiftlint
invocation half failed for some unusual motive.
swift package deal plugin --list
swift package deal format-source-code #will not work, wants entry to supply recordsdata
swift package deal --allow-writing-to-package-directory format-source-code
Looks as if there’s an issue with the exit code of the invoked swiftlint
course of, so I eliminated the success verify from the plugin supply to see if that is inflicting the problem or not additionally tried to print out the executable command to debug the underlying downside.
import PackagePlugin
import Basis
@fundamental
struct MyCommandPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) throws {
let device = strive context.device(named: "swiftlint")
let toolUrl = URL(fileURLWithPath: device.path.string)
for goal in context.package deal.targets {
guard let goal = goal as? SourceModuleTarget else { proceed }
let course of = Course of()
course of.executableURL = toolUrl
course of.arguments = [
"(target.directory)",
"--fix",
]
print(toolUrl.path, course of.arguments!.joined(separator: " "))
strive course of.run()
course of.waitUntilExit()
}
}
}
Deliberately made a small “mistake” within the Instance.swift supply file, so I can see if the swiftlint –fix command will resolve this concern or not. 🤔
public struct Instance {
public personal(set) var textual content = "Howdy, World!"
public init() {
let xxx :Int = 123
}
}
Seems, after I run the plugin through the Course of invocation, nothing occurs, however after I enter the next code manually into the shell, it simply works.
/Customers/tib/Instance/.construct/arm64-apple-macosx/debug/swiftlint /Customers/tib/Instance/Checks/Instance --fix
/Customers/tib/Instance/.construct/arm64-apple-macosx/debug/swiftlint /Customers/tib/Instance/Checks/ExampleTests --fix
All proper, so we undoubtedly have an issue right here… I attempted to get the usual output message and error message from the operating course of, looks as if swiftlint
runs, however one thing within the SPM infrastructure blocks the code adjustments within the package deal. After a number of hours of debugging I made a decision to offer a shot to swift-format, as a result of that is what the official docs recommend. 🤷♂️
import PackageDescription
let package deal = Package deal(
title: "Instance",
platforms: [
.macOS(.v10_15),
],
merchandise: [
.library(name: "Example", targets: ["Example"]),
.plugin(title: "MyCommandPlugin", targets: ["MyCommandPlugin"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-format", exact: "0.50600.1"),
],
targets: [
.target(name: "Example", dependencies: []),
.testTarget(title: "ExampleTests", dependencies: ["Example"]),
.plugin(title: "MyCommandPlugin",
functionality: .command(
intent: .sourceCodeFormatting(),
permissions: [
.writeToPackageDirectory(reason: "This command reformats source files")
]
),
dependencies: [
.product(name: "swift-format", package: "swift-format"),
]),
]
)
Modified each the Package deal.swift
file and the plugin supply code, to make it work with swift-format
.
import PackagePlugin
import Basis
@fundamental
struct MyCommandPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) throws {
let swiftFormatTool = strive context.device(named: "swift-format")
let swiftFormatExec = URL(fileURLWithPath: swiftFormatTool.path.string)
for goal in context.package deal.targets {
guard let goal = goal as? SourceModuleTarget else { proceed }
let course of = Course of()
course of.executableURL = swiftFormatExec
course of.arguments = [
"--in-place",
"--recursive",
"(target.directory)",
]
strive course of.run()
course of.waitUntilExit()
if course of.terminationReason == .exit && course of.terminationStatus == 0 {
print("Formatted the supply code in (goal.listing).")
}
else {
let downside = "(course of.terminationReason):(course of.terminationStatus)"
Diagnostics.error("swift-format invocation failed: (downside)")
}
}
}
}
I attempted to run once more the very same package deal plugin command to format my supply recordsdata, however this time swift-format
was doing the code formatting as an alternative of swiftlint
.
swift package deal --allow-writing-to-package-directory format-source-code
// ... loading dependencies
Construct full! (6.38s)
Formatted the supply code in /Customers/tib/Linter/Checks/ExampleTests.
Formatted the supply code in /Customers/tib/Linter/Sources/Instance.
Labored like a attraction, my Instance.swift
file was fastened and the : was on the left aspect… 🎊
public struct Instance {
public personal(set) var textual content = "Howdy, World!"
public init() {
let xxx: Int = 123
}
}
Yeah, I’ve made some progress, however it took me numerous time to debug this concern and I do not like the truth that I’ve to fiddle with processes to invoke different instruments… my intestine tells me that SwiftLint just isn’t following the usual shell exit standing codes and that is inflicting some points, perhaps it is spawning little one processes and that is the issue, I actually do not know however I do not wished to waste extra time on this concern, however I wished to maneuver ahead with the opposite class. 😅
Integrating the DocC plugin with SPM
As a primary step I added some dummy feedback to my Instance library to have the ability to see one thing within the generated documentation, nothing fancy just a few one-liners. 📖
public struct Instance {
public personal(set) var textual content = "Howdy, World!"
public init() {
let xxx: Int = 123
}
}
I found that Apple has an official DocC plugin, so I added it as a dependency to my mission.
import PackageDescription
let package deal = Package deal(
title: "Instance",
platforms: [
.macOS(.v10_15),
],
merchandise: [
.library(name: "Example", targets: ["Example"]),
.plugin(title: "MyCommandPlugin", targets: ["MyCommandPlugin"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-format", exact: "0.50600.1"),
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
],
targets: [
.target(name: "Example", dependencies: []),
.testTarget(title: "ExampleTests", dependencies: ["Example"]),
.plugin(title: "MyCommandPlugin",
functionality: .command(
intent: .sourceCodeFormatting(),
permissions: [
.writeToPackageDirectory(reason: "This command reformats source files")
]
),
dependencies: [
.product(name: "swift-format", package: "swift-format"),
]),
]
)
Two new plugin instructions have been accessible after I executed the plugin checklist command.
swift package deal plugin --list
Tried to run the primary one, and fortuitously the doccarchive file was generated. 😊
swift package deal generate-documentation
Additionally tried to preview the documentation, there was a be aware concerning the --disable-sandbox
flag within the output, so I merely added it to my unique command and…
swift package deal preview-documentation
swift package deal --disable-sandbox preview-documentation
Magic. It labored and my documentation was accessible. Now that is how plugins ought to work, I liked this expertise and I actually hope that increasingly more official plugins are coming quickly. 😍
Constructing a customized intent command plugin
I wished to construct a small executable goal with some bundled assets and see if a plugin can deploy the executable binary with the assets. This may very well be very helpful after I deploy feather apps, I’ve a number of module bundles there and now I’ve to manually copy every part… 🙈
import PackageDescription
let package deal = Package deal(
title: "Instance",
platforms: [
.macOS(.v10_15),
],
merchandise: [
.library(name: "Example", targets: ["Example"]),
.executable(title: "MyExample", targets: ["MyExample"]),
.plugin(title: "MyCommandPlugin", targets: ["MyCommandPlugin"]),
.plugin(title: "MyDistCommandPlugin", targets: ["MyDistCommandPlugin"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-format", exact: "0.50600.1"),
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
],
targets: [
.executableTarget(name: "MyExample",
resources: [
.copy("Resources"),
], plugins: [
]),
.goal(title: "Instance", dependencies: []),
.testTarget(title: "ExampleTests", dependencies: ["Example"]),
.plugin(title: "MyCommandPlugin",
functionality: .command(
intent: .sourceCodeFormatting(),
permissions: [
.writeToPackageDirectory(reason: "This command reformats source files")
]
),
dependencies: [
.product(name: "swift-format", package: "swift-format"),
]),
.plugin(title: "MyDistCommandPlugin",
functionality: .command(
intent: .customized(verb: "dist", description: "Create dist archive"),
permissions: [
.writeToPackageDirectory(reason: "This command deploys the executable")
]
),
dependencies: [
]),
]
)
As a primary step I created a brand new executable goal known as MyExample
and a brand new MyDistCommandPlugin
with a customized verb. Contained in the Sources/MyExample/Assets
folder I’ve positioned a easy take a look at.json file with the next contents.
{
"success": true
}
The fundamental.swift
file of the MyExample
goal seems to be like this. It simply validates that the useful resource file is accessible and it merely decodes the contents of it and prints every part to the usual output. 👍
import Basis
guard let jsonFile = Bundle.module.url(forResource: "Assets/take a look at", withExtension: "json") else {
fatalError("Bundle file not discovered")
}
let jsonData = strive Knowledge(contentsOf: jsonFile)
struct Json: Codable {
let success: Bool
}
let json = strive JSONDecoder().decode(Json.self, from: jsonData)
print("Is success?", json.success)
Contained in the Plugins folder I’ve created a fundamental.swift file underneath the MyDistCommandPlugin folder.
import PackagePlugin
import Basis
@fundamental
struct MyDistCommandPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) throws {
}
}
Now I used to be capable of re-run the swift package deal plugin --list
command and the dist
verb appeared within the checklist of obtainable instructions. Now the one query is: how can we get the artifacts out of the construct listing? Thankfully the third instance of the instructions proposal is kind of comparable.
import PackagePlugin
import Basis
@fundamental
struct MyDistCommandPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) throws {
let cpTool = strive context.device(named: "cp")
let cpToolURL = URL(fileURLWithPath: cpTool.path.string)
let outcome = strive packageManager.construct(.product("MyExample"), parameters: .init(configuration: .launch, logging: .concise))
guard outcome.succeeded else {
fatalError("could not construct product")
}
guard let executable = outcome.builtArtifacts.first(the place : { $0.variety == .executable }) else {
fatalError("could not discover executable")
}
let course of = strive Course of.run(cpToolURL, arguments: [
executable.path.string,
context.package.directory.string,
])
course of.waitUntilExit()
let exeUrl = URL(fileURLWithPath: executable.path.string).deletingLastPathComponent()
let bundles = strive FileManager.default.contentsOfDirectory(atPath: exeUrl.path).filter { $0.hasSuffix(".bundle") }
for bundle in bundles {
let course of = strive Course of.run(cpToolURL, arguments: ["-R",
exeUrl.appendingPathComponent(bundle).path,
context.package.directory.string,
])
course of.waitUntilExit()
}
}
}
So the one downside was that I used to be not capable of get again the bundled assets, so I had to make use of the URL of the executable file, drop the final path part and skim the contents of that listing utilizing the FileManager
to get again the .bundle
packages within that folder.
Sadly the builtArtifacts
property solely returns the executables and libraries. I actually hope that we will get help for bundles as properly sooner or later so this hacky answer may be averted for good. Anyway it really works simply high-quality, however nonetheless it is a hack, so use it rigorously. ⚠️
swift package deal --allow-writing-to-package-directory dist
./MyExample
I used to be capable of run my customized dist command with out additional points, in fact you need to use further arguments to customise your plugin or add extra flexibility, the examples within the proposal are just about okay, however it’s fairly unlucky that there is no such thing as a official documentation for Swift package deal supervisor plugins simply but. 😕
Conclusion
Studying about command plugins was enjoyable, however to start with it was annoying as a result of I anticipated a bit higher developer expertise relating to the device invocation APIs. In abstract I can say that that is just the start. It is identical to the async / await and actors addition to the Swift language. The function itself is there, it is largely able to go, however not many builders are utilizing it each day. This stuff would require time and hopefully we will see much more plugins in a while… 💪