What if instead of a CLI, Plugins? Part 1, Command plugins

This article is part of a series.

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.

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:

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

References

Official

Repos with Interesting Plugins

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

(name fix PR)

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

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.

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

This article is part of a series.