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)