What if instead of a CLI, Plugins? Part 1, Command plugins
This article is part of a series.
Related Repos
- https://github.com/carlynorama/BuildPluginExample
- https://github.com/carlynorama/BuildPluginExampleTool
- https://github.com/carlynorama/PluginExplorer
I like writing little command line utilities to make my life easier. Swift Package Manager has developed a plugin system which makes those tools easier to reuse across projects. The tools can be incorporated into the build process, before or during. They can also run on demand.
- run on demand: command plugin (this post)
- run only if resources are missing or stale: build plugin (part 2)
- run every build, before the build: pre-build plugin
I wrote one of each. This post covers the intro and command plugins. The related repo already has all three. It has an integrated CLI to test build scripts and wasn’t designed to import into non-package projects from the outset but I’m converting it. I will cover how to make Xcode project compatible plugins from the outset in the write up though. To be clear, package plugins ARE available in in Xcode when a package has them as a dependency. You just can’t use package plugins directly on iOS apps, etc.
While I do describe how to run plugins, I do not include a lot of screen shots. For more details watch:
- WWDC 2022 Meet Swift Package plugins
- WWDC 2022 Create Swift Package plugins
Also, to help with descriptions: Xcode Window Terminology
Handy Commands For Running Command Plugins
swift package plugin --list
swift package $PLUGIN_VERB
swift package --allow-writing-to-package-directory $PLUGIN_VERB
swift package clean #in lieu of deleting the .build folder as needed
Handy Commands for Generating Projects
This is for 5.9, More options/flags will be available in 5.10 it looks like.
swift package init --type tool #Comes with argument parser
swift package init --type build-tool-plugin
swift package init --type command-plugin
Misc Gotchas for All Three Types
- Code generated by build plugins is generally detectable by IDE’s, but the build tool has to have been run at least once before you can use it.
- Code only associated with a
.plugin
target doesn’t get swept up in targets, and build problems can only be found in the build logs. Code in a stand alone tool imported by the plugin does get more regular treatment. Prioritize putting as much as possible into a tool and as little a possible only in a plugin.swift. - Write the tool and get it completely working, then write the plugin.
- stdout for a tool does not always work as expected.
- For two plugins to share code they need to both depend on the same executable target or a binary, not a private target. See discussion
- Adding new files to the source directory is expensive to build plugins. It will rerun not just the plugin, but regenerate every command and run the tool(s).
References
Official
- WWDC 2022 Meet Swift Package plugins
- WWDC 2022 Create Swift Package plugins
- https://github.com/apple/swift-package-manager/
- https://forums.swift.org/t/pitch-package-manager-command-plugins/53172
- https://github.com/apple/swift-evolution/blob/main/proposals/0303-swiftpm-extensible-build-tools.md
- https://github.com/apple/swift-evolution/blob/main/proposals/0325-swiftpm-additional-plugin-apis.md
- https://github.com/apple/swift-evolution/blob/main/proposals/0332-swiftpm-command-plugins.md
- https://github.com/apple/swift-evolution/blob/main/proposals/0356-swift-snippets.md
- https://github.com/apple/swift-package-manager/blob/main/Documentation/Plugins.md
- WWDC 2023 Meet Swift OpenAPI Generator
Repos with Interesting Plugins
- Example bringing in linting from
MessageKit
https://github.com/MessageKit/MessageKit/tree/3fab2f2d7f04b0f7ec19e2bfab0f614fef884ff8/Plugins - https://github.com/SwiftGen/SwiftGenPlugin
- https://github.com/realm/SwiftLint/tree/main/Plugins/SwiftLintPlugin
- https://github.com/apple/swift-docc-plugin
- https://github.com/apple/swift-openapi-generator
- https://github.com/lighter-swift
Command Plugins
The first plugin I wrote was a command plugin. For Advent of Code I wrote a CLI to generate boiler plate code each day. I wanted to see if I could reproduce that behavior with a plugin. I’ll write more about that later.
As part of my trouble shooting process I wrote a plugin that would just write information about the plugin and the package to a file so I could figure out what all the variables were doing.
I created a separate project to house this utility plugin so I could delete it from the original project. That project has become my plugin tester project and no longer contains just the code below. While still usable by other packages, the repo in its current state is not for use in projects. The code below can work in either.
Getting Started
These are the general steps one can follow to make a command plugin. It can be imported into another package or project as a dependency and the plugin will be available to that package or project.
# handy thing to have done if you do this by hand a lot.
git config --global alias.start-repo '!git init . && git add . && git commit --allow-empty -m "Initialize repository"'
mkdir $MYPLUGINNAME
cd $MYPLUGINNAME
swift package init --type command-plugin
touch README.md
# git start-repo if available (see above)
git init .
git add .
git commit -m "Initialize repository"
This will create a directory structure that looks like
└── $MYPLUGINNAME
└── Plugins
│ └── $MYPLUGINNAME.swift
└── Package.swift
└── .git
└── .gitignore
This is fine if it will only hold the one plug in, but you may prefer
└── $MYPLUGINNAME
└── Plugins
│ └── $MYPLUGINNAME
| └── plugin.swift
└── Package.swift
└── .git
└── .gitignore
The Default 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: "$MYPLUGINNAME",
products: [
// Products can be used to vend plugins, making them visible to other packages.
.plugin(
name: "$MYPLUGINNAME",
targets: ["$MYPLUGINNAME"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
//NOTE FROM ME: You will not have to update the path parameter if you move the
//plugin code into Plugin/\(name)/plugin.swift
.plugin(
name: "$MYPLUGINNAME",
capability: .command(intent: .custom(
verb: "$MYPLUGINNAME",
description: "prints hello world"
))
),
]
)
The Default plugin.swift
The contents of $MYPLUGINNAME.swift/plugin.swift will have a func performCommand
for packages and a func performCommand
for Xcode projects that are not packages.
Here’s what it looks like with a couple of comments from me.
import PackagePlugin
//NOTE THE MAIN, plugins are stand alone scripts that require an entry point.
//If your code doesn't run check to make sure you have this.
@main //<==
struct $MYPLUGINNAME: CommandPlugin {
// Entry point for command plugins applied to Swift Packages.
func performCommand(context: PluginContext, arguments: [String]) async throws {
print("Hello, World!")
}
}
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin
extension MyCommandPlugin: XcodeCommandPlugin { //<====== NOT THE SAME NAME WILL HAVE TO FIX
// Entry point for command plugins applied to Xcode projects.
func performCommand(context: XcodePluginContext, arguments: [String]) throws {
print("Hello, World!")
}
}
#endif
It has both because some of the variables in the XcodePluginContext
are different than the PluginContext
. For example:
//CommandPlugin
let location = context.package.directory.appending([fileName])
//XcodeCommandPlugin
let location = context.xcodeProject.directory.appending([fileName])
See
- https://forums.swift.org/t/using-build-tool-plugins-with-xcproj/56064/9
- https://github.com/apple/swift-package-manager/blob/main/Documentation/Plugins.md#xcode-extensions-to-the-packageplugin-api
One thing to keep in mind, if your command plugin generates files in a target’s source directory the behavior will be different for a package vs an App.xcodeproj
. The plugin for Xcode projects may successfully generate files in the right directory but the project will not know about them until they are added to the project by hand. (File > Add Files) There may be a way but I don’t know it.
My Plugin Code
Here is my plugin for spitting out values from the context
parameter and writing them to file. There is duplication between the two that I may go back and tidy up, but in the short term it wasn’t worth it to me.
//
// TellMeAboutYourself/plugin.swift
//
//
// Created by Carlyn Maw on 1/14/24.
//
//Print statements can be found in build log in Xcode in "Package" section.
import PackagePlugin
import Foundation
@main
struct TellMeAboutYourself: CommandPlugin {
func performCommand(context: PackagePlugin.PluginContext,
arguments: [String]) async throws {
let fileName = "WhatThePluginSees" + ".txt"
var message = "Arguments Info"
message.append("\narguments:\(arguments)")
var argExtractor = ArgumentExtractor(arguments)
message.append("\nargument extractor:\(argExtractor)")
let targetNames = argExtractor.extractOption(named: "target")
message.append("\nextracted names:\(targetNames)")
message.append("\n\nContext Info")
message.append("\nworkDirectory: \(context.pluginWorkDirectory)")
message.append("\n\nPackage Info")
message.append("\norigin: \(context.package.origin)")
message.append("\ndirectory: \(context.package.directory)")
message.append("\nproducts:\(context.package.products.map({$0.name}))")
message.append("\nall targets:\(context.package.targets.map({$0.name}))")
//Cannot find 'PluginTarget' in scope
//SwiftSourceModuleTarget.self does work.
//let specialTargets = context.package.targets(ofType: PluginTarget.self)
//Nope. No plugins in here.
let targets = context.package.targets
//FWIW not even if you asked for them explicitly.
//let targets = try context.package.targets(named: targetNames)
let targetDirectories = targets.map({"\ndirectory for \($0.name): \($0.directory)"})
for dir in targetDirectories {
message.append(dir)
}
message.append("\n\nSwift Source Files")
let packageDir = context.package.directory.lastComponent
for target in targets {
//let targetDir = target.directory.lastComponent
if let sourceList = target.sourceModule?.sourceFiles(withSuffix: ".swift") {
sourceList.forEach({ sourceFile in
let fullPath = sourceFile.path.string
let range = fullPath.firstRange(of: "\(packageDir)")
//if used .name instead of .directory.lastComponent
//would need this redundancy for sure. probably overkill here.
let pathStart = range?.lowerBound ?? fullPath.startIndex
let relativePath = fullPath.suffix(from: pathStart)
message.append("\n\(relativePath) \ttype:\(sourceFile.type)")
})
}
}
message.append("\n\n\n--------------------------------------------------------------------")
message.append("\nDUMPS\n")
//message.append("\(context)")
//message.append("\nsourceModules: \(context.package.sourceModules)")
//message.append("\nproducts:\(context.package.products)")
//message.append("\ntargets:\(context.package.targets)")
//let sources = targets.map({ $0.sourceModule?.sourceFiles })
//message.append("\n\nsourceFiles:\(sources)")
let location = context.package.directory.appending([fileName])
try writeToFile(location: location, content: message)
}
func writeToFile(location:Path, content:some StringProtocol) throws {
try content.write(toFile: location.string, atomically: true, encoding: .utf8)
}
}
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin
extension TellMeAboutYourself: XcodeCommandPlugin {
func performCommand(context: XcodeProjectPlugin.XcodePluginContext, arguments: [String]) throws {
let fileName = "WhatThePluginSees" + ".txt"
var message = "Arguments Info"
message.append("\narguments:\(arguments)")
var argExtractor = ArgumentExtractor(arguments)
message.append("\nargument extractor:\(argExtractor)")
let targetNames = argExtractor.extractOption(named: "target")
message.append("\nextracted names:\(targetNames)")
message.append("\n\nContext Info")
message.append("\nworkDirectory: \(context.pluginWorkDirectory)")
message.append("\n\nPackage Info")
message.append("\nNot available for an xcodeProject")
message.append("\n\nXodeProject Info")
let project = context.xcodeProject
message.append("\ndirectory: \(context.xcodeProject.directory)")
message.append("\nfilePaths: \(context.xcodeProject.filePaths)")
message.append("\nall targets:\(context.xcodeProject.targets.map({$0.displayName}))")
let targets = context.xcodeProject.targets
let targetDirectories = targets.map({"\n\($0.displayName) of type \(String(describing: $0.product))"})
for dir in targetDirectories {
message.append(dir)
}
message.append("\n\nSwift Source Files")
let packageDir = context.xcodeProject.directory.lastComponent
for target in targets {
//let targetDir = target.directory.lastComponent
let sourceList = target.inputFiles.filter({$0.type == .source})
sourceList.forEach({ sourceFile in
let fullPath = sourceFile.path.string
let range = fullPath.firstRange(of: "\(packageDir)")
//if used .name instead of .directory.lastComponent
//would need this redundancy for sure. probably overkill here.
let pathStart = range?.lowerBound ?? fullPath.startIndex
let relativePath = fullPath.suffix(from: pathStart)
message.append("\n\(relativePath) \ttype:\(sourceFile.type)")
})
}
message.append("\n\n\n--------------------------------------------------------------------")
message.append("\nDUMPS\n")
//message.append("\(context)")
let location = context.xcodeProject.directory.appending([fileName])
try writeToFile(location: location, content: message)
}
}
#endif
Update package.swift
Add the request for permission to the package manifest.
.plugin(
name: "TellMeAboutYourself",
capability: .command(intent: .custom(verb: "about",
description: "See info about the package"),
permissions: [.writeToPackageDirectory(reason: "This plugin creates a file with information about the plugin and the package it's running on.")]
)
)
Running the plugin.
The plugin should be available when you right click the package that depends on it or contains it in Xcode’s file list. If not, try updating the package or closing and reopening the project.
In the command line
cd $YOUR_PACKAGE
# See the plugin available to that package
swift package plugin --list
# run the one you want
swift package $PLUGIN_VERB
# run the one you want silencing (with agreement) the write permissions request
swift package --allow-writing-to-package-directory $PLUGIN_VERB
Running an existing tool instead.
One thing I found unintuitive is that although plugins are listed as targets in the the target section, they are not listed as targets in the plugin context. This means that for long involved plugins the better approach would be to put as much as possible into an external tool. As a bonus, this also allows command plugins to share code with build plugins.
- https://github.com/apple/swift-package-manager/blob/4b7ee3e328dc8e7bec33d4d5d401d37abead6e41/Sources/PackageModel/Target/PluginTarget.swift#L13
- https://forums.swift.org/t/package-command-plugins-cant-see-plugin-targets/69439/1
An example of a command plugin running a tool that it shares with a build plugin is what MessageKit does with SwiftLint. They define their tool with let swiftLintTool = try context.tool(named: "swiftlint")
, where the binary for swiftlint
is defined in the package. Plugins can depend on any binary that the shell spawned by build tool will see, including ones in /bin/
. Using a tool not defined in the package means the developer using the plugin may not have it in their environment. Look for the error “Plugin does not have access to a tool named ‘$WHATYOUTRIED’
”.
import Foundation
import PackagePlugin
// MARK: - SwiftLintCommandPlugin
@main
struct SwiftLintCommandPlugin: CommandPlugin {
func performCommand(context: PackagePlugin.PluginContext, arguments _: [String]) async throws {
let swiftLintTool = try context.tool(named: "swiftlint")
let swiftLintPath = URL(fileURLWithPath: swiftLintTool.path.string)
let swiftLintArgs = [
"lint",
"--path", context.package.directory.string,
"--config", context.package.directory.string + "/.swiftlint.yml",
"--strict",
]
let task = try Process.run(swiftLintPath, arguments: swiftLintArgs)
task.waitUntilExit()
if task.terminationStatus == 0 || task.terminationStatus == 2 {
// no-op
} else {
throw CommandError.unknownError(exitCode: task.terminationStatus)
}
}
}
// MARK: - CommandError
enum CommandError: Error {
case unknownError(exitCode: Int32)
}
Summary
Command plugins let you run tools on demand in your project through a handy XCode interface or in the command line. They can lookup information about your project without it being hand coded every time, which gives them a leg up over a generic CLI in which you might have to hand code (and get wrong) a file path.
The examples shown tend to be generating resource files or applying formatting to existing code. It seems like the “Swifty Way” to generate actual source code is to use a build plugin or a prebuild plugin which will put that code directly into the build folder instead. This keeps you from “cluttering your source folder”. So that’s what we’ll do next!
See more in Part 2, Build Plugin Start