← Back to list

Swift Package Plugins: Part 2 — Build Tool Plugins

Before we get started, if you’re new to Swift package plugins, I highly recommend checking out my article: Swift Package Plugins: Part 1 —…

Rohan Bimal Raj · 2025-01-19 18:08 · 5 claps · 9.3 min read
#swift-package-plugins #build-tool-plugin #ios-app-development #fonts #ios
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Swift Package Plugins: Part 2 — Build Tool Plugins

Before we get started, if you’re new to Swift package plugins, I highly recommend checking out my article: Swift Package Plugins: Part 1 — Introduction. In that article, I introduce Swift package plugins, explain the different types and their capabilities, and explore the use cases for each. In this tutorial, we’ll dive deeper into Build Tool Plugins, with a specific focus on In-Build Plugins.

Build Tool Plugins

Build tool plugins are Swift package plugins that integrate directly into the build process of your project, running as part of every build. They allow you to customise and automate tasks during the build lifecycle. These plugins are categorised into two types:

  1. Pre-Build Plugins
  • Pre-build plugins are executed before the build process begins.
  • They are ideal when the name of the output files is not known beforehand or in other words cannot be determined before the command is run — this is the case if the contents of the input files (as opposed to just their names) determine the number and names of the output files.
  • However, because pre-build commands run before each build, the build system does not handle caching for them. Therefore, you must implement your own caching mechanism to minimise work and prevent slowing down incremental builds.
  • The plugin will return a prebuildCommand
return [.prebuildCommand(
            displayName: "Running SomeTool",
            executable: try context.tool(named: "SomeTool").path,
            arguments: [ "--verbose", "--outdir", outputDir ],
            outputFilesDirectory: outputDir)
        ]

2. In-Build Plugins

  • In-build plugins run during the build process and are suitable when the paths to both input files and the expected output files are known ahead of time.
  • These plugins benefit from the build system’s built-in dependency tracking and caching. The build system automatically determines whether the command needs to be re-run by checking: — Whether the outputs are missing — When the inputs have changed since the last time the command ran
  • Since the build system handles caching, you don’t need to implement a custom cache mechanism for these plugins.
  • The plugin returns a buildCommand
return [.buildCommand(
                displayName: "Generating \(outputName) from \(inputPath.lastComponent)",
                executable: try context.tool(named: "SomeTool").path,
                arguments: [ "--verbose", "\(inputPath)", "\(outputPath)" ],
                inputFiles: [ inputPath, ],
                outputFiles: [ outputPath ]
            )]

In either case, it’s important to understand that the plugin itself doesn’t perform the work of the build command. Instead, the plugin’s role is to construct the commands that will be executed later. These commands are responsible for carrying out the actual tasks. The plugin is typically lightweight and mainly focuses on assembling the command-line arguments for the build command.

Lets Get Started

In this tutorial, we will focus on creating an In-Build Plugin to automatically generate a Swift enum for fonts added to your project. This plugin will streamline font management by providing a strongly-typed, auto-generated enum that allows you to easily reference fonts in your code.

Step 1: Create a Swift Package

As mentioned in the earlier article, plugins are distributed as part of a Swift package. The first step is to create a Swift package. You can name it anything you like; in this example, I’ll call it FontEnum.

Package.swift file of Newly created swift package

Package.swift file of Newly created swift package

Step 2: Define the Plugin in Package.swift

  1. Open the Package.swift file.
  2. Add a new target for the plugin:
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.
    .target(
        name: "FontEnum"),
    .plugin(name: "FontEnumGenerator", capability: .buildTool())

]
  1. Update the products section if you want to make your plugin available to external projects or other Swift packages. If you only intend to use the plugin within the package it’s defined in, you can skip this step.
products: [
    // Products define the executables and libraries a package produces, making them visible to other packages.
    .plugin(name: "FontEnumGenerator", targets: ["FontEnumGenerator"])
]
  1. This step is optional, but in this example, I will remove the FontEnum target from the products section since we won’t be importing anything from it .

Package.swift file after following steps 1 to 4

Package.swift file after following steps 1 to 4

Step 3: Implement the Plugin

  1. Create a new folder named Plugins in your package directory. If you prefer to use a different name for the folder, you must specify the custom path for the plugin target in the targets section of your Package.swift file.
  2. Within the Plugins folder, create a subfolder named FontEnumGenerator, and inside it, add a new Swift file called FontEnumGenerator.swift.
FontEnum/
├── Package.swift
├── Sources/
│   └── FontEnum
└── Plugins/
    └── FontEnumGenerator/
        └── FontEnumGenerator.swift
  1. Implement Plugin:
import PackagePlugin
import Foundation

@main
struct FontEnumGenerator: BuildToolPlugin {

    // This method creates the build commands which will be run by the build system when necessary
    func createBuildCommands(context: PackagePlugin.PluginContext, target: any PackagePlugin.Target) async throws -> [PackagePlugin.Command] {

        // The executable responsible for generating the Swift enum file from font files
        let fontGenerator = try context.tool(named: "FontEnumGeneratorExc")
        let targetName = target.name

        // Ensure the target has a source module (to retrieve input files)
        guard let sourceModule = target.sourceModule else {
            throw PluginError.missingSourceModule
        }

        // Filter the source files in the target to include only font files with valid extensions
        let inputFiles = sourceModule.sourceFiles.filter { hasValidFontExtension($0.url) }.map{ $0.url }
        let inputFilesArguments = inputFiles.map{ $0.path() }

        // If no valid font files are found, throw an error and display a diagnostic message
        if inputFiles.isEmpty {
            Diagnostics.error("The target \(targetName) does not contain any custom fonts in a supported format. Supported formats are: .ttf, .otf.")
            throw PluginError.missingInputFiles
        }

        // Define the output directory where the generated enum file will be stored
        let outputDirectory = context.pluginWorkDirectoryURL
                                     .appending(path: target.name)
                                     .appending(path: "Generated")

        // Create the output directory if it doesn't already exist
        try FileManager.default.createDirectory(
            at: outputDirectory,
            withIntermediateDirectories: true
        )

        // Define the path for the generated Swift file
        let outputFile = outputDirectory.appending(path: "\(targetName)GeneratedFonts.swift")

        // Return the build command to execute the font generator tool
        return [
            .buildCommand(
                displayName: "Generating Font Definitions For \(targetName)", // Display name for the build process
                executable: fontGenerator.url, // Path to the executable tool
                arguments: [
                    outputFile.path(), // Output file path as the first argument
                ] + inputFilesArguments , // Append input font file paths as arguments
                environment: [:], // Specify any custom environment variables if needed
                inputFiles: inputFiles, // Declare input files for tracking changes
                outputFiles: [outputFile] // Declare the output file for tracking changes. Files which are declared here will be included in the bundle when the app is archived
            )
        ]
    }
}

private extension FontEnumGenerator {

    // Helper method to check if a file has a valid font extension (.ttf or .otf)
    func hasValidFontExtension(_ fileUrl: URL) -> Bool {
        let fileName = fileUrl.lastPathComponent
        return fileName.hasSuffix(".ttf") || fileName.hasSuffix(".otf")
    }
}

// Enum to define possible plugin errors
enum PluginError: Error {
    case missingInputFiles // Error for missing font files
    case missingSourceModule // Error for missing source module
}
  1. This step is optional, but if you want your plugins to be available for Xcode projects — or in other words, to work within Xcode projects — add the following code:
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin

// Extend FontEnumGenerator to conform to XcodeBuildToolPlugin
// This enables the plugin to work with Xcode projects
extension FontEnumGenerator: XcodeBuildToolPlugin {

    func createBuildCommands(context: XcodeProjectPlugin.XcodePluginContext, target: XcodeProjectPlugin.XcodeTarget) throws -> [PackagePlugin.Command] {

        // Retrieve the tool (executable) defined in the package
        let fontGenerator = try context.tool(named: "FontEnumGeneratorExc")
        let targetName = target.displayName // Get the human-readable name of the target

        // Filter input files to include only supported font files
        let inputFiles = target.inputFiles.filter { hasValidFontExtension($0.url) }.map{ $0.url }
        let inputFilesArguments = inputFiles.map{ $0.path() }

        // If no valid font files are found, throw an error and show a diagnostic message
        if inputFiles.isEmpty {
            Diagnostics.error("The target \(targetName) does not contain any custom fonts in a supported format. Supported formats are: .ttf, .otf.")
            throw PluginError.missingInputFiles
        }

        // Define the output directory where the generated file will be stored
        let outputDirectory = context.pluginWorkDirectoryURL
                                     .appending(path: target.displayName)
                                     .appending(path: "Generated")

        // Create the output directory if it doesn't already exist
        try FileManager.default.createDirectory(
            at: outputDirectory,
            withIntermediateDirectories: true
        )

        // Define the output file path for the generated Swift file
        let outputFile = outputDirectory.appending(path: "\(targetName)GeneratedFonts.swift")

        // Construct the build command to execute the tool
        return [
            .buildCommand(
                displayName: "Generating Font Definitions For \(targetName)", // Display message during the build process
                executable: fontGenerator.url, // Path to the executable tool
                arguments: [
                    outputFile.path(), // Specify the output file path as the first argument
                ] + inputFilesArguments , // Append the font file paths as additional arguments
                environment: [:], // Specify any environment variables if required
                inputFiles: inputFiles, // Declare input files for dependency tracking
                outputFiles: [outputFile] // Declare the generated file as an output in order to be included when app is archived
            )
        ]
    }
}

#endif

Plugins do not have permission to create the output directory themselves, so you must ensure the output directory exists beforehand, typically within the plugin’s implementation. When constructing the build command, you need to specify the path to the executable (a command-line tool defined as part of the package or elsewhere), which performs the actual operations such as file generation or transformation. The plugin’s primary responsibility is to construct the command that invokes this executable, providing any necessary arguments, environment variables, or file paths, while the executable handles the core processing logic.

Step 4: Create the Executable Tool

  1. Add a new target for the executable and specify it as a dependency for the plugin in Package.swift:
    .executableTarget(name: "FontEnumGeneratorExc"),
    .plugin(name: "FontEnumGenerator", capability: .buildTool(), dependencies: ["FontEnumGeneratorExc"])

Package.swift file after adding the executable target

Package.swift file after adding the executable target

  1. Implement the executable in Sources/FontEnumGeneratorExc/FontEnumGeneratorExc.swift:
FontEnum/
├── Package.swift
├── Sources/
│   ├── FontEnum/
│   │
│   └── FontEnumGeneratorExc/
│       └── FontEnumGeneratorExc.swift
└── Plugins/
    └── FontEnumGenerator/
        └── FontEnumGenerator.swift
import Foundation

// Main structure representing the executable responsible for generating the font enum
@main
struct FontEnumGeneratorExc {

    // Entry point of the executable
    static func main() throws {

        // Ensure the command line arguments are valid
        // The minimum required arguments: executable name, output path, and at least one input file
        guard CommandLine.arguments.count > 3 else {
            throw Error.invalidArguments
        }

        let arguments = CommandLine.arguments
        let outputPath = arguments[1] // Second argument specifies the output file path

        let inputFiles = Array(arguments[2...]) // Remaining arguments are the input font files

        let enumName = "AppFont" // Name of the generated Swift enum

        // Generate enum cases for each input file
        let cases = try inputFiles.compactMap { path -> String? in
            let url: URL?

            // Use modern file path API if available, fallback to older API for compatibility
            if #available(iOS 16.0, macOS 13.0, *) {
                url = URL(filePath: path)
            } else {
                url = URL(fileURLWithPath: path)
            }

            // Extract the file name (without extension) from the URL
            guard let fileName = url?.deletingPathExtension().lastPathComponent else {
                throw Error.invalidArguments
            }

            // Generate a valid case name by removing invalid characters and formatting
            let caseName = fileName
                    .replacingOccurrences(of: "-", with: "")
                    .replacingOccurrences(of: " ", with: "")
                    .lowercasingFirstLetter()

            // Create the enum case declaration
                return "    case \(caseName) = \"\(fileName)\""
            }.joined(separator: "\n")

        // Define the Swift content for the enum                    
        let swiftContent = """
            enum \(enumName): String {
            \(cases)
            }
            """

        // Write the generated enum to the specified output file
        try swiftContent.write(
            to: URL(fileURLWithPath: outputPath), // Output path for the file
            atomically: true, // Write atomically to avoid partial writes
            encoding: .utf8 // Use UTF-8 encoding for the Swift file
        )

    }
}

// Enum to define errors for invalid command line arguments
enum Error: Swift.Error {
    case invalidArguments // Raised when the arguments are insufficient or invalid
}

// String extension to lowercase the first letter of a string
extension String {
    func lowercasingFirstLetter() -> String {
        return prefix(1).lowercased() + dropFirst() // Lowercase the first character
    }
}

Step 5: Add Fonts to Your Project

  1. Create a Resources/Fonts directory in your project.
  2. Add your .ttf or .otf font files to this directory.

Step 6: Integrate the Plugin

In Your Xcode Project

  1. Add the Swift package as a dependency to your Xcode project.
  2. Link the plugin to your target in Xcode: — Select your target. — Go to Build Phases > Run Build Tool Plug-Ins. — Add the FontEnumGenerator plugin.

Adding plugin to Xcode project

Adding plugin to Xcode project

Within the Same Package

If you are using the plugin within the same Swift package where it was defined, update the targets section of your Package.swift file to link the plugin to the target that requires it. For example:

.target(
    name: "FontEnum",
    plugins: [.plugin(name: "FontEnumGenerator")]
 ),

In Another Swift Package

  1. Add the FontEnum package as a dependency in the dependencies section of the Package.swift file for the other package.
  2. Specify the plugin in the targets section of the Package.swift file for the target where you want to use it.
// Step 1
dependencies: [
    .package(url: "https://github.com/your-repo/FontEnum", from: "1.0.0")
]
// Step 2
.target(
    name: "YourTarget",
    dependencies: [],
    plugins: [
        .plugin(name: "FontEnumGenerator", package: "FontEnum")
    ]
)

Step 7: Build and Verify

  1. Build your project.
  2. Use the generated AppFonts enum in your code:
let customFont = UIFont(name: AppFonts.OpenSans, size: 16)

The complete FontEnum example project is available on GitHub. Feel free to explore it for a detailed understanding and hands-on experience with the implementation.

Conclusion

With this build tool plugin, specifically the in-build plugin, you’ve automated the process of managing font names in your project. By leveraging the capabilities of a build tool plugin, it integrates seamlessly into the build process, reducing manual effort, minimising errors, and ensuring your code stays in sync with the fonts added to your project. This approach not only improves efficiency but also highlights the power and flexibility of Swift package plugins in simplifying development workflows.🚀

Reference Articles


메타데이터
post_id
2228e2ff4ae5
slug
swift-package-plugins-part-2-build-tool-plugins-2228e2ff4ae5
url
https://medium.com/@rohanbimalraj/swift-package-plugins-part-2-build-tool-plugins-2228e2ff4ae5
canonical_url
https://medium.com/@rohanbimalraj/swift-package-plugins-part-2-build-tool-plugins-2228e2ff4ae5
author_url
https://medium.com/@rohanbimalraj
status
ok
fetched_at
2026-09-02 06:28:33