diff --git a/DESIGN.md b/DESIGN.md index dd0319aa..f3572258 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,21 +4,28 @@ This document contains the high level design of swiftly. Not all features have b ## Index +- [Swiftly's purpose](#swiftlys-purpose) +- [Installation of swiftly](#installation-of-switly) - [Linux](#linux) - - [Installation of swiftly](#installation-of-swiftly) - [Installation of a Swift toolchain](#installation-of-a-swift-toolchain) - [macOS](#macos) - - [Installation of swiftly](#installation-of-swiftly-1) - [Installation of a Swift toolchain](#installation-of-a-swift-toolchain-1) - [Interface](#interface) - [Toolchain names and versions](#toolchain-names-and-versions) - [Commands](#commands) + - [Toolchain selection](#toolchain-selection) - [Detailed design](#detailed-design) - [Implementation sketch - Core](#implementation-sketch---core) - [Implementation sketch - Ubuntu 20.04](#implementation-sketch---ubuntu-2004) - [Implementation sketch - macOS](#implementation-sketch---macos) - [`config.json` schema](#configjson-schema) +## Swiftly's purpose + +Swiftly helps you to easily install different Swift toolchains locally on your account. It also provides a single path where you can run the tools in the currently selected toolchain. Toolchain selection is [configurable](#toolchain-selection) using different mechanisms. + +Note that swiftly is *not* a virtual toolchain in itself since there are cases where it cannot behave as a self-contained Swift toolchain. For example, there can be external dependencies on specific files, such as headers or libraries. There are far too many files that change between toolchain versions to be managed by swiftly. Also, for long-lived processes, there is no way to gracefully restart them without help from the client. + ## Installation of swiftly The installation of swiftly is divided into two phases: delivery and initialization. Delivery of the swiftly binary can be accomplished using different methods: @@ -60,7 +67,7 @@ A simple setup for managing the toolchains could look like this: The toolchains (i.e. the contents of a given Swift download tarball) would be contained in the toolchains directory, each named according to the major/minor/patch version. `config.json` would contain any required metadata (e.g. the latest Swift version, which toolchain is selected, etc.). If pulling in Foundation to use `JSONEncoder`/`JSONDecoder` (or some other JSON tool) would be a problem, we could also use something simpler. -The `~/.local/bin` directory would include symlinks pointing to the `bin` directory of the "active" toolchain, if any. +The `~/.local/share/swiftly/bin` directory would include symlinks pointing to swiftly itself. When the proxies binaries are executed swiftly proxies them to the requested toolchain, or the default. This is all very similar to how rustup does things, but I figure there's no need to reinvent the wheel here. @@ -78,7 +85,7 @@ The contents of `~/Library/Application Support/swiftly` would look like this: – env ``` -Instead of downloading tarballs containing the toolchains and storing them directly in `~/.local/share/swiftly/toolchains`, we instead install Swift toolchains to `~/Library/Developer/Toolchains` via the `.pkg` files provided for download at swift.org. To select a toolchain for use, we update the symlinks at `~/Library/Application Support/swiftly/bin` to point to the desired toolchain in `~/Library/Developer/Toolchains`. In the env file, we’ll contain a line that looks like `export PATH="$HOME/Library/Application Support/swiftly:$PATH"`, so the version of swift being used will automatically always be from the active toolchain. `config.json` will contain version information about the selected toolchain as well as its actual location on disk. +Instead of downloading tarballs containing the toolchains and storing them directly in `~/.local/share/swiftly/toolchains`, we instead install Swift toolchains to `~/Library/Developer/Toolchains` via the `.pkg` files provided for download at swift.org. In the env file, we’ll add a line that looks like `export PATH="$HOME/Library/Application Support/swiftly:$PATH"`, so that swiftly can proxy toolchain commands to the requested toolchain, or default. `config.json` will contain version information about the selected toolchain as well as its actual location on disk. This scheme works for ensuring the version of Swift used on the command line can be controlled, but it doesn’t affect the active toolchain used by Xcode, which uses its own mechanisms for that. Xcode, if it is installed, can find the toolchains installed by swiftly. @@ -108,7 +115,7 @@ This will install the latest available stable release of Swift. If the latest ve ##### Installing a specific release version of Swift -To install a specific version of Swift, the user can provide it. +To install a specific version of Swift, the user can provide it. If a patch version isn't specified, it’ll install the latest patch version that matches the minor version provided. If a version is already installed that has the same major and minor version, a message will be printed indicating so and directing the user to `swiftly update a.b` if they wish to check for updates. @@ -138,6 +145,14 @@ Installing a specific snapshot from a swift version development branch `swiftly install 5.5-snapshot-2022-1-28` +##### Installing the version from the `.swift-version` file + +A package could have a ".swift-version" file that specifies the recommended toolchain version. A swiftly install with no version will search for a version file and install that version. + +`swiftly install` + +If no ".swift-version" file can be found then the installation fails indicating that it couldn't fine the file. + #### uninstall Uninstalling versions of Swift should be in a similar form to install. Uninstalling a toolchain that is currently “in use” (see the “use” command section below) will cause swiftly to use the latest Swift release toolchain that is installed. If none are, the latest snapshot will be used. If no snapshots are installed either, then a message will be printed indicating that all Swift versions are uninstalled. @@ -178,7 +193,7 @@ To list all the versions of swift installed on your system #### use -“Using” a toolchain sets it as the active toolchain, meaning it will be the one found via $PATH and invoked via `swift` commands executed in the shell. Only a single toolchain can be used at a given time. Using a toolchain doesn’t uninstall anything; it only updates symlinks so that the requested toolchain can be found by the shell. +“Using” a toolchain sets it as the default toolchain, meaning it will be the default one that is used when running toolchain commands from the shell. Only a single toolchain can be the default at a given time and location. Using a toolchain doesn’t uninstall anything; it only updates the configuration. To use the toolchain associated with the most up-to-date Swift version, the “latest” version can be specified: @@ -208,6 +223,10 @@ To use the latest installed main snapshot, leave off the date: `swiftly use main-snapshot` +The use subcommand also supports `.swift-version` files. If a ".swift-version" file is present in the current working directory, or an ancestory directory, then swiftly will update that file with the new version to use. This can be a useful feature for a team to share and align on toolchain versions with git. As a special case, if swiftly could not find a version file, but it could find a Package.swift file it will create a new version file for you in the package and set that to the requested toolchain version. + +Note: The `.swift-version` file mechanisms can be overridden using the `--global-default` flag so that your swiftly installation's default toolchain can be set explicitly. + #### update Update replaces a given toolchain with a later version of that toolchain. For a stable release, this means updating to a later patch version. For snapshots, this means updating to the most recently available snapshot. @@ -266,6 +285,60 @@ This command checks to see if there are new versions of `swiftly` itself and upg `swiftly self-update` +### Toolchain selection + +Swiftly will create a set of symbolic links in its SWIFTLY_BIN_DIR during installation that point to the swiftly binary itself for each of the common toolchain commands, such as swift, swiftc, clang, etc. This mechanism will allows swiftly to proxy those command invocations to a selected toolchain at the time of invocation. A toolchain can be selected in these ways in order of precedence: + +* The presence of a .swift-version file in the current working directory, or ancestor directory, with the required toolchain version +* The swiftly default (in-use) toolchain set in the swftly config.json by `swiftly install` or `swiftly use` commands + +If swiftly cannot find an installed toolchain that matches the selection then it fails with an error and instructions how to use `swiftly install` to satisfy the selection next time. + +#### Resolve selected toolchain + +For cases where the physical toolchain must be located, such as references specific header files, or shared libraries that are not proxied by swiftly there is a method to resolve the currently selected toolchain to its physical location using `swiftly use`. + +``` +swiftly use --print-location +``` + +This command will provide the full path to the directory where the selected toolchain is installed to standard output if such a toolchain exists. An external tool can directly navigate to the resources that it requires. For external tools that manage long-lived processes from the toolchain, such as the language server, and lldb, this command can be used in a poll to detect cases where the processes should be restarted. + +#### Run with a selected toolchain + +There are cases where you might want to run an arbitrary command using a selected toolchain. An example could be that you want to build something with CMake or Autoconf. + +``` +# CMake +swiftly run cmake -G ninja -D CMAKE_C_COMPILER=clang -D CMAKE_CXX_COMPILER=clang++ +swiftly run ninja build + +# Autoconf +CC=clang swiftly run ./configure +CC=clang swiftly run make +``` + +Swiftly prefixes the PATH to the selected toolchain directory and runs the command so that the toolchain executables are available and have precedence. + +If you want to explicitly specify a toolchain for the command you can do that with a selector notation like this: + +``` +swiftly run swift build +5.10.1 # Runs swift build with the 5.10.1 toolchain +``` + +A few notes about the '+' prefix. First, if a literal '+' prefix should be sent directly to the tool as an argument then it is escaped by doubling it with '++'. An argument with only '++' is ignored entirely, and any additional arguments are sent directly to the command without any further inspection of their prefixes. This is analogous to the special '--' token that certain argument parsers accept so that they don't interpret anything following that token as command flags or options. + +If the selected toolchain is not installed then swiftly will exit with a message indicating that you need to run `swiftly install x.y.z` to install it. + +``` +# Use the latest main snapshot toolchain and run 'swift build' to build the package with it. +swiftly run swift build +main-snapshot + +# Generate makefiles with the latest released Swift toolchain +swiftly run +latest cmake -G "Unix Makefile" -D CMAKE_C_COMPILER=clang +CC=clang swiftly run +latest make +``` + ## Detailed Design Swiftly itself will be a SPM project consisting of several executable products, one per supported platform, and all of these will share the core module that handles argument parsing, printing help information, and dispatching commands. Each platform’s executable will be built to statically link the stdlib so that they can be run without having installed Swift first. @@ -427,7 +500,7 @@ If the tag is a newer version than the installed one, a prompt indicating the ne $ dpkg --status libcurl4 ``` -If the exit code of the previous command was 0, then we know the dependency exists and can return true. If it wasn't, then we call fall back to attempting to locate the library via `pkg-config`: +If the exit code of the previous command was 0, then we know the dependency exists and can return true. If it wasn't, then we can fall back to attempting to locate the library via `pkg-config`: ``` $ pkg-config --exists libcurl @@ -457,15 +530,7 @@ https://download.swift.org/swift-5.5.1-release/ubuntu1604/swift-5.5.1-RELEASE/sw $ tar -xf --directory ~/.local/share/swiftly/toolchains ``` -It also updates `config.json` to include this toolchain as the latest for the provided version. If installing a new patch release toolchain, the now-outdated one can be deleted (e.g. `5.5.0` can be deleted when `5.5.1` is installed). - -Finally, the use implementation executes the following to update the link: - -``` -$ ln -s ~/.local/share/swiftly/toolchains//usr/bin/swift ~/.local/bin/swift -``` - -It also updates `config.json` to include this version as the currently selected one. +It also updates `config.json` to include this toolchain as the latest for the provided version. If installing a new patch release toolchain, the now-outdated one can be deleted (e.g. `5.5.0` can be deleted when `5.5.1` is installed). The `config.json` is updated to include this version as the currently selected (default) one. ### Implementation Sketch - macOS @@ -481,18 +546,13 @@ https://download.swift.org/swift--RELEASE/xcode/swift--RELEASE `config.json` is then updated to include this toolchain as the latest for the provided version. -Finally, the use implementation executes the following to update the link: - -``` -$ ln -s ~/Library/Developer/Toolchains/ ~/.swiftly/active-toolchain -``` - -It also updates `config.json` to include this version as the currently selected one. +It also updates `config.json` to include this version as the currently selected (default) one. ### `config.json` Schema ``` { + "version": "", "platform": { "namePretty": , "fullName": , diff --git a/Documentation/SwiftlyDocs.docc/SwiftlyDocs.md b/Documentation/SwiftlyDocs.docc/SwiftlyDocs.md index 8af36940..bd0b4da0 100644 --- a/Documentation/SwiftlyDocs.docc/SwiftlyDocs.md +++ b/Documentation/SwiftlyDocs.docc/SwiftlyDocs.md @@ -13,6 +13,7 @@ Install and manage your Swift programming language toolchains. ### HOWTOS - +- - - - diff --git a/Documentation/SwiftlyDocs.docc/getting-started.md b/Documentation/SwiftlyDocs.docc/getting-started.md index 7d48eab1..b66d50e3 100644 --- a/Documentation/SwiftlyDocs.docc/getting-started.md +++ b/Documentation/SwiftlyDocs.docc/getting-started.md @@ -27,9 +27,11 @@ $ swift --version Swift version 5.8.1 (swift-5.8.1-RELEASE) Target: x86_64-unknown-linux-gnu + +$ swift build # Build with the latest (5.8.1) toolchain ``` -Or, you can install (and use) a swift release: +You can install (and use) another release toolchain: ``` $ swiftly install --use 5.7 @@ -38,12 +40,27 @@ $ swift --version Swift version 5.7.2 (swift-5.7.2-RELEASE) Target: x86_64-unknown-linux-gnu + +$ swift build # Build with the 5.7.2 toolchain ``` -There's also an option to install the latest snapshot release and get access to the latest features: +Quickly test your package with the latest nightly snapshot to prepare for the next release: ``` $ swiftly install main-snapshot +$ swiftly run swift test +main-snapshot # Run "swift test" with the main-snapshot toolchain +$ swift build # Continue to build with my usual toolchain ``` -> Note: This last example just installed the toolchain. You can run "swiftly use" to switch to it and other installed toolchahins when you're ready. +Uninstall this toolchain after you're finished with it: + +``` +$ swiftly uninstall main-snapshot +``` + +# See Also: + +- [Install Toolchains](install-toolchains) +- [Using Toolchains](use-toolchains) +- [Uninstall Toolchains](uninstall-toolchains) +- [Swiftly CLI Reference](swiftly-cli-reference) diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index fb3591fd..827a1ecc 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -23,7 +23,7 @@ swiftly [--version] [--help] Install a new toolchain. ``` -swiftly install [--use] [--verify] [--post-install-file=] [--version] [--help] +swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--assume-yes] [--version] [--help] ``` **version:** @@ -53,13 +53,17 @@ Likewise, the latest snapshot associated with a given development branch can be $ swiftly install 5.7-snapshot $ swiftly install main-snapshot + Install whatever toolchain is currently selected, such as the the one in the .swift-version file: + + $ swiftly install + **--use:** *Mark the newly installed toolchain as in-use.* -**--verify:** +**--verify|no-verify:** *Verify the toolchain's PGP signature before proceeding with installation.* @@ -72,6 +76,11 @@ If the toolchain that is installed has extra post installation steps they they w written to this file as commands that can be run after the installation. +**--assume-yes:** + +*Disable confirmation prompts by assuming 'yes'* + + **--version:** *Show the version.* @@ -131,12 +140,27 @@ Note that listing available snapshots before the latest release (major and minor ## use -Set the active toolchain. If no toolchain is provided, print the currently in-use toolchain, if any. +Set the in-use toolchain. If no toolchain is provided, print the currently in-use toolchain, if any. ``` -swiftly use [] [--version] [--help] +swiftly use [--print-location] [--global-default] [--assume-yes] [] [--version] [--help] ``` +**--print-location:** + +*Print the location of the in-use toolchain. This is valid only when there is no toolchain argument.* + + +**--global-default:** + +*Use the global default, ignoring any .swift-version files.* + + +**--assume-yes:** + +*Disable confirmation prompts by assuming 'yes'* + + **toolchain:** *The toolchain to use.* @@ -285,7 +309,7 @@ The installed snapshots for a given devlopment branch can be listed by specifyin Update an installed toolchain to a newer version. ``` -swiftly update [] [--assume-yes] [--verify] [--post-install-file=] [--version] [--help] +swiftly update [] [--assume-yes] [--verify|no-verify] [--post-install-file=] [--version] [--help] ``` **toolchain:** @@ -331,7 +355,7 @@ A specific snapshot toolchain can be updated by including the date: *Disable confirmation prompts by assuming 'yes'* -**--verify:** +**--verify|no-verify:** *Verify the toolchain's PGP signature before proceeding with installation.* @@ -416,3 +440,59 @@ swiftly self-update [--version] [--help] +## run + +Run a command while proxying to the selected toolchain commands. + +``` +swiftly run ... [--version] [--help] +``` + +**command:** + +*Run a command while proxying to the selected toolchain commands.* + + +Run a command with a selected toolchain. The toolchain commands become the default in the system path. + +You can run one of the usual toolchain commands directly: + + $ swiftly run swift build + +Or you can run another program (or script) that runs one or more toolchain commands: + + $ CC=clang swiftly run make # Builds targets using clang + $ swiftly run ./build-things.sh # Script invokes 'swift build' to create certain product binaries + +Toolchain selection is determined by swift version files `.swift-version`, with a default global as the fallback. See the `swiftly use` command for more details. + +You can also override the selection mechanisms temporarily for the duration of the command using a special syntax. An argument prefixed with a '+' will be treated as the selector. + + $ swiftly run swift build +latest + $ swiftly run swift build +5.10.1 + +The first command builds the swift package with the latest toolchain and the second selects the 5.10.1 toolchain. Note that if these aren't installed then run will fail with an error message. You can pre-install the toolchain using `swiftly install ` to ensure success. + +If the command that you are running needs the arguments with the '+' prefixes then you can escape it by doubling the '++'. + + $ swiftly run ./myscript.sh ++abcde + +The script will receive the argument as '+abcde'. If there are multiple arguments with the '+' prefix that should be escaped you can disable the selection using a '++' argument, which turns off any selector argument processing for subsequent arguments. This is anologous to the '--' that turns off flag and option processing for subsequent arguments in many argument parsers. + + $ swiftly run ./myscript.sh ++ +abcde +xyz + +The script will receive the argument '+abcde' followed by '+xyz'. + + +**--version:** + +*Show the version.* + + +**--help:** + +*Show help information.* + + + + diff --git a/Documentation/SwiftlyDocs.docc/use-toolchains.md b/Documentation/SwiftlyDocs.docc/use-toolchains.md new file mode 100644 index 00000000..941b9a80 --- /dev/null +++ b/Documentation/SwiftlyDocs.docc/use-toolchains.md @@ -0,0 +1,79 @@ +# Use Swift Toolchains + +swiftly use and swiftly run + +Swiftly toolchains include a variety of compilers, linkers, debuggers, documentation generators, and other useful tools for working with Swift. Using a toolchain activates it so that when you run toolchain commands they are run with that version. + +When you install a toolchain you can start using it right away. If you don't have any other toolchains installed then it becomes the default. + +``` +$ swiftly install latest +$ swift --version +Swift version 6.0.1 (swift-6.0.1-RELEASE) +Target: aarch64-unknown-linux-gnu +$ swift build # Build with the current toolchain +``` + +When you have more than one toolchain installed then you can choose to use one of them with `swiftly use` for all subsequent commands like this: + +``` +$ swiftly install 5.10.1 +$ swiftly install main-snapshot +$ swiftly use 5.10.1 +$ swift build # Builds with the 5.10.1 toolchain +$ swift test # Tests with the 5.10.1 toolchain +$ swiftly use main-snapshot +$ swift build # Builds with the latest snapshot toolchain on the main branch +$ lldb # Run the debugger from the latest snapshot toolchain +``` + +If you're not certain which toolchain is in-use then use the bare `swiftly use` command to provide details: + +``` +$ swiftly use +Swift 6.0.1 (default) +``` + +You can print the exact toolchain location with the `--print-location` flag: + +``` +$ swiftly use --print-location +/Users/someuser/Library/Developer/Toolchains/swift-5.10.1-RELEASE.xctoolchain +``` + +## Sharing recommended toolchain versions + +Swiftly can create and update a special `.swift-version` file at the top of your git repository so that you can share your toolchain preference with the rest of your team: + +``` +$ cd path/to/git/repository +$ swiftly use 6.0.1 +A new file `path/to/git/repository/.swift-version` will be created to set the new in-use toolchain for this project. +Alternatively, you can set your default globally with the `--global-default` flag. Proceed with creating this file? (Y/n) Y +$ cat .swift-version +6.0.1 +``` + +When a team member uses swiftly with this git repository it can use the correct toolchain version automatically: + +``` +$ cd path/to/git/repository +$ swift --version +Swift version 6.0.1 (swift-6.0.1-RELEASE) +Target: aarch64-unknown-linux-gnu +``` + +If that team member doesn't have the toolchain installed on their system there will be a warning. They can install the selected toolchain automatically like this: + +``` +$ cd path/to/git/repository +$ swiftly install # Installs the version of the toolchain in the .swift-version file +``` + +If you want to temporarily use a toolchain version for one command you can try `swiftly run`. This will build your package with the latest snapshot toolchain: + +``` +$ swiftly run swift build +main-snapshot +``` + +> Note: The toolchain must be installed on your system before you can run with it. diff --git a/README.md b/README.md index d7541392..10f301bb 100644 --- a/README.md +++ b/README.md @@ -133,50 +133,37 @@ $ swiftly list ### Selecting a toolchain for use -“Using” a toolchain sets it as the active toolchain, meaning it will be the one found via $PATH and invoked via `swift` commands executed in the shell. +“Using” a toolchain sets it as the active toolchain, meaning it will be the one found via $PATH and invoked via `swift` commands executed in the shell. The toolchain must be installed before you can use it. -To use the toolchain associated with the most up-to-date Swift version, the “latest” version can be specified: +You can provide the same version selectors as you used with `swiftly install` to use a toolchain, including exact releacs versions "major.minor.patch", and snapshots. ``` $ swiftly use latest -``` - -To use a specific stable version of Swift already installed, specify the major/minor/patch version: - -``` $ swiftly use 5.3.1 -``` - -To use the latest installed patch version associated with a given major/minor version pair, the patch can be omitted: - -``` $ swiftly use 5.3 -``` - -To use a specific snapshot version, specify the full snapshot version name: - -``` -$ swiftly use 5.3-snapshot-YYYY-MM-DD -``` - -To use the latest installed snapshot associated with a given version, the date can be omitted: - -``` $ swiftly use 5.3-snapshot +$ swiftly use 5.3-snapshot-2022-08-16 +$ swiftly use main-snapshot +$ swiftly use main-snapshot-2024-06-18 ``` -To use a specific main snapshot, specify the full snapshot version name: +After you use a toolchain your commands at the shell will run with that toolchain: ``` -$ swiftly use main-snapshot-YYYY-MM-DD +$ swiftly use x.y.z +$ swift build # Build my package with toolchain version x.y.z +$ clang -c foo.c -o foo.o # Compile this C file using the clang compiler in toolchain version x.y.z +$ lldb # Open the debugger from toolchain version x.y.z ``` -To use the latest installed main snapshot, leave off the date: +If you want to run just one command with a particular toolchain without having to switch back to the one you used previously you can use the `swiftly run` command with the version. This command builds your current package with the latest snapshot toolchain of the current release: ``` -$ swiftly use main-snapshot +$ swiftly run swift build +main-snapshot ``` +The parameter with the "+" indicates that this is the version selector of the toolchain to use and supports the full range of selectors shown above and with the `swiftly install` command. The toolchain must be installed to run a command with that toolchain. + ### Updating a toolchain Update replaces a given toolchain with a later version of that toolchain. For a stable release, this means updating to a later patch, minor, or major version. For snapshots, this means updating to the most recently available snapshot. diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 7a08917c..3934ea54 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -25,8 +25,7 @@ public struct Linux: Platform { return URL(fileURLWithPath: dir) } else { return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local", isDirectory: true) - .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent(".local/share", isDirectory: true) } } @@ -34,8 +33,7 @@ public struct Linux: Platform { SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent(".local/share/swiftly/bin", isDirectory: true) } public var swiftlyToolchainsDir: URL { @@ -376,86 +374,6 @@ public struct Linux: Platform { try FileManager.default.removeItem(at: toolchainDir) } - public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool { - let toolchainBinURL = self.swiftlyToolchainsDir - .appendingPathComponent(toolchain.name, isDirectory: true) - .appendingPathComponent("usr", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - - if !FileManager.default.fileExists(atPath: toolchainBinURL.path) { - return false - } - - // Delete existing symlinks from previously in-use toolchain. - if let currentToolchain { - try self.unUse(currentToolchain: currentToolchain) - } - - // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. - let swiftlyBinDirContents = try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path) - let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) - let willBeOverwritten = Set(toolchainBinDirContents).intersection(swiftlyBinDirContents) - if !willBeOverwritten.isEmpty { - SwiftlyCore.print("The following existing executables will be overwritten:") - - for executable in willBeOverwritten { - SwiftlyCore.print(" \(self.swiftlyBinDir.appendingPathComponent(executable).path)") - } - - let proceed = SwiftlyCore.readLine(prompt: "Proceed? (y/n)") ?? "n" - - guard proceed == "y" else { - SwiftlyCore.print("Aborting use") - return false - } - } - - for executable in toolchainBinDirContents { - let linkURL = self.swiftlyBinDir.appendingPathComponent(executable) - let executableURL = toolchainBinURL.appendingPathComponent(executable) - - // Deletion confirmed with user above. - try linkURL.deleteIfExists() - - try FileManager.default.createSymbolicLink( - atPath: linkURL.path, - withDestinationPath: executableURL.path - ) - } - - return true - } - - public func unUse(currentToolchain: ToolchainVersion) throws { - let currentToolchainBinURL = self.swiftlyToolchainsDir - .appendingPathComponent(currentToolchain.name, isDirectory: true) - .appendingPathComponent("usr", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - - for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: currentToolchainBinURL.path) { - guard existingExecutable != "swiftly" else { - continue - } - - let url = self.swiftlyBinDir.appendingPathComponent(existingExecutable) - let vals = try url.resourceValues(forKeys: [.isSymbolicLinkKey]) - - guard let islink = vals.isSymbolicLink, islink else { - throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)") - } - let symlinkDest = url.resolvingSymlinksInPath() - guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else { - throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)") - } - - try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() - } - } - - public func listAvailableSnapshots(version _: String?) async -> [Snapshot] { - [] - } - public func getExecutableName() -> String { #if arch(x86_64) let arch = "x86_64" @@ -468,8 +386,6 @@ public struct Linux: Platform { return "swiftly-\(arch)-unknown-linux-gnu" } - public func currentToolchain() throws -> ToolchainVersion? { nil } - public func getTempFilePath() -> URL { FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") } @@ -634,5 +550,9 @@ public struct Linux: Platform { return "/bin/bash" } + public func findToolchainLocation(_ toolchain: ToolchainVersion) -> URL { + self.swiftlyToolchainsDir.appendingPathComponent("\(toolchain.name)") + } + public static let currentPlatform: any Platform = Linux() } diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index 619e0a93..761e0666 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -138,102 +138,10 @@ public struct MacOS: Platform { try? runProgram("pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier) } - public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool { - let toolchainBinURL = self.swiftlyToolchainsDir - .appendingPathComponent(toolchain.identifier + ".xctoolchain", isDirectory: true) - .appendingPathComponent("usr", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - - if !FileManager.default.fileExists(atPath: toolchainBinURL.path) { - return false - } - - // Delete existing symlinks from previously in-use toolchain. - if let currentToolchain { - try self.unUse(currentToolchain: currentToolchain) - } - - // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. - let swiftlyBinDirContents = try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path) - let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) - let willBeOverwritten = Set(toolchainBinDirContents).intersection(swiftlyBinDirContents) - if !willBeOverwritten.isEmpty { - SwiftlyCore.print("The following existing executables will be overwritten:") - - for executable in willBeOverwritten { - SwiftlyCore.print(" \(self.swiftlyBinDir.appendingPathComponent(executable).path)") - } - - let proceed = SwiftlyCore.readLine(prompt: "Proceed? (y/n)") ?? "n" - - guard proceed == "y" else { - SwiftlyCore.print("Aborting use") - return false - } - } - - for executable in toolchainBinDirContents { - let linkURL = self.swiftlyBinDir.appendingPathComponent(executable) - let executableURL = toolchainBinURL.appendingPathComponent(executable) - - // Deletion confirmed with user above. - try linkURL.deleteIfExists() - - try FileManager.default.createSymbolicLink( - atPath: linkURL.path, - withDestinationPath: executableURL.path - ) - } - - SwiftlyCore.print(""" - NOTE: On macOS it is possible that the shell will pick up the system Swift on the path - instead of the one that swiftly has installed for you. You can run the 'hash -r' - command to update the shell with the latest PATHs. - - hash -r - - """ - ) - - return true - } - - public func unUse(currentToolchain: ToolchainVersion) throws { - let currentToolchainBinURL = self.swiftlyToolchainsDir - .appendingPathComponent(currentToolchain.identifier + ".xctoolchain", isDirectory: true) - .appendingPathComponent("usr", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - - for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: currentToolchainBinURL.path) { - guard existingExecutable != "swiftly" else { - continue - } - - let url = self.swiftlyBinDir.appendingPathComponent(existingExecutable) - let vals = try url.resourceValues(forKeys: [URLResourceKey.isSymbolicLinkKey]) - - guard let islink = vals.isSymbolicLink, islink else { - throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)") - } - let symlinkDest = url.resolvingSymlinksInPath() - guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else { - throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)") - } - - try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() - } - } - - public func listAvailableSnapshots(version _: String?) async -> [Snapshot] { - [] - } - public func getExecutableName() -> String { "swiftly-macos-osx" } - public func currentToolchain() throws -> ToolchainVersion? { nil } - public func getTempFilePath() -> URL { FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg") } @@ -263,5 +171,9 @@ public struct MacOS: Platform { return "/bin/zsh" } + public func findToolchainLocation(_ toolchain: ToolchainVersion) -> URL { + self.swiftlyToolchainsDir.appendingPathComponent("\(toolchain.identifier).xctoolchain") + } + public static let currentPlatform: any Platform = MacOS() } diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index 5d86e95e..374984eb 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -54,6 +54,23 @@ internal struct Init: SwiftlyCommand { } } + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir + let swiftlyBinDirContents = (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]() + let willBeOverwritten = Set(["swiftly"]).intersection(swiftlyBinDirContents) + if !willBeOverwritten.isEmpty && !overwrite { + SwiftlyCore.print("The following existing executables will be overwritten:") + + for executable in willBeOverwritten { + SwiftlyCore.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)") + } + + let proceed = SwiftlyCore.readLine(prompt: "Proceed? [y/N]") ?? "n" + + guard proceed == "y" else { + throw Error(message: "Swiftly installation has been cancelled") + } + } + let shell = if let s = ProcessInfo.processInfo.environment["SHELL"] { s } else { @@ -106,18 +123,14 @@ internal struct Init: SwiftlyCommand { let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swiftly", isDirectory: false) let cmd = URL(fileURLWithPath: CommandLine.arguments[0]) - let systemManaged = try Swiftly.currentPlatform.isSystemManagedBinary(cmd.path) + let systemManagedSwiftlyBin = try Swiftly.currentPlatform.systemManagedBinary(CommandLine.arguments[0]) // Don't move the binary if it's already in the right place, this is being invoked inside an xctest, or it is a system managed binary - if cmd != swiftlyBin && !cmd.path.hasSuffix("xctest") && !systemManaged { + if cmd != swiftlyBin && !cmd.path.hasSuffix("xctest") && systemManagedSwiftlyBin == nil { SwiftlyCore.print("Moving swiftly into the installation directory...") if swiftlyBin.fileExists() { - if !overwrite { - throw Error(message: "Swiftly binary already exists. You can try again with `--overwrite` to replace it.") - } else { - try FileManager.default.removeItem(at: swiftlyBin) - } + try FileManager.default.removeItem(at: swiftlyBin) } do { @@ -201,6 +214,7 @@ internal struct Init: SwiftlyCommand { SwiftlyCore.print(""" To begin using installed swiftly from your current shell, first run the following command: \(sourceLine) + """) } } diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 119a6ce7..489b1366 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -39,9 +39,13 @@ struct Install: SwiftlyCommand { $ swiftly install 5.7-snapshot $ swiftly install main-snapshot + + Install whatever toolchain is currently selected, such as the the one in the .swift-version file: + + $ swiftly install """ )) - var version: String + var version: String? @Flag(name: .shortAndLong, help: "Mark the newly installed toolchain as in-use.") var use: Bool = false @@ -58,24 +62,57 @@ struct Install: SwiftlyCommand { )) var postInstallFile: String? + @OptionGroup var root: GlobalOptions + private enum CodingKeys: String, CodingKey { - case version, use, verify, postInstallFile + case version, use, verify, postInstallFile, root } mutating func run() async throws { try validateSwiftly() - let selector = try ToolchainSelector(parsing: self.version) var config = try Config.load() - let toolchainVersion = try await self.resolve(config: config, selector: selector) - let postInstallScript = try await Self.execute( + + var selector: ToolchainSelector + + if let version = self.version { + selector = try ToolchainSelector(parsing: version) + } else { + if case let (_, result) = try await selectToolchain(config: &config), + case let .swiftVersionFile(_, sel, error) = result + { + if let sel = sel { + selector = sel + } else if let error = error { + throw error + } else { + throw Error(message: "Internal error selecting toolchain to install.") + } + } else { + throw Error(message: "Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`") + } + } + + let toolchainVersion = try await Self.resolve(config: config, selector: selector) + let (postInstallScript, pathChanged) = try await Self.execute( version: toolchainVersion, &config, useInstalledToolchain: self.use, - verifySignature: self.verify + verifySignature: self.verify, + assumeYes: self.root.assumeYes ) - if let postInstallScript = postInstallScript { + if pathChanged { + SwiftlyCore.print(""" + NOTE: We have updated some elements in your path and your shell may not yet be + aware of the changes. You can run this command to update your shell. + + hash -r + + """) + } + + if let postInstallScript { guard let postInstallFile = self.postInstallFile else { throw Error(message: """ @@ -91,15 +128,16 @@ struct Install: SwiftlyCommand { } } - internal static func execute( + public static func execute( version: ToolchainVersion, _ config: inout Config, useInstalledToolchain: Bool, - verifySignature: Bool - ) async throws -> String? { + verifySignature: Bool, + assumeYes: Bool + ) async throws -> (postInstall: String?, pathChanged: Bool) { guard !config.installedToolchains.contains(version) else { - SwiftlyCore.print("\(version) is already installed, exiting.") - return nil + SwiftlyCore.print("\(version) is already installed.") + return (nil, false) } // Ensure the system is set up correctly before downloading it. Problems that prevent installation @@ -200,6 +238,66 @@ struct Install: SwiftlyCommand { try Swiftly.currentPlatform.install(from: tmpFile, version: version) + var pathChanged = false + + // Don't create the proxies in the tests + if CommandLine.arguments.count > 0 && !CommandLine.arguments[0].hasSuffix("xctest") { + // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. + let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swiftly", isDirectory: false) + let systemManagedSwiftlyBin = try Swiftly.currentPlatform.systemManagedBinary(CommandLine.arguments[0]) + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir + let swiftlyBinDirContents = (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]() + let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(version) + let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinDir.path) + + let proxyTo = if let systemManagedSwiftlyBin = systemManagedSwiftlyBin { + systemManagedSwiftlyBin + } else { + swiftlyBin.path + } + + let existingProxies = swiftlyBinDirContents.filter { bin in + do { + let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: swiftlyBinDir.appendingPathComponent(bin).path) + return linkTarget == proxyTo + } catch { return false } + } + + let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection(swiftlyBinDirContents) + if !overwrite.isEmpty && !assumeYes { + SwiftlyCore.print("The following existing executables will be overwritten:") + + for executable in overwrite { + SwiftlyCore.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)") + } + + let proceed = SwiftlyCore.readLine(prompt: "Proceed? [y/N]") ?? "n" + + guard proceed == "y" else { + throw Error(message: "Toolchain installation has been cancelled") + } + } + + SwiftlyCore.print("Setting up toolchain proxies...") + + let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union(overwrite) + + for p in proxiesToCreate { + let proxy = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(p) + + if proxy.fileExists() { + try FileManager.default.removeItem(at: proxy) + } + + try FileManager.default.createSymbolicLink( + atPath: proxy.path, + withDestinationPath: proxyTo + ) + + pathChanged = true + } + } + config.installedToolchains.insert(version) try config.save() @@ -207,15 +305,16 @@ struct Install: SwiftlyCommand { // If this is the first installed toolchain, mark it as in-use regardless of whether the // --use argument was provided. if useInstalledToolchain || config.inUse == nil { - try await Use.execute(version, &config) + // TODO: consider adding the global default option to this commands flags + try await Use.execute(version, globalDefault: false, &config) } SwiftlyCore.print("\(version) installed successfully!") - return postInstallScript + return (postInstallScript, pathChanged) } /// Utilize the swift.org API along with the provided selector to select a toolchain for install. - func resolve(config: Config, selector: ToolchainSelector) async throws -> ToolchainVersion { + public static func resolve(config: Config, selector: ToolchainSelector) async throws -> ToolchainVersion { switch selector { case .latest: SwiftlyCore.print("Fetching the latest stable Swift release...") diff --git a/Sources/Swiftly/List.swift b/Sources/Swiftly/List.swift index 285c7933..1757532e 100644 --- a/Sources/Swiftly/List.swift +++ b/Sources/Swiftly/List.swift @@ -39,16 +39,19 @@ struct List: SwiftlyCommand { try ToolchainSelector(parsing: input) } - let config = try Config.load() + var config = try Config.load() let toolchains = config.listInstalledToolchains(selector: selector).sorted { $0 > $1 } - let activeToolchain = config.inUse + let (inUse, _) = try await selectToolchain(config: &config) let printToolchain = { (toolchain: ToolchainVersion) in var message = "\(toolchain)" - if toolchain == activeToolchain { + if let inUse = inUse, toolchain == inUse { message += " (in use)" } + if toolchain == config.inUse { + message += " (default)" + } SwiftlyCore.print(message) } diff --git a/Sources/Swiftly/Proxy.swift b/Sources/Swiftly/Proxy.swift new file mode 100644 index 00000000..aca9440d --- /dev/null +++ b/Sources/Swiftly/Proxy.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftlyCore + +@main +public enum Proxy { + static func main() async throws { + do { + let zero = CommandLine.arguments[0] + guard let binName = zero.components(separatedBy: "/").last else { + fatalError("Could not determine the binary name for proxying") + } + + guard binName != "swiftly" else { + // Treat this as a swiftly invocation + await Swiftly.main() + return + } + + var config = try Config.load() + + let (toolchain, result) = try await selectToolchain(config: &config) + + // Abort on any errors relating to swift version files + if case let .swiftVersionFile(_, _, error) = result, let error = error { + throw error + } + + guard let toolchain = toolchain else { + throw Error(message: "No swift toolchain could be selected from either from a .swift-version file, or the default. You can try using `swiftly install ` to install one.") + } + + try await Swiftly.currentPlatform.proxy(toolchain, binName, Array(CommandLine.arguments[1...])) + } catch let terminated as RunProgramError { + exit(terminated.exitCode) + } catch let error as Error { + SwiftlyCore.print(error.message) + exit(1) + } catch { + SwiftlyCore.print("\(error)") + exit(1) + } + } +} diff --git a/Sources/Swiftly/Run.swift b/Sources/Swiftly/Run.swift new file mode 100644 index 00000000..8080930f --- /dev/null +++ b/Sources/Swiftly/Run.swift @@ -0,0 +1,135 @@ +import ArgumentParser +import Foundation +import SwiftlyCore + +internal struct Run: SwiftlyCommand { + public static var configuration = CommandConfiguration( + abstract: "Run a command while proxying to the selected toolchain commands." + ) + + @Argument(parsing: .captureForPassthrough, help: ArgumentHelp( + "Run a command while proxying to the selected toolchain commands.", + discussion: """ + + Run a command with a selected toolchain. The toolchain commands \ + become the default in the system path. + + You can run one of the usual toolchain commands directly: + + $ swiftly run swift build + + Or you can run another program (or script) that runs one or more toolchain commands: + + $ CC=clang swiftly run make # Builds targets using clang + $ swiftly run ./build-things.sh # Script invokes 'swift build' to create certain product binaries + + Toolchain selection is determined by swift version files `.swift-version`, with a default global \ + as the fallback. See the `swiftly use` command for more details. + + You can also override the selection mechanisms temporarily for the duration of the command using \ + a special syntax. An argument prefixed with a '+' will be treated as the selector. + + $ swiftly run swift build +latest + $ swiftly run swift build +5.10.1 + + The first command builds the swift package with the latest toolchain and the second selects the \ + 5.10.1 toolchain. Note that if these aren't installed then run will fail with an error message. \ + You can pre-install the toolchain using `swiftly install ` to ensure success. + + If the command that you are running needs the arguments with the '+' prefixes then you can escape \ + it by doubling the '++'. + + $ swiftly run ./myscript.sh ++abcde + + The script will receive the argument as '+abcde'. If there are multiple arguments with the '+' prefix \ + that should be escaped you can disable the selection using a '++' argument, which turns off any \ + selector argument processing for subsequent arguments. This is anologous to the '--' that turns off \ + flag and option processing for subsequent arguments in many argument parsers. + + $ swiftly run ./myscript.sh ++ +abcde +xyz + + The script will receive the argument '+abcde' followed by '+xyz'. + """ + )) + var command: [String] + + internal mutating func run() async throws { + try validateSwiftly() + + var config = try Config.load() + + let (command, selector) = try extractProxyArguments(command: self.command) + + let toolchain: ToolchainVersion? + + if let selector = selector { + let matchedToolchain = config.listInstalledToolchains(selector: selector).max() + guard let matchedToolchain = matchedToolchain else { + throw Error(message: "The selected toolchain \(selector.description) didn't match any of the installed toolchains. You can install it with `swiftly install \(selector.description)`") + } + + toolchain = matchedToolchain + } else { + let (version, result) = try await selectToolchain(config: &config) + + // Abort on any errors relating to swift version files + if case let .swiftVersionFile(_, _, error) = result, let error = error { + throw error + } + + toolchain = version + } + + guard let toolchain = toolchain else { + throw Error(message: "No swift toolchain could be selected from either from a .swift-version file, or the default. You can try using `swiftly install ` to install one.") + } + + do { + if let outputHandler = SwiftlyCore.outputHandler { + if let output = try await Swiftly.currentPlatform.proxyOutput(toolchain, command[0], [String](command[1...])) { + for line in output.split(separator: "\n") { + outputHandler.handleOutputLine(String(line)) + } + } + return + } + + try await Swiftly.currentPlatform.proxy(toolchain, command[0], [String](command[1...])) + } catch let terminated as RunProgramError { + Foundation.exit(terminated.exitCode) + } catch { + throw error + } + } +} + +public func extractProxyArguments(command: [String]) throws -> (command: [String], selector: ToolchainSelector?) { + var args: (command: [String], selector: ToolchainSelector?) = (command: [], nil) + + var disableEscaping = false + + for c in command { + if !disableEscaping && c == "++" { + disableEscaping = true + continue + } + + if !disableEscaping && c.hasPrefix("++") { + args.command.append("+\(String(c.dropFirst(2)))") + continue + } + + if !disableEscaping && c.hasPrefix("+") { + args.selector = try ToolchainSelector(parsing: String(c.dropFirst())) + continue + } + + args.command.append(c) + } + + guard args.command.count > 0 else { + throw Error(message: "Provide at least one command to run.") + } + + return args +} diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index c65d1d7e..1652c9a2 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -14,7 +14,6 @@ public struct GlobalOptions: ParsableArguments { public init() {} } -@main public struct Swiftly: SwiftlyCommand { public static var configuration = CommandConfiguration( abstract: "A utility for installing and managing Swift toolchains.", @@ -30,6 +29,7 @@ public struct Swiftly: SwiftlyCommand { Update.self, Init.self, SelfUpdate.self, + Run.self, ] ) diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index a763cd4f..b330d7bb 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -100,10 +100,9 @@ struct Uninstall: SwiftlyCommand { ?? config.listInstalledToolchains(selector: .latest).filter({ !toolchains.contains($0) }).max() ?? config.installedToolchains.filter({ !toolchains.contains($0) }).max() { - try await Use.execute(toUse, &config) + try await Use.execute(toUse, globalDefault: true, &config) } else { // If there are no more toolchains installed, just unuse the currently active toolchain. - try Swiftly.currentPlatform.unUse(currentToolchain: toolchain) config.inUse = nil try config.save() } diff --git a/Sources/Swiftly/Update.swift b/Sources/Swiftly/Update.swift index 3fb4ae27..43e56b55 100644 --- a/Sources/Swiftly/Update.swift +++ b/Sources/Swiftly/Update.swift @@ -108,11 +108,12 @@ struct Update: SwiftlyCommand { } } - let postInstallScript = try await Install.execute( + let (postInstallScript, pathChanged) = try await Install.execute( version: newToolchain, &config, useInstalledToolchain: config.inUse == parameters.oldToolchain, - verifySignature: self.verify + verifySignature: self.verify, + assumeYes: self.root.assumeYes ) try await Uninstall.execute(parameters.oldToolchain, &config) @@ -132,6 +133,16 @@ struct Update: SwiftlyCommand { try Data(postInstallScript.utf8).write(to: URL(fileURLWithPath: postInstallFile), options: .atomic) } + + if pathChanged { + SwiftlyCore.print(""" + NOTE: We have updated some elements in your path and your shell may not yet be + aware of the changes. You can run this command to update your shell. + + hash -r + + """) + } } /// Using the provided toolchain selector and the current config, returns a set of parameters that determines diff --git a/Sources/Swiftly/Use.swift b/Sources/Swiftly/Use.swift index b1cc9238..aeecbe5b 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -1,11 +1,20 @@ import ArgumentParser +import Foundation import SwiftlyCore internal struct Use: SwiftlyCommand { public static var configuration = CommandConfiguration( - abstract: "Set the active toolchain. If no toolchain is provided, print the currently in-use toolchain, if any." + abstract: "Set the in-use toolchain. If no toolchain is provided, print the currently in-use toolchain, if any." ) + @Flag(name: .shortAndLong, help: "Print the location of the in-use toolchain. This is valid only when there is no toolchain argument.") + var printLocation: Bool = false + + @Flag(name: .shortAndLong, help: "Use the global default, ignoring any .swift-version files.") + var globalDefault: Bool = false + + @OptionGroup var root: GlobalOptions + @Argument(help: ArgumentHelp( "The toolchain to use.", discussion: """ @@ -46,13 +55,44 @@ internal struct Use: SwiftlyCommand { try validateSwiftly() var config = try Config.load() + // This is the bare use command where we print the selected toolchain version (or the path to it) guard let toolchain = self.toolchain else { - if let inUse = config.inUse { - SwiftlyCore.print("\(inUse) (in use)") + let (selectedVersion, result) = try await selectToolchain(config: &config, globalDefault: self.globalDefault) + + // Abort on any errors with the swift version files + if case let .swiftVersionFile(_, _, error) = result, let error = error { + throw error } + + guard let selectedVersion = selectedVersion else { + // Return with nothing if there's no toolchain that is selected + return + } + + if self.printLocation { + // Print the toolchain location and exit + SwiftlyCore.print("\(Swiftly.currentPlatform.findToolchainLocation(selectedVersion).path)") + return + } + + var message = "\(selectedVersion)" + + switch result { + case let .swiftVersionFile(versionFile, _, _): + message += " (\(versionFile.path))" + case .globalDefault: + message += " (default)" + } + + SwiftlyCore.print(message) + return } + guard !self.printLocation else { + throw Error(message: "The print location flag cannot be used with a toolchain version.") + } + let selector = try ToolchainSelector(parsing: toolchain) guard let toolchain = config.listInstalledToolchains(selector: selector).max() else { @@ -60,29 +100,138 @@ internal struct Use: SwiftlyCommand { return } - try await Self.execute(toolchain, &config) + try await Self.execute(toolchain, globalDefault: self.globalDefault, assumeYes: self.root.assumeYes, &config) } - /// Use a toolchain. This method modifies and saves the input config. - internal static func execute(_ toolchain: ToolchainVersion, _ config: inout Config) async throws { - let previousToolchain = config.inUse + /// Use a toolchain. This method can modify and save the input config and also create/modify a `.swift-version` file. + internal static func execute(_ toolchain: ToolchainVersion, globalDefault: Bool, assumeYes: Bool = true, _ config: inout Config) async throws { + let (selectedVersion, result) = try await selectToolchain(config: &config, globalDefault: globalDefault) - guard toolchain != previousToolchain else { - SwiftlyCore.print("\(toolchain) is already in use") - return - } + if case let .swiftVersionFile(versionFile, _, _) = result { + if !assumeYes { + SwiftlyCore.print("The file `\(versionFile)` will be updated to set the new in-use toolchain for this project. Alternatively, you can set your default globally with the `--global-default` flag. Proceed with modifying this file?") - guard try Swiftly.currentPlatform.use(toolchain, currentToolchain: previousToolchain) else { - return + guard SwiftlyCore.promptForConfirmation(defaultBehavior: true) else { + SwiftlyCore.print("Aborting setting in-use toolchain") + return + } + } + + // We don't care in this case if there were any problems with the swift version files, just overwrite it with the new value + try toolchain.name.write(to: versionFile, atomically: true, encoding: .utf8) + } else if let newVersionFile = findNewVersionFile(), !globalDefault { + if !assumeYes { + SwiftlyCore.print("A new file `\(newVersionFile)` will be created to set the new in-use toolchain for this project. Alternatively, you can set your default globally with the `--global-default` flag. Proceed with creating this file?") + + guard SwiftlyCore.promptForConfirmation(defaultBehavior: true) else { + SwiftlyCore.print("Aborting setting in-use toolchain") + return + } + } + + try toolchain.name.write(to: newVersionFile, atomically: true, encoding: .utf8) + } else { + config.inUse = toolchain + try config.save() } - config.inUse = toolchain - try config.save() - var message = "Set the active toolchain to \(toolchain)" - if let previousToolchain { - message += " (was \(previousToolchain))" + var message = "Set the in-use toolchain to \(toolchain)" + if let selectedVersion = selectedVersion { + message += " (was \(selectedVersion.name))" } SwiftlyCore.print(message) } + + internal static func findNewVersionFile() -> URL? { + var cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + + while cwd.path != "" && cwd.path != "/" { + guard FileManager.default.fileExists(atPath: cwd.path) else { + break + } + + let gitDir = cwd.appendingPathComponent(".git") + + if FileManager.default.fileExists(atPath: gitDir.path) { + return cwd.appendingPathComponent(".swift-version") + } + + cwd = cwd.deletingLastPathComponent() + } + + return nil + } +} + +public enum ToolchainSelectionResult { + case globalDefault + case swiftVersionFile(URL, ToolchainSelector?, Error?) +} + +/// Returns the currently selected swift toolchain, if any, with details of the selection. +/// +/// The first portion of the returned tuple is the version that was selected, which +/// can be nil if none can be selected. +/// +/// Selection of a toolchain can be accomplished in a number of ways. There is the +/// the configuration's global default 'inUse' setting. This is the fallback selector +/// if there are no other selections. The returned tuple will contain the default toolchain +/// version and the result will be .default. +/// +/// A toolchain can also be selected from a `.swift-version` file in the current +/// working directory, or an ancestor directory. If it successfully selects a toolchain +/// then the result will be .swiftVersionFile with the URL of the file that made the +/// selection and the first item of the tuple is the selected toolchain version. +/// +/// However, there are cases where the swift version file fails to select a toolchain. +/// If such a case happens then the toolchain version in the tuple will be nil, but the +/// result will be .swiftVersionFile and a detailed error about the problem. This error +/// can be thrown by the client, or ignored. +public func selectToolchain(config: inout Config, globalDefault: Bool = false) async throws -> (ToolchainVersion?, ToolchainSelectionResult) { + if !globalDefault { + var cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + + while cwd.path != "" && cwd.path != "/" { + guard FileManager.default.fileExists(atPath: cwd.path) else { + break + } + + let svFile = cwd.appendingPathComponent(".swift-version") + + if FileManager.default.fileExists(atPath: svFile.path) { + let contents = try? String(contentsOf: svFile, encoding: .utf8) + + guard let contents = contents else { + return (nil, .swiftVersionFile(svFile, nil, Error(message: "The swift version file could not be read: \(svFile)"))) + } + + guard !contents.isEmpty else { + return (nil, .swiftVersionFile(svFile, nil, Error(message: "The swift version file is empty: \(svFile)"))) + } + + let selectorString = contents.replacingOccurrences(of: "\n", with: "").replacingOccurrences(of: "\r", with: "") + let selector: ToolchainSelector? + do { + selector = try ToolchainSelector(parsing: selectorString) + } catch { + return (nil, .swiftVersionFile(svFile, nil, Error(message: "The swift version file is malformed: \(svFile) \(error)"))) + } + + guard let selector = selector else { + return (nil, .swiftVersionFile(svFile, nil, Error(message: "The swift version file is malformed: \(svFile)"))) + } + + guard let selectedToolchain = config.listInstalledToolchains(selector: selector).max() else { + return (nil, .swiftVersionFile(svFile, selector, Error(message: "The swift version file `\(svFile.path)` uses toolchain version \(selector), but it doesn't match any of the installed toolchains. You can install the toolchain with `swiftly install`."))) + } + + return (selectedToolchain, .swiftVersionFile(svFile, selector, nil)) + } + + cwd = cwd.deletingLastPathComponent() + } + } + + return (config.inUse, .globalDefault) } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 591aa971..cad42f9b 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -33,6 +33,11 @@ public struct PlatformDefinition: Codable, Equatable { public static let debian12 = PlatformDefinition(name: "debian12", nameFull: "debian12", namePretty: "Debian GNU/Linux 12") } +public struct RunProgramError: Swift.Error { + public let exitCode: Int32 + public let program: String +} + public protocol Platform { /// The platform-specific location on disk where applications are /// supposed to store their custom data. @@ -69,18 +74,6 @@ public protocol Platform { /// If this version is in use, the next latest version will be used afterwards. func uninstall(_ version: ToolchainVersion) throws - /// Select the toolchain associated with the given version. - /// Returns whether the selection was successful. - func use(_ version: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool - - /// Clear the current active toolchain. - func unUse(currentToolchain: ToolchainVersion) throws - - /// Get a list of snapshot builds for the platform. If a version is specified, only - /// return snapshots associated with the version. - /// This will likely have a default implementation. - func listAvailableSnapshots(version: String?) async -> [Snapshot] - /// Get the name of the swiftly release binary. func getExecutableName() -> String @@ -113,6 +106,12 @@ public protocol Platform { /// Get the user's current login shell func getShell() async throws -> String + + /// Find the location where the toolchain should be installed. + func findToolchainLocation(_ toolchain: ToolchainVersion) -> URL + + /// Find the location of the toolchain binaries. + func findToolchainBinDir(_ toolchain: ToolchainVersion) -> URL } extension Platform { @@ -141,11 +140,67 @@ extension Platform { } #if os(macOS) || os(Linux) - public func runProgram(_ args: String..., quiet: Bool = false) throws { + internal func proxyEnv(_ toolchain: ToolchainVersion) throws -> [String: String] { + let tcPath = self.findToolchainLocation(toolchain).appendingPathComponent("usr/bin") + var newEnv = ProcessInfo.processInfo.environment + + // Prevent circularities with a memento environment variable + guard newEnv["SWIFTLY_PROXY_IN_PROGRESS"] == nil else { + throw Error(message: "Circular swiftly proxy invocation") + } + newEnv["SWIFTLY_PROXY_IN_PROGRESS"] = "1" + + // The toolchain goes to the beginning of the PATH + var newPath = newEnv["PATH"] ?? "" + if !newPath.hasPrefix(tcPath.path + ":") { + newPath = "\(tcPath.path):\(newPath)" + } + newEnv["PATH"] = newPath + + return newEnv + } + + /// Proxy the invocation of the provided command to the chosen toolchain. + /// + /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with + /// the exit code and program information. + /// + public func proxy(_ toolchain: ToolchainVersion, _ command: String, _ arguments: [String]) async throws { + try self.runProgram([command] + arguments, env: self.proxyEnv(toolchain)) + } + + /// Proxy the invocation of the provided command to the chosen toolchain and capture the output. + /// + /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with + /// the exit code and program information. + /// + public func proxyOutput(_ toolchain: ToolchainVersion, _ command: String, _ arguments: [String]) async throws -> String? { + try await self.runProgramOutput(command, arguments, env: self.proxyEnv(toolchain)) + } + + /// Run a program. + /// + /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with + /// the exit code and program information. + /// + public func runProgram(_ args: String..., quiet: Bool = false, env: [String: String]? = nil) throws { + try self.runProgram([String](args), quiet: quiet, env: env) + } + + /// Run a program. + /// + /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with + /// the exit code and program information. + /// + public func runProgram(_ args: [String], quiet: Bool = false, env: [String: String]? = nil) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args + if let env = env { + process.environment = env + } + if quiet { process.standardOutput = nil process.standardError = nil @@ -160,15 +215,33 @@ extension Platform { process.waitUntilExit() guard process.terminationStatus == 0 else { - throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)") + throw RunProgramError(exitCode: process.terminationStatus, program: args.first!) } } - public func runProgramOutput(_ program: String, _ args: String...) async throws -> String? { + /// Run a program and capture its output. + /// + /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with + /// the exit code and program information. + /// + public func runProgramOutput(_ program: String, _ args: String..., env: [String: String]? = nil) async throws -> String? { + try await self.runProgramOutput(program, [String](args), env: env) + } + + /// Run a program and capture its output. + /// + /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with + /// the exit code and program information. + /// + public func runProgramOutput(_ program: String, _ args: [String], env: [String: String]? = nil) async throws -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [program] + args + if let env = env { + process.environment = env + } + let outPipe = Pipe() process.standardInput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice @@ -185,7 +258,7 @@ extension Platform { process.waitUntilExit() guard process.terminationStatus == 0 else { - throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)") + throw RunProgramError(exitCode: process.terminationStatus, program: args.first!) } if let outData = outData { @@ -195,7 +268,7 @@ extension Platform { } } - public func isSystemManagedBinary(_ cmd: String) throws -> Bool { + public func systemManagedBinary(_ cmd: String) throws -> String? { let userHome = FileManager.default.homeDirectoryForCurrentUser let binLocs = [cmd] + ProcessInfo.processInfo.environment["PATH"]!.components(separatedBy: ":").map { $0 + "/" + cmd } var bin: String? @@ -212,10 +285,14 @@ extension Platform { // If the binary is in the user's home directory, or is not in system locations ("/usr", "/opt", "/bin") // then it is expected to be outside of a system package location and we manage the binary ourselves. if bin.hasPrefix(userHome.path + "/") || (!bin.hasPrefix("/usr") && !bin.hasPrefix("/opt") && !bin.hasPrefix("/bin")) { - return false + return nil } - return true + return bin + } + + public func findToolchainBinDir(_ toolchain: ToolchainVersion) -> URL { + self.findToolchainLocation(toolchain).appendingPathComponent("usr/bin") } #endif } diff --git a/Tests/SwiftlyTests/E2ETests.swift b/Tests/SwiftlyTests/E2ETests.swift index 88cbe0b3..29ac97a0 100644 --- a/Tests/SwiftlyTests/E2ETests.swift +++ b/Tests/SwiftlyTests/E2ETests.swift @@ -61,18 +61,6 @@ final class E2ETests: SwiftlyTests { XCTAssertTrue(release >= ToolchainVersion.StableRelease(major: 5, minor: 8, patch: 0)) try await validateInstalledToolchains([installedToolchain], description: "install latest") - - if let envScript = envScript { - let whichCmd = if shell.hasSuffix("bash") { - "type -P swift" - } else { - "which swift" - } - - // Check that within a new shell, the swift version succeeds and is the version we expect - let whichSwift = (try? await Swiftly.currentPlatform.runProgramOutput(shell, "-c", ". \(envScript.path) ; \(whichCmd)")) ?? "" - XCTAssertTrue(whichSwift.hasPrefix(Swiftly.currentPlatform.swiftlyBinDir.path)) - } } } } diff --git a/Tests/SwiftlyTests/ListTests.swift b/Tests/SwiftlyTests/ListTests.swift index ae2c1d37..6724ab97 100644 --- a/Tests/SwiftlyTests/ListTests.swift +++ b/Tests/SwiftlyTests/ListTests.swift @@ -128,7 +128,7 @@ final class ListTests: SwiftlyTests { let output = try await list.runWithMockedIO() let inUse = output.filter { $0.contains("in use") } - XCTAssertEqual(inUse, ["\(toolchain) (in use)"]) + XCTAssertEqual(inUse, ["\(toolchain) (in use) (default)"]) } try await self.runListTest { diff --git a/Tests/SwiftlyTests/PlatformTests.swift b/Tests/SwiftlyTests/PlatformTests.swift index d9f07e61..01e5841c 100644 --- a/Tests/SwiftlyTests/PlatformTests.swift +++ b/Tests/SwiftlyTests/PlatformTests.swift @@ -73,45 +73,4 @@ final class PlatformTests: SwiftlyTests { XCTAssertEqual(0, toolchains.count) } } - - func testUse() async throws { - try await self.rollbackLocalChanges { - // GIVEN: toolchains have been downloaded, and installed - var (mockedToolchainFile, version) = try await self.mockToolchainDownload(version: "5.8.0") - try Swiftly.currentPlatform.install(from: mockedToolchainFile, version: version) - (mockedToolchainFile, version) = try await self.mockToolchainDownload(version: "5.6.3") - try Swiftly.currentPlatform.install(from: mockedToolchainFile, version: version) - // WHEN: one of the toolchains is used - var result = try Swiftly.currentPlatform.use(ToolchainVersion(parsing: "5.8.0"), currentToolchain: nil) - // THEN: there are symbolic links for the toolchain binaries in the bin dir that point to the toolchain - XCTAssertTrue(result) - var swiftLinkTarget = try? FileManager.default.destinationOfSymbolicLink(atPath: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swift").path) - guard let target = swiftLinkTarget else { - throw Error(message: "swift symlink was not found") - } - XCTAssertTrue(target.contains("5.8")) - - // GIVEN: toolchains have been downloaded, installed, and a toolchain is in use - // WHEN: another toolchain is used - result = try Swiftly.currentPlatform.use(ToolchainVersion(parsing: "5.6.3"), currentToolchain: ToolchainVersion(parsing: "5.8.0")) - // THEN: there are symbolic links for the toolchain binaries in the bin dir that point to the toolchain - XCTAssertTrue(result) - swiftLinkTarget = try? FileManager.default.destinationOfSymbolicLink(atPath: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swift").path) - guard let target2 = swiftLinkTarget else { - throw Error(message: "swift symlink was not found") - } - XCTAssertTrue(target2.contains("5.6.3")) - - // GIVEN: toolchains have been downloaded, installed, and a toolchain is in use - // WHEN: a toolchain is used that has not been installed - result = try Swiftly.currentPlatform.use(ToolchainVersion(parsing: "5.2.1"), currentToolchain: ToolchainVersion(parsing: "5.6.3")) - // THEN: the symbolic links remain the same - XCTAssertFalse(result) - swiftLinkTarget = try? FileManager.default.destinationOfSymbolicLink(atPath: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swift").path) - guard let target3 = swiftLinkTarget else { - throw Error(message: "swift symlink was not found") - } - XCTAssertTrue(target3.contains("5.6.3")) - } - } } diff --git a/Tests/SwiftlyTests/RunTests.swift b/Tests/SwiftlyTests/RunTests.swift new file mode 100644 index 00000000..47337fd0 --- /dev/null +++ b/Tests/SwiftlyTests/RunTests.swift @@ -0,0 +1,96 @@ +import Foundation +@testable import Swiftly +@testable import SwiftlyCore +import XCTest + +final class RunTests: SwiftlyTests { + static let homeName = "runTests" + + /// Tests that the `run` command can switch between installed toolchains. + func testRunSelection() async throws { + try await self.withMockedHome(homeName: Self.homeName, toolchains: Self.allToolchains) { + // GIVEN: a set of installed toolchains + // WHEN: invoking the run command with a selector argument for that toolchain + var run = try self.parseCommand(Run.self, ["run", "swift", "--version", "+\(Self.newStable.name)"]) + var output = try await run.runWithMockedIO() + // THEN: the output confirms that it ran with the selected toolchain + XCTAssert(output.contains(Self.newStable.name)) + + // GIVEN: a set of installed toolchains and one is selected with a .swift-version file + let versionFile = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".swift-version") + try Self.oldStable.name.write(to: versionFile, atomically: true, encoding: .utf8) + // WHEN: invoking the run command without any selector arguments for toolchains + run = try self.parseCommand(Run.self, ["run", "swift", "--version"]) + output = try await run.runWithMockedIO() + // THEN: the output confirms that it ran with the selected toolchain + XCTAssert(output.contains(Self.oldStable.name)) + + // GIVEN: a set of installed toolchains + // WHEN: invoking the run command with a selector argument for a toolchain that isn't installed + run = try self.parseCommand(Run.self, ["run", "swift", "+1.2.3", "--version"]) + do { + try await run.run() + XCTAssert(false) + } catch let e as Error { + XCTAssert(e.message.contains("didn't match any of the installed toolchains")) + } + // THEN: an error is shown because there is no matching toolchain that is installed + } + } + + /// Tests the `run` command verifying that the environment is as expected + func testRunEnvironment() async throws { + try await self.withMockedHome(homeName: Self.homeName, toolchains: Self.allToolchains) { + // The toolchains directory should be the fist entry on the path + var run = try self.parseCommand(Run.self, ["run", try await Swiftly.currentPlatform.getShell(), "-c", "echo $PATH"]) + var output = try await run.runWithMockedIO() + XCTAssert(output.count == 1) + XCTAssert(output[0].contains(Swiftly.currentPlatform.swiftlyToolchainsDir.path)) + } + } + + /// Tests the extraction of proxy arguments from the run command arguments. + func testExtractProxyArguments() throws { + var (command, selector) = try extractProxyArguments(command: ["swift", "build"]) + XCTAssertEqual(["swift", "build"], command) + XCTAssertEqual(nil, selector) + + (command, selector) = try extractProxyArguments(command: ["swift", "+1.2.3", "build"]) + XCTAssertEqual(["swift", "build"], command) + XCTAssertEqual(try! ToolchainSelector(parsing: "1.2.3"), selector) + + (command, selector) = try extractProxyArguments(command: ["swift", "build", "+latest"]) + XCTAssertEqual(["swift", "build"], command) + XCTAssertEqual(try! ToolchainSelector(parsing: "latest"), selector) + + (command, selector) = try extractProxyArguments(command: ["+5.6", "swift", "build"]) + XCTAssertEqual(["swift", "build"], command) + XCTAssertEqual(try! ToolchainSelector(parsing: "5.6"), selector) + + (command, selector) = try extractProxyArguments(command: ["swift", "++1.2.3", "build"]) + XCTAssertEqual(["swift", "+1.2.3", "build"], command) + XCTAssertEqual(nil, selector) + + (command, selector) = try extractProxyArguments(command: ["swift", "++", "+1.2.3", "build"]) + XCTAssertEqual(["swift", "+1.2.3", "build"], command) + XCTAssertEqual(nil, selector) + + do { + let _ = try extractProxyArguments(command: ["+1.2.3"]) + XCTAssert(false) + } catch {} + + do { + let _ = try extractProxyArguments(command: []) + XCTAssert(false) + } catch {} + + (command, selector) = try extractProxyArguments(command: ["swift", "+1.2.3", "build"]) + XCTAssertEqual(["swift", "build"], command) + XCTAssertEqual(try! ToolchainSelector(parsing: "1.2.3"), selector) + + (command, selector) = try extractProxyArguments(command: ["swift", "build"]) + XCTAssertEqual(["swift", "build"], command) + XCTAssertEqual(nil, selector) + } +} diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index 10f44e9d..cc59daee 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -123,7 +123,7 @@ class SwiftlyTests: XCTestCase { } class func getTestHomePath(name: String) -> URL { - URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(name, isDirectory: true) + FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-tests-\(name)-\(UUID())") } /// Create a fresh swiftly home directory, populate it with a base config, and run the provided closure. @@ -148,6 +148,13 @@ class SwiftlyTests: XCTestCase { let config = try await self.baseTestConfig() try config.save() + let cwd = FileManager.default.currentDirectoryPath + defer { + FileManager.default.changeCurrentDirectoryPath(cwd) + } + + FileManager.default.changeCurrentDirectoryPath(testHome.path) + try await f() } @@ -283,17 +290,6 @@ class SwiftlyTests: XCTestCase { func validateInUse(expected: ToolchainVersion?) async throws { let config = try Config.load() XCTAssertEqual(config.inUse, expected) - - let executable = SwiftExecutable(path: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swift")) - - XCTAssertEqual(executable.exists(), expected != nil) - - guard let expected else { - return - } - - let inUseVersion = try await executable.version() - XCTAssertEqual(inUseVersion, expected) } /// Validate that all of the provided toolchains have been installed. diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index 724e2cce..604af79a 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -9,15 +9,10 @@ final class UseTests: SwiftlyTests { /// Execute a `use` command with the provided argument. Then validate that the configuration is updated properly and /// the in-use swift executable prints the the provided expectedVersion. func useAndValidate(argument: String, expectedVersion: ToolchainVersion) async throws { - var use = try self.parseCommand(Use.self, ["use", argument]) + var use = try self.parseCommand(Use.self, ["use", "-g", argument]) try await use.run() XCTAssertEqual(try Config.load().inUse, expectedVersion) - - let toolchainVersion = try self.getMockedToolchainVersion( - at: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swift") - ) - XCTAssertEqual(toolchainVersion, expectedVersion) } /// Tests that the `use` command can switch between installed stable release toolchains. @@ -171,13 +166,13 @@ final class UseTests: SwiftlyTests { /// Tests that the `use` command gracefully exits when executed before any toolchains have been installed. func testUseNoInstalledToolchains() async throws { try await self.withMockedHome(homeName: Self.homeName, toolchains: []) { - var use = try self.parseCommand(Use.self, ["use", "latest"]) + var use = try self.parseCommand(Use.self, ["use", "-g", "latest"]) try await use.run() var config = try Config.load() XCTAssertEqual(config.inUse, nil) - use = try self.parseCommand(Use.self, ["use", "5.6.0"]) + use = try self.parseCommand(Use.self, ["use", "-g", "5.6.0"]) try await use.run() config = try Config.load() @@ -213,117 +208,81 @@ final class UseTests: SwiftlyTests { } } - /// Tests that the `use` command symlinks all of the executables provided in a toolchain and removes any existing - /// symlinks from the previously active toolchain. - func testOldSymlinksRemoved() async throws { - try await self.withMockedHome(homeName: Self.homeName, toolchains: Self.allToolchains) { - let spec = [ - ToolchainVersion(major: 1, minor: 2, patch: 3): ["a", "b"], - ToolchainVersion(major: 2, minor: 3, patch: 4): ["b", "c", "d"], - ToolchainVersion(major: 3, minor: 4, patch: 5): ["a", "c", "d", "e"], - ] - - for (toolchain, files) in spec { - try await self.installMockedToolchain(toolchain: toolchain, executables: files) - } - - // Add an unrelated executable to the binary directory. - let existingFileName = "existing" - let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(existingFileName) - let data = Data("hello world\n".utf8) - try data.write(to: existingExecutableURL) - - for (toolchain, files) in spec { - var use = try self.parseCommand(Use.self, ["use", toolchain.name]) + /// Tests that running a use command without an argument prints the currently in-use toolchain. + func testPrintInUse() async throws { + let toolchains = [ + Self.newStable, + Self.newMainSnapshot, + Self.newReleaseSnapshot, + ] + try await self.withMockedHome(homeName: Self.homeName, toolchains: Set(toolchains)) { + for toolchain in toolchains { + var use = try self.parseCommand(Use.self, ["use", "-g", toolchain.name]) try await use.run() - // Verify that only the symlinks for the active toolchain remain. - let symlinks = try FileManager.default.contentsOfDirectory( - atPath: Swiftly.currentPlatform.swiftlyBinDir.path - ) - XCTAssertEqual(symlinks.sorted(), (files + [existingFileName]).sorted()) - - // Verify that any all the symlinks point to the right toolchain. - for file in files { - guard file != existingFileName else { - continue - } - let observedVersion = try self.getMockedToolchainVersion( - at: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(file) - ) - XCTAssertEqual(observedVersion, toolchain) - } - } - } - } - - /// Tests that any executables that already exist in SWIFTLY_BIN_DIR. - func testExistingExecutablesNotOverwritten() async throws { - try await self.withMockedHome(homeName: Self.homeName, toolchains: []) { - let existingExecutables = ["a", "b", "c"] - let existingText = "existing" - for fileName in existingExecutables { - let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(fileName) - let data = Data(existingText.utf8) - try data.write(to: existingExecutableURL) - } - - let toolchain = ToolchainVersion(major: 7, minor: 2, patch: 3) - try await self.installMockedToolchain( - toolchain: toolchain, - executables: ["a", "b", "c", "d", "e"] - ) - - var use = try self.parseCommand(Use.self, ["use", toolchain.name]) - let nOutput = try await use.runWithMockedIO(input: ["n"]) + var useEmpty = try self.parseCommand(Use.self, ["use", "-g"]) + var output = try await useEmpty.runWithMockedIO() - for exec in existingExecutables { - // Ensure we were prompted for each existing executable. - XCTAssert(nOutput.contains(where: { $0.contains(exec) })) - - // Ensure files were not overwritten. - let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(exec) - let contents = try String(contentsOf: existingExecutableURL, encoding: .utf8) - XCTAssertEqual(contents, existingText) - } - - let nConfig = try Config.load() - XCTAssertEqual(nConfig.inUse, nil) - - let yOutput = try await use.runWithMockedIO(input: ["y"]) + XCTAssert(output.contains(where: { $0.contains(String(describing: toolchain)) })) - // Ensure we were prompted for each existing executable. - for exec in existingExecutables { - XCTAssert(yOutput.contains(where: { $0.contains(exec) })) + useEmpty = try self.parseCommand(Use.self, ["use", "-g", "--print-location"]) + output = try await useEmpty.runWithMockedIO() - // Ensure files were overwritten. - let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(exec) - let contents = try String(contentsOf: existingExecutableURL, encoding: .utf8) - XCTAssertNotEqual(contents, existingText) + XCTAssert(output.contains(where: { $0.contains(Swiftly.currentPlatform.findToolchainLocation(toolchain).path) })) } - - let yConfig = try Config.load() - XCTAssertEqual(yConfig.inUse, toolchain) } } - /// Tests that running a use command without an argument prints the currently in-use toolchain. - func testPrintInUse() async throws { + /// Tests in-use toolchain selected by the .swift-version file. + func testSwiftVersionFile() async throws { let toolchains = [ Self.newStable, Self.newMainSnapshot, Self.newReleaseSnapshot, ] try await self.withMockedHome(homeName: Self.homeName, toolchains: Set(toolchains)) { - for toolchain in toolchains { - var use = try self.parseCommand(Use.self, ["use", toolchain.name]) - try await use.run() - - var useEmpty = try self.parseCommand(Use.self, ["use"]) - let output = try await useEmpty.runWithMockedIO() - - XCTAssert(output.contains(where: { $0.contains(String(describing: toolchain)) })) - } + let versionFile = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".swift-version") + + // GIVEN: a directory with a swift version file that selects a particular toolchain + try Self.newStable.name.write(to: versionFile, atomically: true, encoding: .utf8) + // WHEN: checking which toolchain is selected with the use command + var useCmd = try self.parseCommand(Use.self, ["use"]) + var output = try await useCmd.runWithMockedIO() + // THEN: the output shows this toolchain is in use with this working directory + XCTAssert(output.contains(where: { $0.contains(Self.newStable.name) })) + + // GIVEN: a directory with a swift version file that selects a particular toolchain + // WHEN: using another toolchain version + useCmd = try self.parseCommand(Use.self, ["use", Self.newMainSnapshot.name]) + output = try await useCmd.runWithMockedIO() + // THEN: the swift version file is updated to this toolchain version + var versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + XCTAssertEqual(Self.newMainSnapshot.name, versionFileContents) + // THEN: the use command reports this toolchain to be in use + XCTAssert(output.contains(where: { $0.contains(Self.newMainSnapshot.name) })) + + // GIVEN: a directory with no swift version file at the top of a git repository + try FileManager.default.removeItem(atPath: versionFile.path) + let gitDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".git") + try FileManager.default.createDirectory(atPath: gitDir.path, withIntermediateDirectories: false) + // WHEN: using a toolchain version + useCmd = try self.parseCommand(Use.self, ["use", Self.newReleaseSnapshot.name]) + try await useCmd.run() + // THEN: a swift version file is created + XCTAssert(FileManager.default.fileExists(atPath: versionFile.path)) + // THEN: the version file contains the specified version + versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + XCTAssertEqual(Self.newReleaseSnapshot.name, versionFileContents) + + // GIVEN: a directory with a swift version file at the top of a git repository + try "1.2.3".write(to: versionFile, atomically: true, encoding: .utf8) + // WHEN: using with a toolchain selector that can select more than one version, but matches one of the installed toolchains + let broadSelector = ToolchainSelector.stable(major: Self.newStable.asStableRelease!.major, minor: nil, patch: nil) + useCmd = try self.parseCommand(Use.self, ["use", broadSelector.description]) + try await useCmd.run() + // THEN: the swift version file is set to the specific toolchain version that was installed including major, minor, and patch + versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + XCTAssertEqual(Self.newStable.name, versionFileContents) } } } diff --git a/Tools/generate-docs-reference/GenerateDocsReference.swift b/Tools/generate-docs-reference/GenerateDocsReference.swift index 06e5e8ed..8e13fcce 100644 --- a/Tools/generate-docs-reference/GenerateDocsReference.swift +++ b/Tools/generate-docs-reference/GenerateDocsReference.swift @@ -142,11 +142,14 @@ extension ArgumentInfoV0 { return "" } - let name: String - if let preferred = self.preferredName { - name = preferred.name + let names: [String] + + if let myNames = self.names { + names = myNames.filter { $0.kind == .long }.map(\.name) + } else if let preferred = self.preferredName { + names = [preferred.name] } else if let value = self.valueName { - name = value + names = [value] } else { return "" } @@ -157,11 +160,11 @@ extension ArgumentInfoV0 { switch self.kind { case .positional: - "<\(name)>" + "<\(names.joined(separator: "|"))>" case .option: - "--\(name)=<\(self.valueName ?? "")>" + "--\(names.joined(separator: "|"))=<\(self.valueName ?? "")>" case .flag: - "--\(name)" + "--\(names.joined(separator: "|"))" } if self.isRepeating { @@ -176,11 +179,13 @@ extension ArgumentInfoV0 { } public func identity() -> String { - let name: String - if let preferred = self.preferredName { - name = preferred.name + let names: [String] + if let myNames = self.names { + names = myNames.filter { $0.kind == .long }.map(\.name) + } else if let preferred = self.preferredName { + names = [preferred.name] } else if let value = self.valueName { - name = value + names = [value] } else { return "" } @@ -191,11 +196,11 @@ extension ArgumentInfoV0 { switch self.kind { case .positional: - "\(name)" + "\(names.joined(separator: "|"))" case .option: - "--\(name)=\\<\(self.valueName ?? "")\\>" + "--\(names.joined(separator: "|"))=\\<\(self.valueName ?? "")\\>" case .flag: - "--\(name)" + "--\(names.joined(separator: "|"))" } return inner