├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dir-locals.el ├── .editorconfig ├── .github └── workflows │ └── build-and-test.yml ├── .gitignore ├── DemoScripts ├── Dummy.c ├── Echo1Into2 ├── Echo1Into2.cmd └── Echo1Into2.swift ├── LICENSE ├── Package.swift ├── Plugins ├── CmdPlugin │ ├── CommandDemoPlugin.swift │ └── SPMBuildToolSupport.swift ├── CmdTgtPlugin │ ├── LocalTargetCommandDemoPlugin.swift │ └── SPMBuildToolSupport.swift ├── ExecutablePlugin │ ├── ExecutableFileDemoPlugin.swift │ └── SPMBuildToolSupport.swift ├── SwiftScriptPlugin │ ├── SPMBuildToolSupport.swift │ └── SwiftScriptDemoPlugin.swift └── SwiftToolchainCmdPlugin │ ├── SPMBuildToolSupport.swift │ └── SwiftToolchainCommandDemoPlugin.swift ├── README.md ├── SPMBuildToolSupport.swift ├── Sources ├── AppWithResource │ └── main.swift ├── GenRsrc │ └── main.swift ├── LibWithRsrcFromLocalTgt │ ├── BuildToolPluginInputs │ │ ├── Test1.in │ │ └── Test2.in │ └── LibWithResourceGeneratedByLocalTarget.swift ├── LibWithRsrcFromToolCmd │ └── LibWithResourceGeneratedBySwiftToolchainCommand.swift ├── LibWithSrcFromCmd │ └── LibWithSourceGeneratedByCommand.swift ├── LibWithSrcFromExecutable │ └── LibWithSourceGeneratedByExecutableFile.swift └── LibWithSrcFromSwiftScript │ └── LibWithSourceGeneratedBySwiftScript.swift └── Tests └── SPMBuildToolSupportTests └── SPMBuildToolSupportTests.swift /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG SWIFT_VERSION 2 | 3 | FROM swift:${SWIFT_VERSION} 4 | 5 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Swift", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "args": { 6 | "SWIFT_VERSION" : "6.1" 7 | } 8 | }, 9 | "features": { 10 | "ghcr.io/devcontainers/features/common-utils:2": { 11 | "installZsh": "false", 12 | "username": "vscode", 13 | "userUid": "1001", 14 | "userGid": "1001", 15 | "upgradePackages": "false" 16 | }, 17 | "ghcr.io/devcontainers/features/git:1": { 18 | "version": "os-provided", 19 | "ppa": "false" 20 | } 21 | }, 22 | "runArgs": [ 23 | "--cap-add=SYS_PTRACE", 24 | "--security-opt", 25 | "seccomp=unconfined" 26 | ], 27 | // Configure tool-specific properties. 28 | "customizations": { 29 | // Configure properties specific to VS Code. 30 | "vscode": { 31 | // Set *default* container specific settings.json values on container create. 32 | "settings": { 33 | "lldb.library": "/usr/lib/liblldb.so" 34 | }, 35 | // Add the IDs of extensions you want installed when the container is created. 36 | "extensions": [ 37 | "sswg.swift-lang" 38 | ] 39 | } 40 | }, 41 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 42 | // "forwardPorts": [], 43 | 44 | // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 45 | "remoteUser": "vscode" 46 | } 47 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ;;; Directory Local Variables -*- no-byte-compile: t -*- 2 | ;;; For more information see (info "(emacs) Directory Variables") 3 | 4 | ((nil . ((fill-column . 100)))) 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # A newline ending every file 7 | [*] 8 | # end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build and test 3 | 4 | "on": 5 | push: 6 | branches: [main, rewrite] 7 | paths-ignore: 8 | - "Docs/**" 9 | - "**.md" 10 | - "README.md" 11 | - "LICENSE" 12 | - ".gitignore" 13 | pull_request: 14 | branches: ["**"] 15 | paths-ignore: 16 | - "Docs/**" 17 | - "**.md" 18 | - "README.md" 19 | - "LICENSE" 20 | - ".gitignore" 21 | 22 | env: 23 | swift-version: '6.1' 24 | 25 | jobs: 26 | devcontainer: 27 | name: "Devcontainer: ${{ matrix.os }}/${{ matrix.configuration }}" 28 | strategy: 29 | matrix: 30 | os: [ubuntu-latest] 31 | configuration: [debug] 32 | 33 | runs-on: ${{ matrix.os }} 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Build and Test 38 | uses: devcontainers/ci@v0.3 39 | with: 40 | runCmd: swift test -c ${{ matrix.configuration }} 41 | 42 | native: 43 | name: "Native: ${{ matrix.os }}/${{ matrix.configuration }}" 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | os: [macos-latest, ubuntu-latest, windows-latest] 48 | 49 | configuration: [debug] 50 | 51 | include: 52 | # Default values to add 53 | - shell: 'bash -eo pipefail {0}' 54 | - build-options: '--explicit-target-dependency-import-check=error' 55 | 56 | # Overrides for the defaults 57 | - shell: pwsh 58 | os: windows-latest 59 | 60 | runs-on: ${{ matrix.os }} 61 | 62 | defaults: 63 | run: 64 | shell: ${{ matrix.shell }} 65 | 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | 70 | - name: Set up swift 71 | uses: SwiftyLab/setup-swift@latest 72 | with: 73 | swift-version: ${{ env.swift-version }} 74 | 75 | - name: Build and Test (${{ matrix.configuration }}) 76 | run: > 77 | swift test -c ${{ matrix.configuration }} ${{ matrix.build-options }} 78 | --explicit-target-dependency-import-check=error 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac 2 | 3 | .DS_Store 4 | 5 | # VSCode 6 | .vscode/ 7 | 8 | # Xcode 9 | # 10 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 11 | 12 | ## User settings 13 | xcuserdata/ 14 | 15 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 16 | *.xcscmblueprint 17 | *.xccheckout 18 | 19 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 20 | build/ 21 | DerivedData/ 22 | *.moved-aside 23 | *.pbxuser 24 | !default.pbxuser 25 | *.mode1v3 26 | !default.mode1v3 27 | *.mode2v3 28 | !default.mode2v3 29 | *.perspectivev3 30 | !default.perspectivev3 31 | 32 | ## Obj-C/Swift specific 33 | *.hmap 34 | 35 | ## App packaging 36 | *.ipa 37 | *.dSYM.zip 38 | *.dSYM 39 | 40 | ## Playgrounds 41 | timeline.xctimeline 42 | playground.xcworkspace 43 | 44 | # Swift Package Manager 45 | # 46 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 47 | # Packages/ 48 | # Package.pins 49 | # Package.resolved 50 | # *.xcodeproj 51 | # 52 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 53 | # hence it is not needed unless you have added a package configuration file to your project 54 | # .swiftpm 55 | 56 | .build/ 57 | .swiftpm/ 58 | 59 | # CocoaPods 60 | # 61 | # We recommend against adding the Pods directory to your .gitignore. However 62 | # you should judge for yourself, the pros and cons are mentioned at: 63 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 64 | # 65 | # Pods/ 66 | # 67 | # Add this line if you want to avoid checking in source code from the Xcode workspace 68 | # *.xcworkspace 69 | 70 | # Carthage 71 | # 72 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 73 | # Carthage/Checkouts 74 | 75 | Carthage/Build/ 76 | 77 | # Accio dependency management 78 | Dependencies/ 79 | .accio/ 80 | 81 | # fastlane 82 | # 83 | # It is recommended to not store the screenshots in the git repo. 84 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 85 | # For more information about the recommended setup visit: 86 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 87 | 88 | fastlane/report.xml 89 | fastlane/Preview.html 90 | fastlane/screenshots/**/*.png 91 | fastlane/test_output 92 | 93 | # Code Injection 94 | # 95 | # After new code Injection tools there's a generated folder /iOSInjectionProject 96 | # https://github.com/johnno1962/injectionforxcode 97 | 98 | iOSInjectionProject/ 99 | -------------------------------------------------------------------------------- /DemoScripts/Dummy.c: -------------------------------------------------------------------------------- 1 | // This is not really used as a script; it's just a dummy input for a run of the clang AST dumper 2 | int main() { return 0; } 3 | -------------------------------------------------------------------------------- /DemoScripts/Echo1Into2: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | echo "$1" > "$2" 3 | -------------------------------------------------------------------------------- /DemoScripts/Echo1Into2.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo %~1 >"%2" 3 | -------------------------------------------------------------------------------- /DemoScripts/Echo1Into2.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | print("Echoing \(String(reflecting: CommandLine.arguments[1])) into \(String(reflecting: CommandLine.arguments[2]))") 3 | try CommandLine.arguments[1].write( 4 | to: URL(fileURLWithPath: CommandLine.arguments[2]), atomically: true, encoding: .utf8) 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Boost Software License - Version 1.0 - August 17th, 2003 2 | 3 | Permission is hereby granted, free of charge, to any person or organization 4 | obtaining a copy of the software and accompanying documentation covered by 5 | this license (the "Software") to use, reproduce, display, distribute, 6 | execute, and transmit the Software, and to prepare derivative works of the 7 | Software, and to permit third-parties to whom the Software is furnished to 8 | do so, all subject to the following: 9 | 10 | The copyright notices in the Software and this entire statement, including 11 | the above license grant, this restriction and the following disclaimer, 12 | must be included in all copies of the Software, in whole or in part, and 13 | all derivative works of the Software, unless such copies or derivative 14 | works are solely in the form of machine-executable object code generated by 15 | a source language processor. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT 20 | SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE 21 | FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, 22 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SPMBuildToolSupport", 7 | products: [], 8 | 9 | targets: [ 10 | // ----------------- Demonstrates a plugin running an executable target -------------- 11 | 12 | // This plugin causes an invocation of the executable GenRsrc target below. 13 | .plugin( 14 | name: "CmdTgtPlugin", capability: .buildTool(), 15 | dependencies: ["GenRsrc"] 16 | ), 17 | 18 | // The executable target run by the above plugin 19 | .executableTarget(name: "GenRsrc"), 20 | 21 | // The target into whose resource bundle which the result is copied 22 | .target( 23 | name: "LibWithRsrcFromLocalTgt", 24 | // If we don't exclude these, we can use 25 | // (target as! SourceModuleTarget).sourceFiles(withSuffix: ".in") 26 | // to find them, but we will get (incorrect) warnings from SPM about unhandled sources. 27 | // See CmdTgtPlugin.swift for how to deal with them instead. 28 | exclude: ["BuildToolPluginInputs"], 29 | plugins: ["CmdTgtPlugin"] 30 | ), 31 | 32 | // An app that uses the resources in the above library 33 | .executableTarget( 34 | name: "AppWithResource", dependencies: ["LibWithRsrcFromLocalTgt"]), 35 | 36 | // ------ Demonstrates a plugin running an executable file with a known path ------ 37 | 38 | // This plugin invokes one of the scripts in the DemoScripts/ directory. 39 | .plugin( 40 | name: "ExecutablePlugin", capability: .buildTool() 41 | ), 42 | 43 | // The target into which the resulting source files are incorporated. 44 | .target( 45 | name: "LibWithSrcFromExecutable", 46 | plugins: ["ExecutablePlugin"] 47 | ), 48 | 49 | // ------ Demonstrates a plugin running a command by name as if in a shell ------ 50 | 51 | // This plugin invokes one of the scripts in the DemoScripts/ directory. 52 | .plugin( 53 | name: "CmdPlugin", capability: .buildTool() 54 | ), 55 | 56 | // The target into which the resulting source files are incorporated. 57 | .target( 58 | name: "LibWithSrcFromCmd", 59 | plugins: ["CmdPlugin"] 60 | ), 61 | 62 | // ------ Demonstrates a plugin running a single-file Swift script ------ 63 | 64 | // This plugin invokes one of the scripts in the DemoScripts/ directory. 65 | .plugin( 66 | name: "SwiftScriptPlugin", capability: .buildTool()), 67 | 68 | // The target into which the resulting source files are incorporated. 69 | .target( 70 | name: "LibWithSrcFromSwiftScript", 71 | plugins: ["SwiftScriptPlugin"] 72 | ), 73 | 74 | // ----------------- Demonstrates a plugin running a tool from the Swift toolchain -------------- 75 | 76 | // This plugin causes an invocation of the `swiftc` tool 77 | .plugin( 78 | name: "SwiftToolchainCmdPlugin", capability: .buildTool() 79 | ), 80 | 81 | // The target into whose resource bundle which the result is copied 82 | .target( 83 | name: "LibWithRsrcFromToolCmd", 84 | plugins: ["SwiftToolchainCmdPlugin"] 85 | ), 86 | 87 | // ----------------- Tests that prove this all works. -------------- 88 | 89 | .testTarget( 90 | name: "SPMBuildToolSupportTests", 91 | dependencies: [ 92 | "LibWithRsrcFromLocalTgt", 93 | "LibWithRsrcFromToolCmd", 94 | "LibWithSrcFromCmd", 95 | "LibWithSrcFromExecutable", 96 | "LibWithSrcFromSwiftScript", 97 | ] 98 | ), 99 | 100 | ] 101 | ) 102 | -------------------------------------------------------------------------------- /Plugins/CmdPlugin/CommandDemoPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackagePlugin 3 | 4 | /// A plugin that generates Swift source by running a command by name as if in a shell. 5 | @main 6 | struct CmdPlugin: SPMBuildToolPlugin { 7 | 8 | func buildCommands( 9 | context: PackagePlugin.PluginContext, target: PackagePlugin.Target 10 | ) throws -> [SPMBuildCommand] { 11 | 12 | let outputFile = context.pluginWorkDirectoryURL/"CommandOutput.swift" 13 | 14 | let shell = osIsWindows ? "cmd" : "sh" 15 | let arguments = (osIsWindows ? [ "/Q", "/C"] : ["-c"]) 16 | + [ "echo let commandOutput = 1 >\(outputFile.platformString)" ] 17 | 18 | return [ 19 | .buildCommand( 20 | displayName: "Running \([shell] + arguments)", 21 | executable: .command(shell), 22 | // Note the use of `.platformString` on these paths rather 23 | // than `.string`. Your executable tool may have trouble 24 | // finding files and directories with `.string`. 25 | arguments: arguments, 26 | inputFiles: [], 27 | outputFiles: [outputFile]) 28 | ] 29 | } 30 | 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Plugins/CmdPlugin/SPMBuildToolSupport.swift: -------------------------------------------------------------------------------- 1 | ../../SPMBuildToolSupport.swift -------------------------------------------------------------------------------- /Plugins/CmdTgtPlugin/LocalTargetCommandDemoPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackagePlugin 3 | 4 | @main 5 | struct CmdTgtPlugin: SPMBuildToolPlugin { 6 | 7 | func buildCommands( 8 | context: PackagePlugin.PluginContext, target: PackagePlugin.Target 9 | ) throws -> [SPMBuildCommand] { 10 | 11 | // Treating the inputs as sources causes SPM to (incorrectly) warn that they are unhandled. 12 | // let inputs = (target as! SourceModuleTarget) 13 | // .sourceFiles(withSuffix: ".in").map(\.path) 14 | let inputDirectory = target.directoryURL / "BuildToolPluginInputs" 15 | 16 | let inputs = try FileManager.default 17 | .subpathsOfDirectory(atPath: inputDirectory.platformString) 18 | .map { inputDirectory/$0 } 19 | 20 | let workDirectory = context.pluginWorkDirectoryURL 21 | let outputDirectory = workDirectory / "GeneratedResources" 22 | 23 | let outputs = inputs.map { 24 | outputDirectory / ($0.lastPathComponent.dropLast(2) + "out") 25 | } 26 | 27 | return [ 28 | .buildCommand( 29 | displayName: "Running GenRsrc", 30 | executable: .targetInThisPackage("GenRsrc"), 31 | // Note the use of `.platformString` on these paths rather 32 | // than `.string`. Your executable tool may have trouble 33 | // finding files and directories with `.string`. 34 | arguments: (inputs + [ outputDirectory ]).map(\URL.platformString), 35 | inputFiles: inputs, 36 | outputFiles: outputs) 37 | ] 38 | } 39 | 40 | } 41 | 42 | -------------------------------------------------------------------------------- /Plugins/CmdTgtPlugin/SPMBuildToolSupport.swift: -------------------------------------------------------------------------------- 1 | ../../SPMBuildToolSupport.swift -------------------------------------------------------------------------------- /Plugins/ExecutablePlugin/ExecutableFileDemoPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackagePlugin 3 | 4 | /// A plugin that generates Swift source by running an executable file with a known path in the 5 | /// filesystem. 6 | @main 7 | struct ExecutablePlugin: SPMBuildToolPlugin { 8 | 9 | func buildCommands( 10 | context: PackagePlugin.PluginContext, target: PackagePlugin.Target 11 | ) throws -> [SPMBuildCommand] { 12 | 13 | let outputFile = context.pluginWorkDirectoryURL/"ExecutableOutput.swift" 14 | 15 | return [ 16 | .buildCommand( 17 | displayName: "Running Echo1Into2", 18 | executable: .file(context.package.directoryURL/"DemoScripts"/(osIsWindows ? "Echo1Into2.cmd" : "Echo1Into2")), 19 | // Note the use of `.platformString` on these paths rather 20 | // than `.string`. Your executable tool may have trouble 21 | // finding files and directories with `.string`. 22 | arguments: [ "let executableOutput = 1", outputFile.platformString ], 23 | inputFiles: [], 24 | outputFiles: [outputFile]) 25 | ] 26 | } 27 | 28 | } 29 | 30 | -------------------------------------------------------------------------------- /Plugins/ExecutablePlugin/SPMBuildToolSupport.swift: -------------------------------------------------------------------------------- 1 | ../../SPMBuildToolSupport.swift -------------------------------------------------------------------------------- /Plugins/SwiftScriptPlugin/SPMBuildToolSupport.swift: -------------------------------------------------------------------------------- 1 | ../../SPMBuildToolSupport.swift -------------------------------------------------------------------------------- /Plugins/SwiftScriptPlugin/SwiftScriptDemoPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackagePlugin 3 | 4 | /// A plugin that generates Swift source by running an executable file with a known path in the 5 | /// filesystem. 6 | @main 7 | struct SwiftScriptPlugin: SPMBuildToolPlugin { 8 | 9 | func buildCommands( 10 | context: PackagePlugin.PluginContext, target: PackagePlugin.Target 11 | ) throws -> [SPMBuildCommand] { 12 | 13 | let outputFile = context.pluginWorkDirectoryURL/"SwiftScriptOutput.swift" 14 | 15 | return [ 16 | .buildCommand( 17 | displayName: "Running Echo1Into2.swift", 18 | executable: .swiftScript(context.package.directoryURL/"DemoScripts"/"Echo1Into2.swift"), 19 | arguments: [ "let swiftScriptOutput = 1", outputFile.platformString ], 20 | inputFiles: [], 21 | outputFiles: [outputFile]) 22 | ] 23 | } 24 | 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Plugins/SwiftToolchainCmdPlugin/SPMBuildToolSupport.swift: -------------------------------------------------------------------------------- 1 | ../../SPMBuildToolSupport.swift -------------------------------------------------------------------------------- /Plugins/SwiftToolchainCmdPlugin/SwiftToolchainCommandDemoPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackagePlugin 3 | 4 | @main 5 | struct CmdTgtPlugin: SPMBuildToolPlugin { 6 | 7 | func buildCommands( 8 | context: PackagePlugin.PluginContext, target: PackagePlugin.Target 9 | ) throws -> [SPMBuildCommand] { 10 | 11 | let rawCCode = context.package.directoryURL/"DemoScripts"/"Dummy.c" 12 | let preprocessedOutput = context.pluginWorkDirectoryURL/"Dummy.pp" 13 | 14 | return [ 15 | .buildCommand( 16 | displayName: "Generating preprocessed C as resource", 17 | executable: .swiftToolchainCommand("clang"), 18 | arguments: ["-E", rawCCode.platformString, "-o", preprocessedOutput.platformString], 19 | inputFiles: [rawCCode], 20 | outputFiles: [preprocessedOutput]) 21 | ] 22 | } 23 | 24 | } 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SPMBuildToolSupport 2 | 3 | This code allows your build tool plugins to easily and portably run executable targets, executable 4 | files by their path, commands that can be invoked from a shell, or Swift script files. 5 | 6 | This package provides (and demonstrates) workarounds for Swift Package Manager bugs and limitations. 7 | 8 | ## What bugs and limitations? 9 | 10 | This is just a partial list: 11 | 12 | - Bugs: 13 | - Plugin outputs are not automatically rebuilt when a plugin's executable changes ([SPM issue 14 | #6936](https://github.com/apple/swift-package-manager/issues/6936)) 15 | - Broken file system path handling on Windows ([SPM issue 16 | #6994](https://github.com/apple/swift-package-manager/issues/6994)) 17 | - If you use a plugin to generate tests or source for an executable, on Windows, SPM will try to 18 | link the plugin itself into the executable, resulting in “duplicate main” link errors ([SPM issue 19 | #6859](https://github.com/apple/swift-package-manager/issues/6859#issuecomment-1720371716)). 20 | - `swift SomeFile.swift` doesn't work on Windows. 21 | 22 | - Limitations: 23 | 24 | - No easy way to reentrantly invoke SPM from within a build tool plugin, a key to working around 25 | many of the other bugs and limitations described here. 26 | - No easy way to find the source files on which an executable product depends. 27 | - SPM's `Path` type doesn't interoperate well with Foundation's `URL`. 28 | - The released version of the API docs for build tool plugins is inaccurate and confusing (fixes 29 | [here](https://github.com/apple/swift-package-manager/pull/6941/files)). 30 | 31 | **Note:** Plugin outputs are not automatically rebuilt when a plugin's source changes ([SPM issue 32 | #6936](https://github.com/apple/swift-package-manager/issues/6936)). We don't have a workaround 33 | for this problem. 34 | 35 | ## How do I use this package? 36 | 37 | 1. SPM build tool plugins [cannot have any dependencies on 38 | libraries](https://forums.swift.org/t/difficulty-sharing-code-between-swift-package-manager-plugins/61690/10), 39 | so you must arrange for your plugin's source to include 40 | [`SPMBuildToolSupport.swift`](SPMBuildToolSupport.swift). One way to do that if you want to stay 41 | up-to-date with improvements here, and especially if your project contains multiple plugins, is 42 | to make this repository a submodule of yours, and symlink the file into each subdirectory of your 43 | `Plugins/` directory (assuming standard SPM layout). 44 | 45 | 2. Make your plugin inherit from `SPMBuildToolPlugin` and implement its `buildCommands` method 46 | (instead of inheriting from `BuildToolPlugin` and implementing `createBuildCommands`). This 47 | project contains [several examples](https://github.com/dabrahams/SPMBuildToolSupport/tree/main/Plugins). 48 | Executables that can run build commands are divided into the following cases: 49 | 50 | - `.targetInThisPackage`: an executable target in the same package as the plugin. 51 | - `.file`: a specific executable file. 52 | - `.command`: an executable found in the environment's executable search path, 53 | given the name you'd use to invoke it in a shell (e.g. "find"). 54 | - `.swiftScript`: the executable produced by building a single specific `.swift` file, almost as 55 | though the file was passed as a parameter to the `swift` command. 56 | - `.swiftToolchainCommand`: an executable from the currently-running Swift toolchain, given the 57 | name you'd use to invoke it in a shell (e.g. "swift", "swiftc", "clang"). 58 | 59 | 60 | 4. To turn a `PackagePlugin.Path` or a `Foundation.URL` into a string that will be recognized by the 61 | host OS (say, to pass on a command line), use its `.platformString` property. **Do not use 62 | `URL`'s other properties (e.g. `.path`) for this purpose, as tempting as it may be**. 63 | 64 | 5. Avoid naïve path manipulations on a `PackagePlugin.Path` directly, which is buggy on some 65 | platforms. Consider using its `url` property and then, if necessary, converting the result back 66 | to a `PackagePlugin.Path`. 67 | 68 | 6. To avoid spurious warnings from SPM about unhandled sources, do not use SPM's 69 | `.sourceFiles(withSuffix: ".in")` to find the input files to your build plugin. Instead, 70 | [exclude them from the 71 | target](https://github.com/dabrahams/SPMBuildToolSupport/blob/48d0253/Package.swift#L45) in 72 | `Package.swift` and in your plugin, locate them relative to other directories in your 73 | project. [`CmdTgtPlugin.swift`](https://github.com/dabrahams/SPMBuildToolSupport/blob/48d0253/Plugins/CmdTgtPlugin/CmdTgtPlugin.swift#L11-L14) 74 | shows an example. 75 | -------------------------------------------------------------------------------- /SPMBuildToolSupport.swift: -------------------------------------------------------------------------------- 1 | import PackagePlugin 2 | import Foundation 3 | 4 | #if os(Windows) 5 | import WinSDK 6 | let osIsWindows = true 7 | #else 8 | let osIsWindows = false 9 | #endif 10 | 11 | #if os(macOS) 12 | let osIsMacOS = true 13 | #else 14 | let osIsMacOS = false 15 | #endif 16 | 17 | var fileManager: FileManager { FileManager.default } 18 | 19 | /// The separator between elements of the executable search path. 20 | private let pathEnvironmentSeparator: Character = osIsWindows ? ";" : ":" 21 | 22 | /// The environment variables of the running process. 23 | /// 24 | /// On platforms where environment variable names are case-insensitive (Windows), the keys have all 25 | /// been normalized to upper case, so looking up a variable value from this dictionary by a name 26 | /// that isn't all-uppercase is a non-portable operation. 27 | // FIXME: use a second map from upcased keys to actual keys. 28 | private let environmentVariables = osIsWindows ? 29 | Dictionary( 30 | uniqueKeysWithValues: ProcessInfo.processInfo.environment.lazy.map { 31 | x in (key: x.key.uppercased(), value: x.value) 32 | }) 33 | : ProcessInfo.processInfo.environment 34 | 35 | 36 | // The directories searched for command-line commands having no directory qualification. 37 | private var executableSearchPath: [URL] { 38 | (environmentVariables["PATH"] ?? "") 39 | .split(separator: pathEnvironmentSeparator) 40 | .map { URL(fileURLWithPath: String($0)) } 41 | } 42 | 43 | public extension URL { 44 | 45 | /// Returns `root` with the additional path component `x` appended. 46 | static func / (_ root: Self, x: String) -> URL { 47 | root.appendingPathComponent(x) 48 | } 49 | 50 | } 51 | 52 | extension PackagePlugin.PluginContext { 53 | 54 | func makeScratchDirectory() throws -> URL { 55 | var d: URL! 56 | var e: Error! 57 | for _ in 0..<10 { 58 | do { 59 | d = pluginWorkDirectoryURL/UUID().uuidString 60 | try fileManager.createDirectory(at: d, withIntermediateDirectories: false) 61 | return d 62 | } 63 | catch let e1 { e = e1 } 64 | } 65 | throw Failure( 66 | description: """ 67 | Couldn't create scratch directory after 10 tries 68 | last attempt at: \(d.absoluteString) 69 | error: \(e!) 70 | """) 71 | } 72 | 73 | /// Returns the binary executable file that would be invoked as `command` from the command line if 74 | /// the executable search path in the environment was `searchPath`. 75 | /// 76 | /// - Throws if no such binary can be found 77 | /// - Note: the current directory is only searched if it appears in `searchPath`; it is not 78 | /// considered first as in Windows shells. 79 | func executable( 80 | invokedAs command: String, searching searchPath: [URL] 81 | ) throws -> URL { 82 | if !osIsWindows { 83 | if let r = searchPath.lazy.map({ $0/(command) }) 84 | .first(where: { fileManager.isExecutableFile(atPath: $0.path) }) 85 | { 86 | return r 87 | } 88 | throw Failure(description: "No executable invoked as \(command) found in: \(searchPath)") 89 | } 90 | 91 | var subshellEnvironment = ProcessInfo.processInfo.environment 92 | subshellEnvironment["Path"] = searchPath.map(\.platformString).joined(separator: ";") 93 | 94 | let whereCommand 95 | = URL(fileURLWithPath: environmentVariables["WINDIR"]!)/"System32"/"where.exe" 96 | 97 | // Use an empty working directory to shield Windows from finding it in the current directory, 98 | // should it happen to contain an appropriately-named executable. 99 | let t = try makeScratchDirectory() 100 | defer { _ = try? fileManager.removeItem(at: t) } // ignore if we fail to remove it. 101 | 102 | let p = try Process.commandOutput( 103 | whereCommand, arguments: [command], 104 | environment: subshellEnvironment, workingDirectory: t) 105 | 106 | return URL(fileURLWithPath: String(p.prefix { !$0.isNewline})) 107 | } 108 | 109 | /// Returns the executable from the current Swift toolchain that could be invoked as `commandName` 110 | /// from a shell. 111 | func swiftToolchainExecutable(invokedAs commandName: String) throws -> URL { 112 | return try tool(named: commandName).url 113 | } 114 | 115 | } 116 | 117 | extension URL { 118 | 119 | /// Returns a copy of self after removing `possibleSuffix` from the tail of its `pathComponents`, 120 | /// or returns `nil` if `possibleSuffix` is not a suffix of `pathComponents`. 121 | fileprivate func sansPathComponentSuffix< 122 | PossibleSuffix: BidirectionalCollection >(_ possibleSuffix: PossibleSuffix) -> URL? 123 | { 124 | var r = self 125 | var remainingSuffix = possibleSuffix[...] 126 | while let x = remainingSuffix.popLast() { 127 | if r.lastPathComponent != x { return nil } 128 | r.deleteLastPathComponent() 129 | } 130 | return r 131 | } 132 | 133 | /// The representation used by the native filesystem. 134 | public var platformString: String { 135 | self.withUnsafeFileSystemRepresentation { String(cString: $0!) } 136 | } 137 | 138 | } 139 | 140 | public extension PackagePlugin.Target { 141 | 142 | /// The source files. 143 | var allSourceFiles: [URL] { 144 | return (self as? PackagePlugin.SourceModuleTarget)? 145 | .sourceFiles(withSuffix: "").map(\.url) ?? [] 146 | } 147 | 148 | } 149 | 150 | public extension PackagePlugin.Package { 151 | 152 | /// Returns all the source files on which any executable target named `targetName` depends. 153 | // This is a very conservative check because we have no way of knowing which package the plugin 154 | // itself was defined in! 155 | func sourceDependencies(ofTargetsNamed targetName: String) -> Set { 156 | var result: Set = [] 157 | if let t0 = targets.first(where: { $0.name == targetName }) { 158 | var visitedTargets: Set = [t0.id] 159 | 160 | result.formUnion(t0.allSourceFiles) 161 | 162 | 163 | for t1 in t0.recursiveTargetDependencies { 164 | if visitedTargets.insert(t1.id).inserted { 165 | result.formUnion(t1.allSourceFiles) 166 | } 167 | } 168 | } 169 | 170 | for d in dependencies { 171 | result.formUnion(d.package.sourceDependencies(ofTargetsNamed: targetName)) 172 | } 173 | return result 174 | } 175 | 176 | } 177 | 178 | // Workarounds for SPM's buggy `Path` type on Windows. 179 | // 180 | // SPM `PackagePlugin.Path` uses a representation that—if not repaired before used by a 181 | // `BuildToolPlugin` on Windows—will cause files not to be found. 182 | public extension Path { 183 | 184 | /// A string representation appropriate to the platform. 185 | var platformString: String { 186 | #if os(Windows) 187 | string.withCString(encodedAs: UTF16.self) { pwszPath in 188 | // Allocate a buffer for the repaired UTF-16. 189 | let bufferSize = Int(GetFullPathNameW(pwszPath, 0, nil, nil)) 190 | var buffer = Array(repeating: 0, count: bufferSize) 191 | // Actually do the repair 192 | _ = GetFullPathNameW(pwszPath, DWORD(bufferSize), &buffer, nil) 193 | // Drop the zero terminator and convert back to a Swift string. 194 | return String(decoding: buffer.dropLast(), as: UTF16.self) 195 | } 196 | #else 197 | return self.url.withUnsafeFileSystemRepresentation { 198 | String(cString: $0!) 199 | } 200 | #endif 201 | } 202 | 203 | /// A `URL` referring to the same location. 204 | var url: URL { URL(fileURLWithPath: platformString) } 205 | 206 | } 207 | public extension URL { 208 | 209 | /// Returns `self` with the relative file path `suffix` appended. 210 | /// 211 | /// This is a portable version of `self.appending(path:)`, which is only available on recent 212 | /// macOSes. 213 | func appendingPath(_ suffix: String) -> URL { 214 | 215 | #if os(macOS) 216 | if #available(macOS 13.0, *) { return self.appending(path: suffix) } 217 | #endif 218 | 219 | return (suffix as NSString).pathComponents 220 | .reduce(into: self) { $0.appendPathComponent($1) } 221 | } 222 | 223 | } 224 | 225 | /// Defines functionality for all plugins having a `buildTool` capability. 226 | public protocol SPMBuildToolPlugin: BuildToolPlugin { 227 | 228 | /// Returns the build commands for `target` in `context`. 229 | func buildCommands( 230 | context: PackagePlugin.PluginContext, 231 | target: PackagePlugin.Target 232 | ) async throws -> [SPMBuildCommand] 233 | 234 | } 235 | 236 | extension SPMBuildToolPlugin { 237 | 238 | public func createBuildCommands(context: PluginContext, target: Target) async throws 239 | -> [PackagePlugin.Command] 240 | { 241 | 242 | return try await buildCommands(context: context, target: target).map { 243 | try $0.spmCommand(in: context) 244 | } 245 | 246 | } 247 | 248 | } 249 | 250 | private extension SPMBuildCommand.Executable { 251 | 252 | /// A partial translation to SPM plugin inputs of an invocation. 253 | struct SPMInvocation { 254 | 255 | /// The executable that will actually run. 256 | let executable: URL 257 | /// The command-line arguments that must precede the ones specified by the caller. 258 | let argumentPrefix: [String] 259 | /// The source files that must be added as build dependencies if we want the tool 260 | /// to be re-run when its sources change. 261 | let additionalSources: [URL] 262 | 263 | /// Creates an instance with the given properties. 264 | init( 265 | executable: URL, 266 | argumentPrefix: [String] = [], 267 | additionalSources: [URL] = [], 268 | additionalCommands: [SPMBuildCommand] = [] 269 | ) 270 | { 271 | self.executable = executable 272 | self.argumentPrefix = argumentPrefix 273 | self.additionalSources = additionalSources 274 | } 275 | 276 | } 277 | 278 | func spmInvocation(in context: PackagePlugin.PluginContext) throws -> SPMInvocation { 279 | switch self { 280 | case .file(let p): 281 | return .init(executable: p, argumentPrefix: []) 282 | 283 | case .targetInThisPackage(let targetName): 284 | return try .init(executable: context.tool(named: targetName).url) 285 | 286 | case .command(let c): 287 | return try .init( 288 | executable: context.executable(invokedAs: c, searching: executableSearchPath)) 289 | 290 | case .swiftScript(let s): 291 | let work = context.pluginWorkDirectoryURL 292 | let scratch = work/UUID().uuidString 293 | 294 | // On Windows, SPM doesn't work unless git is in the Path, and we can find a working bash 295 | // relative to that as part of the git installation. 296 | let bash = try osIsWindows 297 | ? context.executable(invokedAs: "git", searching: executableSearchPath) 298 | .deletingLastPathComponent().deletingLastPathComponent()/"bin"/"bash.exe" 299 | : context.executable(invokedAs: "bash", searching: executableSearchPath) 300 | 301 | let swiftc = osIsMacOS ? "xcrun swiftc" : "swiftc" 302 | 303 | return .init( 304 | executable: bash, 305 | argumentPrefix: [ 306 | "-eo", "pipefail", "-c", 307 | """ 308 | SCRATCH="$1" 309 | SCRIPT="$2" 310 | shift 2 311 | mkdir -p "$SCRATCH"/module-cache 312 | \(swiftc) -module-cache-path "$SCRATCH"/module-cache "$SCRIPT" -o "$SCRATCH"/runner 313 | "$SCRATCH"/runner "$@" 314 | """, 315 | "ignored", // $0 316 | scratch.platformString, 317 | s.platformString, 318 | ], 319 | additionalSources: [s]) 320 | 321 | case .swiftToolchainCommand(let c): 322 | return try .init(executable: context.swiftToolchainExecutable(invokedAs: c)) 323 | } 324 | } 325 | } 326 | 327 | fileprivate extension SPMBuildCommand { 328 | 329 | /// Returns a representation of `self` for the result of a `BuildToolPlugin.createBuildCommands` 330 | /// invocation with the given `context` parameter. 331 | func spmCommand(in context: PackagePlugin.PluginContext) throws -> PackagePlugin.Command { 332 | 333 | switch self { 334 | case .buildCommand( 335 | displayName: let displayName, 336 | executable: let executable, 337 | arguments: let arguments, 338 | environment: let environment, 339 | inputFiles: let inputFiles, 340 | outputFiles: let outputFiles): 341 | 342 | let i = try executable.spmInvocation(in: context) 343 | 344 | // Work around an SPM bug on Windows: the path to PWSH is some kind of zero-byte shortcut, and 345 | // SPM complains that it doesn't exist if we try to depend on it. 346 | let executableDependency = try osIsWindows && fileManager.attributesOfItem( 347 | atPath: i.executable.platformString)[FileAttributeKey.size] as! UInt64 == 0 ? [] 348 | : [ i.executable ] 349 | 350 | return .buildCommand( 351 | displayName: displayName, 352 | executable: i.executable, 353 | arguments: i.argumentPrefix + arguments, 354 | environment: environment, 355 | inputFiles: inputFiles 356 | + i.additionalSources 357 | + executableDependency, 358 | outputFiles: outputFiles) 359 | 360 | case .prebuildCommand( 361 | displayName: let displayName, 362 | executable: let tool, 363 | arguments: let arguments, 364 | environment: let environment, 365 | outputFilesDirectory: let outputFilesDirectory): 366 | 367 | let i = try tool.spmInvocation(in: context) 368 | 369 | return .prebuildCommand( 370 | displayName: displayName, 371 | executable: i.executable, 372 | arguments: i.argumentPrefix + arguments, 373 | environment: environment, 374 | outputFilesDirectory: outputFilesDirectory) 375 | } 376 | } 377 | 378 | } 379 | 380 | 381 | /// A command to run during the build. 382 | public enum SPMBuildCommand { 383 | 384 | /// A command-line tool to be invoked. 385 | public enum Executable { 386 | 387 | /// The executable target in this package, by name 388 | case targetInThisPackage(String) 389 | 390 | /// An executable file not that exists before the build starts. 391 | case file(URL) 392 | 393 | /// An executable found in the environment's executable search path, given the name you'd use to 394 | /// invoke it in a shell (e.g. "find"). 395 | case command(String) 396 | 397 | /// The executable produced by building the given `.swift` file, almost as though the file was 398 | /// passed as a parameter to the `swift` command. 399 | case swiftScript(URL) 400 | 401 | /// An executable from the currently-running Swift toolchain, given the name you'd use to 402 | /// invoke it in a shell (e.g. "swift", "swiftc", "clang"). 403 | /// 404 | /// Works portably as long as you haven't made your plugin depend on a target with the same name 405 | /// as the command. 406 | case swiftToolchainCommand(String) 407 | } 408 | 409 | /// A command that runs when any of its output files are needed by 410 | /// the build, but out-of-date. 411 | /// 412 | /// An output file is out-of-date if it doesn't exist, or if any 413 | /// input files have changed since the command was last run. 414 | /// 415 | /// - Note: the paths in the list of output files may depend on the list of 416 | /// input file paths, but **must not** depend on reading the contents of 417 | /// any input files. Such cases must be handled using a `prebuildCommand`. 418 | /// 419 | /// - Parameters: 420 | /// - displayName: An optional string to show in build logs and other 421 | /// status areas. 422 | /// - executable: The executable invoked to build the output files. 423 | /// - arguments: Command-line arguments to be passed to the executable. 424 | /// - environment: Environment variable assignments visible to the 425 | /// tool. 426 | /// - inputFiles: Files on which the contents of output files may depend. 427 | /// Any paths passed as `arguments` should typically be passed here as 428 | /// well. 429 | /// - outputFiles: Files to be generated or updated by the tool. 430 | /// Any files recognizable by their extension as source files 431 | /// (e.g. `.swift`) are compiled into the target for which this command 432 | /// was generated as if in its source directory; other files are treated 433 | /// as resources as if explicitly listed in `Package.swift` using 434 | /// `.process(...)`. 435 | case buildCommand( 436 | displayName: String?, 437 | executable: Executable, 438 | arguments: [String], 439 | environment: [String: String] = [:], 440 | inputFiles: [URL] = [], 441 | outputFiles: [URL] = []) 442 | 443 | /// A command that runs unconditionally before every build. 444 | /// 445 | /// Prebuild commands can have a significant performance impact 446 | /// and should only be used when there would be no way to know the 447 | /// list of output file paths without first reading the contents 448 | /// of one or more input files. Typically there is no way to 449 | /// determine this list without first running the command, so 450 | /// instead of encoding that list, the caller supplies an 451 | /// `outputFilesDirectory` parameter, and all files in that 452 | /// directory after the command runs are treated as output files. 453 | /// 454 | /// - Parameters: 455 | /// - displayName: An optional string to show in build logs and other 456 | /// status areas. 457 | /// - executable: The executable invoked to build the output files. 458 | /// - arguments: Command-line arguments to be passed to the tool. 459 | /// - environment: Environment variable assignments visible to the tool. 460 | /// - workingDirectory: Optional initial working directory when the tool 461 | /// runs. 462 | /// - outputFilesDirectory: A directory into which the command writes its 463 | /// output files. Any files there recognizable by their extension as 464 | /// source files (e.g. `.swift`) are compiled into the target for which 465 | /// this command was generated as if in its source directory; other 466 | /// files are treated as resources as if explicitly listed in 467 | /// `Package.swift` using `.process(...)`. 468 | case prebuildCommand( 469 | displayName: String?, 470 | executable: Executable, 471 | arguments: [String], 472 | environment: [String: String] = [:], 473 | outputFilesDirectory: URL) 474 | 475 | } 476 | 477 | private extension Process { 478 | 479 | /// The results of a process run that exited with a nonzero code. 480 | struct NonzeroExit: Error { 481 | 482 | /// The nonzero exit code of the process run. 483 | public let terminationStatus: Int32 484 | 485 | /// The contents of the standard output stream. 486 | public let standardOutput: String 487 | 488 | /// The contents of the standard error stream. 489 | public let standardError: String 490 | 491 | /// The command-line that triggered the process run. 492 | public let commandLine: [String] 493 | } 494 | 495 | /// Runs `executable` with the given command line `arguments` and returns the text written to its 496 | /// standard output, throwing `NonzeroExit` if the command fails. 497 | static func commandOutput( 498 | _ executable: URL, arguments: [String] = [], environment: [String: String]? = nil, 499 | workingDirectory: URL? = nil 500 | ) throws -> String { 501 | 502 | let p = Process() 503 | let pipes = (standardOutput: Pipe(), standardError: Pipe()) 504 | p.executableURL = executable 505 | p.arguments = arguments 506 | p.standardOutput = pipes.standardOutput 507 | p.standardError = pipes.standardError 508 | p.environment = environment 509 | p.currentDirectoryURL = workingDirectory 510 | try p.run() 511 | p.waitUntilExit() 512 | 513 | let outputText = ( 514 | standardOutput: pipes.standardOutput.readUTF8(), 515 | standardError: pipes.standardError.readUTF8() 516 | ) 517 | 518 | if p.terminationStatus != 0 { 519 | throw NonzeroExit( 520 | terminationStatus: p.terminationStatus, 521 | standardOutput: outputText.standardOutput, standardError: outputText.standardError, 522 | commandLine: [executable.platformString] + arguments) 523 | } 524 | 525 | return outputText.standardOutput 526 | } 527 | 528 | } 529 | 530 | extension Process.NonzeroExit: CustomStringConvertible { 531 | 532 | var description: String { 533 | return """ 534 | Process.NonzeroExit (status: \(terminationStatus)) 535 | Command line: \(commandLine.map(String.init(reflecting:)).joined(separator: " ")) 536 | 537 | standard output: 538 | ------------- 539 | \(standardOutput) 540 | ------------- 541 | 542 | standard error: 543 | ------------- 544 | \(standardError) 545 | ------------- 546 | """ 547 | } 548 | 549 | } 550 | 551 | extension Pipe { 552 | 553 | /// Returns the contents decoded as UTF-8, while consuming `self`. 554 | func readUTF8() -> String { 555 | String(decoding: fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) 556 | } 557 | 558 | } 559 | 560 | private struct Failure: Error, CustomStringConvertible { 561 | let description: String 562 | } 563 | -------------------------------------------------------------------------------- /Sources/AppWithResource/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LibWithRsrcFromLocalTgt 3 | 4 | print(resourcesGeneratedByLocalTarget.path(forResource: "Test1.out", ofType: nil) ?? "** Not Found **") 5 | -------------------------------------------------------------------------------- /Sources/GenRsrc/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // Log our invocation for diagnostic purposes 4 | print("GenRsrc invocation:", CommandLine.arguments) 5 | 6 | // The ".in" files to be used to generate the resource files. 7 | let inputs = CommandLine.arguments.dropFirst().dropLast().map(URL.init(fileURLWithPath:)) 8 | 9 | let outputDirectory = URL.init(fileURLWithPath: CommandLine.arguments.last!) 10 | 11 | // The generated ".out" files that should be copied into the resource bundle 12 | let outputs = inputs.map { 13 | outputDirectory.appendingPathComponent( 14 | $0.deletingPathExtension().appendingPathExtension("out").lastPathComponent 15 | ) 16 | } 17 | 18 | for (i, o) in zip(inputs, outputs) { 19 | 20 | try (String(contentsOf: i, encoding: .utf8) + "\n# PROCESSED!\n") 21 | .write(to: o, atomically: true, encoding: .utf8) 22 | } 23 | -------------------------------------------------------------------------------- /Sources/LibWithRsrcFromLocalTgt/BuildToolPluginInputs/Test1.in: -------------------------------------------------------------------------------- 1 | # Unprocessed resource file 1 -------------------------------------------------------------------------------- /Sources/LibWithRsrcFromLocalTgt/BuildToolPluginInputs/Test2.in: -------------------------------------------------------------------------------- 1 | # Unprocessed resource file 2 2 | -------------------------------------------------------------------------------- /Sources/LibWithRsrcFromLocalTgt/LibWithResourceGeneratedByLocalTarget.swift: -------------------------------------------------------------------------------- 1 | // A Swift file is needed, if nothing else, to expose the bundle as a public variable. 2 | import class Foundation.Bundle 3 | public let resourcesGeneratedByLocalTarget = Bundle.module 4 | -------------------------------------------------------------------------------- /Sources/LibWithRsrcFromToolCmd/LibWithResourceGeneratedBySwiftToolchainCommand.swift: -------------------------------------------------------------------------------- 1 | // A Swift file is needed, if nothing else, to expose the bundle as a public variable. 2 | import class Foundation.Bundle 3 | public let resourcesGeneratedBySwiftToolchainCommand = Bundle.module 4 | -------------------------------------------------------------------------------- /Sources/LibWithSrcFromCmd/LibWithSourceGeneratedByCommand.swift: -------------------------------------------------------------------------------- 1 | // `commandOutput` is in a generated Swift source file, so if this compiles, it proves the expected 2 | // sources were generated. 3 | 4 | public let dependentOnCommandOutput = commandOutput 5 | -------------------------------------------------------------------------------- /Sources/LibWithSrcFromExecutable/LibWithSourceGeneratedByExecutableFile.swift: -------------------------------------------------------------------------------- 1 | // `executableOutput` is in a generated Swift source file, so if this compiles, it proves the expected sources 2 | // were generated. 3 | 4 | public let dependentOnExecutableOutput = executableOutput 5 | -------------------------------------------------------------------------------- /Sources/LibWithSrcFromSwiftScript/LibWithSourceGeneratedBySwiftScript.swift: -------------------------------------------------------------------------------- 1 | // `swiftScriptOutput` is in a generated Swift source file, so if this compiles, it proves the 2 | // expected sources were generated. 3 | 4 | public let dependentOnSwiftScriptOutput = swiftScriptOutput 5 | -------------------------------------------------------------------------------- /Tests/SPMBuildToolSupportTests/SPMBuildToolSupportTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import LibWithRsrcFromLocalTgt 3 | import LibWithRsrcFromToolCmd 4 | import LibWithSrcFromCmd 5 | import LibWithSrcFromExecutable 6 | import LibWithSrcFromSwiftScript 7 | 8 | import Foundation 9 | 10 | final class SPMBuildToolSupportTests: XCTestCase { 11 | 12 | func testLocalTargetCommand() throws { 13 | guard let test1 = resourcesGeneratedByLocalTarget.url( 14 | forResource: "Test1.out", withExtension: nil) else { 15 | XCTFail("Test1.out not found.") 16 | return 17 | } 18 | let content1 = try String(contentsOf: test1, encoding: .utf8) 19 | XCTAssert(content1.hasSuffix("\n# PROCESSED!\n")) 20 | 21 | guard let test2 = resourcesGeneratedByLocalTarget.url( 22 | forResource: "Test2.out", withExtension: nil) else { 23 | XCTFail("Test2.out not found.") 24 | return 25 | } 26 | let content2 = try String(contentsOf: test2, encoding: .utf8) 27 | XCTAssert(content2.hasSuffix("\n# PROCESSED!\n")) 28 | } 29 | 30 | func testSwiftToolchainCommand() throws { 31 | guard let preprocessedFile = resourcesGeneratedBySwiftToolchainCommand.url( 32 | forResource: "Dummy.pp", withExtension: nil) else { 33 | XCTFail("Preprocessed output file not found.") 34 | return 35 | } 36 | let preprocessedSource = try String(contentsOf: preprocessedFile, encoding: .utf8) 37 | XCTAssert(preprocessedSource.contains("int main() { return 0; }"), "Expected content not found") 38 | } 39 | 40 | func testCmdPlugin() { 41 | XCTAssertEqual(dependentOnCommandOutput, 1) 42 | } 43 | 44 | func testExecutablePlugin() { 45 | XCTAssertEqual(dependentOnExecutableOutput, 1) 46 | } 47 | 48 | func testSwiftScriptPlugin() { 49 | XCTAssertEqual(dependentOnSwiftScriptOutput, 1) 50 | } 51 | } 52 | --------------------------------------------------------------------------------