My in build plugin runs from scratch every time a new file is created in Target's source directory even if it doesn't affect the plugin's input list.
About the plugin: Using swift package init --type build-tool-plugin
as a starting point I made a build plugin and a CLI that imports it. The build plugin creates a Command
for every text file in the Source folder. The tool in the command just creates a .swift
file of the same name. Super minimum viable build plugin.
- Run down of steps I took to make the plugin
- plugin.swift, main.swift included below
- links to plugin and cli repos also below
The trick is back in the CLI that imports the plugin (swift-tools-version: 5.9, vanilla swift run
):
- If I add a .swift file to the Source folder the tool still reruns for each and every text file even though .swift files shouldn't generate new commands.
- If I just edit one of the text files the tool only runs for that text file as expected.
- If I add a file OUTSIDE of the Source folder like a README.md the tools do not rerun.
- If I ad a README.md in an EXCLUDED folder inside the source directory, the tools rerun.
My understanding was that in-build tools should only run if their inputs have changed or the outputs are missing. My inputs have not changed. Presumably the outputs haven't been zotted either?
I can understand rechecking when the Source directory changes, I'd rather rerun by mistake than not run. That said if it was A LOT of files / expensive processes it'd be annoying.
If I wanted to see if I could change the behavior what should I do next?
- A: Nothing - This is the expected behavior / known issue and unavoidable / changed in 5.10, etc.
Carry on.
- B: Keep trying to twiddle the code -
- For example: Presumably the number of
.none
s in the returned[Command]
changes, but I tried filtering on text files infilesFromDirectory
and that didn't seem to have an effect. Also presumably.none
s get compactMapped out? I can try again if that's the ticket.
- For example: Presumably the number of
- C: Look into if any of the options/flags on
swift build
orswift run
could impact this. - D: File an issue.
- E: Something else.
Thanks so much for this incredibly powerful system. Cheers.
Side Note: In an Xcode project (Just the default multi-platform template, Xcode 15.2, basic built for MacOS and iOS 15 in the simulator) the build plugin just seems to run every time, no matter what but I care less about Xcode projects over churning.
Test Projects
import PackagePlugin import Foundation @main struct FruitStoreBuild: BuildToolPlugin { /// Entry point for creating build commands for targets in Swift packages. func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { // Find the code generator tool to run (This is what we named our actual one.). let generatorTool = try context.tool(named: "my-code-generator") // Still ensures that the target is a source module. guard let target = target as? SourceModuleTarget else { return [] } let filesToProcess = try filesFromDirectory(path: target.directory, shallow: false) // Construct a build command for each source file with a particular suffix. return filesToProcess.compactMap { createBuildCommand(for: $0, in: context.pluginWorkDirectory, with: generatorTool.path) } } } #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension FruitStoreBuild: XcodeBuildToolPlugin { // Entry point for creating build commands for targets in Xcode projects. func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { // Find the code generator tool to run (replace this with the actual one). let generatorTool = try context.tool(named: "my-code-generator") // Construct a build command for each source file with a particular suffix. return target.inputFiles.map(\.path).compactMap { createBuildCommand(for: $0, in: context.pluginWorkDirectory, with: generatorTool.path) } } } #endif extension FruitStoreBuild { /// Shared function that returns a configured build command if the input files is one that should be processed. func createBuildCommand(for inputPath: Path, in outputDirectoryPath: Path, with generatorToolPath: Path) -> Command? { // Skip any file that doesn't have the extension we're looking for (replace this with the actual one). guard inputPath.extension == "txt" else { return .none } print("PROOF OF PLUGIN LIFE from createBuildCommand") // Return a command that will run during the build to generate the output file. let inputName = inputPath.lastComponent let outputName = inputPath.stem + ".swift" let outputPath = outputDirectoryPath.appending(outputName) return .buildCommand( displayName: "------------ Generating \(outputName) from \(inputName) ------------", executable: generatorToolPath, arguments: ["\(inputPath)", "-o", "\(outputPath)"], inputFiles: [inputPath], outputFiles: [outputPath] ) } } func filesFromDirectory(path providedPath:Path, shallow:Bool = true) throws -> [Path] { if shallow { return try FileManager.default.contentsOfDirectory(atPath: providedPath.string).compactMap { fileName in providedPath.appending([fileName]) } } else { let dataDirectoryURL = URL(fileURLWithPath: providedPath.string, isDirectory: true) var allFiles = [Path?]() let enumerator = FileManager.default.enumerator(at: dataDirectoryURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) while let fileURL = enumerator?.nextObject() as? URL { if let regularFileCheck = try fileURL.resourceValues(forKeys:[.isRegularFileKey]).isRegularFile, regularFileCheck == true { allFiles.append((Path(fileURL.path()))) } } return allFiles.compactMap({$0}) } }
import Foundation let arguments = ProcessInfo().arguments if arguments.count < 4 { print("missing arguments") } // print("ARGUMENTS") // arguments.forEach { // print($0) // } let (input, output) = (arguments[1], arguments[3]) //Added for ease of scanning for our output. print("FIIIIIIIIIIIIIINNNNNNNNNDDDMMMEEEEEEEEEEEEEEEEE") print("from MyBuildPluginTool:", input) print("from MyBuildPluginTool:", output) var outputURL = URL(fileURLWithPath: output) let contentsOfFile = "//nothing of importance" try contentsOfFile.write(to: outputURL, atomically: true, encoding: .utf8)