diff --git a/.claude/skills/swiftyshell.md b/.claude/skills/swiftyshell.md index d08b97f..08b54c5 100644 --- a/.claude/skills/swiftyshell.md +++ b/.claude/skills/swiftyshell.md @@ -27,14 +27,16 @@ Before writing any code, follow this decision tree: → Use `Grep` or `Jq` 6. Is this a Homebrew operation (`brew install`, `brew upgrade`, `brew list`, ...)? → Use `Brew` -7. Does the operation need typed output, structured results, or conditional follow-up? +7. Is this a Swift toolchain or SwiftPM operation (`swift build`, `swift test`, `swift run`, `swift package`, ...)? + → Use `Swift` +8. Does the operation need typed output, structured results, or conditional follow-up? → Use the appropriate typed client -8. Are two or more commands chained by pipe? +9. Are two or more commands chained by pipe? → Use `.pipe(to:)` to build a `Pipeline` -9. Does the command write output to a file? +10. Does the command write output to a file? → Use `.stdout(.file(path:append:))` on the command -10. Is this any other command? - → Use `Command` +11. Is this any other command? + → Use `Command` ### API Reference @@ -690,6 +692,63 @@ public struct Rsync: RunnableCommandFamily { } ``` +#### Swift Toolchain + +```swift +public enum SwiftSubcommand: Sendable, Equatable, Hashable { + case version + case build + case test + case run + case package + case repl + case custom(String) +} + +public enum SwiftBuildConfiguration: String, Sendable, Equatable, Hashable { + case debug + case release +} + +public struct Swift: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func subcommand(_ value: SwiftSubcommand) -> Self + public func subcommand(_ value: String) -> Self + public func build() -> Self + public func test() -> Self + public func runProduct(_ product: String? = nil) -> Self + public func package(_ subcommand: String? = nil) -> Self + public func repl() -> Self + public func version() -> Self + public func packagePath(_ path: String) -> Self + public func scratchPath(_ path: String) -> Self + public func configuration(_ value: SwiftBuildConfiguration) -> Self + public func target(_ name: String) -> Self + public func product(_ name: String) -> Self + public func traits(_ names: [String]) -> Self + public func traits(_ names: String...) -> Self + public func enableAllTraits(_ enabled: Bool = true) -> Self + public func disableDefaultTraits(_ enabled: Bool = true) -> Self + public func buildTests(_ enabled: Bool = true) -> Self + public func codeCoverage(_ enabled: Bool = true) -> Self + public func skipBuild(_ enabled: Bool = true) -> Self + public func listTests(_ enabled: Bool = true) -> Self + public func filter(_ pattern: String) -> Self + public func skip(_ pattern: String) -> Self + public func jobs(_ count: Int) -> Self + public func swiftCompilerFlag(_ value: String) -> Self + public func swiftCompilerFlags(_ values: [String]) -> Self + public func cCompilerFlag(_ value: String) -> Self + public func linkerFlag(_ value: String) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func positionalArgument(_ value: String) -> Self + public func positionalArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} +``` + #### Archives (Tar / Zip / Unzip) ```swift @@ -1098,6 +1157,13 @@ try await Brew(context: context).install("ripgrep").run() // Homebrew — check outdated casks let outdated = try await Brew(context: context).outdated().greedy().run() +// SwiftPM — release build with warnings as errors +try await Swift(context: context) + .build() + .configuration(.release) + .swiftCompilerFlag("-warnings-as-errors") + .run() + // Rsync — mirror a project directory to a remote host try await Rsync(context: context) .archive() @@ -1352,7 +1418,7 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo Declared in `Package.swift`: -- **Per-family** — `Git`, `Brew`, `Grep`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip`. One trait per family directory; for `Common/`, one trait per file. +- **Per-family** — `Git`, `Brew`, `Grep`, `Swift`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip`. One trait per family directory; for `Common/`, one trait per file. - **Umbrellas** — `CommonUtilities` (every `Common/*` family), `All` (every command family). Consumers select families with `traits:` on `.package(...)`: @@ -1365,7 +1431,7 @@ Consumers select families with `traits:` on `.package(...)`: The contract is enforced by `Scripts/validate-traits.swift` and CI: -1. Every `.swift` file under a gated source directory (`Git/`, `Brew/`, `Grep/`, and each file in `Common/`) is wrapped top-to-bottom in `#if ... #endif`. +1. Every `.swift` file under a gated source directory (`Git/`, `Brew/`, `Grep/`, `Swift/`, and each file in `Common/`) is wrapped top-to-bottom in `#if ... #endif`. 2. Every test file targeting a gated family is wrapped the same way. Cross-family tests use combined guards (`#if Git && Grep`). 3. Every family directory (or `Common/*.swift` file) has a matching `.trait(name:)` entry in `Package.swift`. 4. The `All` umbrella's `enabledTraits` transitively enables every per-family trait. The `CommonUtilities` umbrella enables every `Common/*` trait. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04302d3..b07f281 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - name: Compute affected traits id: compute run: | - FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' + FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD") echo "Changed files:" @@ -53,6 +53,7 @@ jobs: echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Grep/|Tests/SwiftyShellTests/Grep/)' && family_traits+=("Grep") echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Fzf/|Tests/SwiftyShellTests/Fzf/)' && family_traits+=("Fzf") echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Rg/|Tests/SwiftyShellTests/Rg/)' && family_traits+=("Rg") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Swift/|Tests/SwiftyShellTests/Swift/)' && family_traits+=("Swift") echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Common/|Tests/SwiftyShellTests/Common/)' && family_traits+=("CommonUtilities") traits+=("${family_traits[@]}") diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index a270338..d635a1a 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -25,7 +25,7 @@ on: Defaults to the full matrix when omitted. required: false type: string - default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' + default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' jobs: validate-traits: diff --git a/AGENTS.md b/AGENTS.md index 06f2c3f..fc1a1de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,10 @@ Typed `grep` wrapper: `Grep` and `GrepPattern`. Typed wrapper for the Homebrew package manager: `Brew` and `BrewSubcommand`. +### `Sources/SwiftyShell/Swift/` + +Typed wrapper for the Swift toolchain: `Swift`, `SwiftSubcommand`, and `SwiftBuildConfiguration`. + ### `Sources/SwiftyShell/Common/` Typed wrappers for frequently used shell utilities: `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `JqArgument`, `Rsync`, `Tar`, `TarOperation`, `TarCompression`, `Zip`, `ZipCompressionLevel`, `Unzip`, and `UnzipEntry`. Each follows the same fluent builder conventions as all other command families. @@ -161,7 +165,7 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo **Trait inventory (declared in `Package.swift`):** -- Per-family: `Git`, `Brew`, `Grep`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip` (one trait per family directory; for `Common/`, one trait per file). +- Per-family: `Git`, `Brew`, `Grep`, `Swift`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip` (one trait per family directory; for `Common/`, one trait per file). - Umbrellas: `CommonUtilities` (all `Common/*`), `All` (every family). **The wiring contract** — enforced by `Scripts/validate-traits.swift` and CI: diff --git a/Package.swift b/Package.swift index 3ef8d93..feea0be 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( .trait(name: "Grep", description: "Typed wrapper for grep."), .trait(name: "Fzf", description: "Typed wrapper for the fzf fuzzy finder."), .trait(name: "Rg", description: "Typed wrapper for ripgrep (rg)."), + .trait(name: "Swift", description: "Typed wrapper for the Swift toolchain CLI."), .trait(name: "Ls", description: "Typed wrapper for ls."), .trait(name: "Cp", description: "Typed wrapper for cp."), .trait(name: "Mkdir", description: "Typed wrapper for mkdir."), @@ -43,7 +44,7 @@ let package = Package( .trait( name: "All", description: "Enables every command family shipped by SwiftyShell.", - enabledTraits: ["Git", "Brew", "Grep", "Fzf", "Rg", "CommonUtilities"] + enabledTraits: ["Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "CommonUtilities"] ), // Default is intentionally empty: consumers opt in to the families they want. .default(enabledTraits: []), diff --git a/README.md b/README.md index 8a4b1a5..b66db23 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ SwiftyShell ships typed wrappers for common tools. Each family is gated behind a | `Rg` | `rg` | `Rg` | Comprehensive ripgrep support, context, globs, JSON output | | `Brew` | `brew` | `Brew` | Full top-level subcommand coverage, plus `--cask` and `--greedy` | | `Fzf` | `fzf` | `Fzf` | Fuzzy finder options for interactive and filter-mode pipelines | +| `Swift` | `swift` | `Swift` | SwiftPM build, test, run, package commands, traits, compiler flags | | `Ls` | `ls` | `Ls` | All flags, recursive, human-readable sizes | | `Cp` | `cp` | `Cp` | Recursive, force | | `Mkdir` | `mkdir` | `Mkdir` | Parent directories, permissions | diff --git a/Sources/SwiftyShell/Swift/Swift.swift b/Sources/SwiftyShell/Swift/Swift.swift new file mode 100644 index 0000000..786637a --- /dev/null +++ b/Sources/SwiftyShell/Swift/Swift.swift @@ -0,0 +1,466 @@ +#if Swift +import Foundation + +/// The first argument selected for a ``Swift`` toolchain invocation. +public enum SwiftSubcommand: Sendable, Equatable, Hashable { + /// `swift --version` — print Swift toolchain version information. + case version + /// `swift build` — build a Swift package. + case build + /// `swift test` — build and run package tests. + case test + /// `swift run` — run an executable product from a package. + case run + /// `swift package` — perform Swift Package Manager operations. + case package + /// `swift repl` — start the Swift REPL. + case repl + /// Any Swift top-level subcommand or flag not modeled by a dedicated case. + case custom(String) + + fileprivate var argument: String { + switch self { + case .version: "--version" + case .build: "build" + case .test: "test" + case .run: "run" + case .package: "package" + case .repl: "repl" + case let .custom(value): value + } + } +} + +/// A SwiftPM build configuration passed with `--configuration`. +public enum SwiftBuildConfiguration: String, Sendable, Equatable, Hashable { + /// Debug configuration. + case debug + /// Release configuration. + case release +} + +/// A fluent wrapper for the `swift` toolchain command. +/// +/// ``Swift`` focuses on common package automation: `swift build`, `swift test`, `swift run`, +/// and `swift package` invocations. It models frequently used SwiftPM options such as package +/// path, build configuration, targets/products, traits, compiler flags, test filters, and code +/// coverage. Use ``argument(_:)`` or ``arguments(_:)`` for plugin commands or newer flags that +/// are not modeled yet. +/// +/// ```swift +/// try await Swift(context: context) +/// .build() +/// .configuration(.release) +/// .swiftCompilerFlag("-warnings-as-errors") +/// .run() +/// ``` +public struct Swift: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + /// + /// Forwarded from the embedded ``ToolConfiguration`` so commands built by ``command()`` and + /// invocations of ``run()`` share the same executor and defaults. + public var context: ShellContext { state.config.context } + + /// Creates a Swift toolchain command family bound to a shell context. + /// + /// The default invocation is `swift --version`, which is safe and read-only. Select package + /// operations with ``build()``, ``test()``, ``runProduct(_:)``, or ``package(_:)``. + /// + /// - Parameter context: The shell context whose executor, search paths, environment, and + /// defaults will be used. Defaults to a freshly constructed ``ShellContext``. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { + self.state = state + } + + /// Returns a copy with updated shared tool configuration. + /// + /// Funnels the protocol-provided helpers (``executable(_:)``, ``env(_:_:)``, + /// ``workingDirectory(_:)``, ``timeout(_:)``, ``outputLimit(_:)``). + /// + /// - Parameter update: A pure function that receives the current ``ToolConfiguration`` and + /// returns the next one. + /// - Returns: A new ``Swift`` value with the updated configuration applied. + public func updatingConfiguration( + _ update: (ToolConfiguration) -> ToolConfiguration + ) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes the built `swift` command's stdout to the given destination. + /// + /// Defaults to ``OutputDestination/capture``. SwiftPM writes package descriptions, version + /// output, and some command results to stdout. + /// + /// - Parameter destination: Where the executor should send the stdout stream. + /// - Returns: A new ``Swift`` value with the stdout destination applied. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes the built `swift` command's stderr to the given destination. + /// + /// Defaults to ``OutputDestination/capture``. SwiftPM diagnostics and build progress commonly + /// appear on stderr. + /// + /// - Parameter destination: Where the executor should send the stderr stream. + /// - Returns: A new ``Swift`` value with the stderr destination applied. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects a top-level Swift subcommand or flag. + public func subcommand(_ value: SwiftSubcommand) -> Self { copy(subcommand: value, packageSubcommand: nil) } + + /// Returns a copy that selects a raw top-level Swift subcommand or flag. + public func subcommand(_ value: String) -> Self { subcommand(.custom(value)) } + + /// Returns a copy that builds the package (`swift build`). + public func build() -> Self { subcommand(.build) } + + /// Returns a copy that tests the package (`swift test`). + public func test() -> Self { subcommand(.test) } + + /// Returns a copy that runs an executable package product (`swift run`). + /// + /// - Parameter product: Optional executable product name to pass after `swift run` options. + /// - Returns: A new ``Swift`` value configured for `swift run`. + public func runProduct(_ product: String? = nil) -> Self { + copy(subcommand: .run, packageSubcommand: nil, positionalArguments: product.map { [$0] } ?? []) + } + + /// Returns a copy that performs a Swift Package Manager operation (`swift package`). + /// + /// - Parameter subcommand: Optional package subcommand such as `resolve`, `dump-package`, or + /// `show-dependencies`. + /// - Returns: A new ``Swift`` value configured for `swift package`. + public func package(_ subcommand: String? = nil) -> Self { + copy(subcommand: .package, packageSubcommand: subcommand, positionalArguments: []) + } + + /// Returns a copy that starts the Swift REPL (`swift repl`). + public func repl() -> Self { subcommand(.repl) } + + /// Returns a copy that prints toolchain version information (`swift --version`). + public func version() -> Self { subcommand(.version) } + + /// Returns a copy that sets SwiftPM's package path (`--package-path`). + public func packagePath(_ path: String) -> Self { copy(packagePath: path) } + + /// Returns a copy that sets SwiftPM's scratch path (`--scratch-path`). + public func scratchPath(_ path: String) -> Self { copy(scratchPath: path) } + + /// Returns a copy that selects the package build configuration (`--configuration`). + public func configuration(_ value: SwiftBuildConfiguration) -> Self { copy(configuration: value) } + + /// Returns a copy that selects a package target (`--target`). + public func target(_ name: String) -> Self { copy(target: name) } + + /// Returns a copy that selects a package product (`--product`). + public func product(_ name: String) -> Self { copy(product: name) } + + /// Returns a copy that sets SwiftPM traits (`--traits`). + /// + /// - Parameter names: Trait names to join with commas, matching SwiftPM's expected syntax. + /// - Returns: A new ``Swift`` value with the trait list applied. + public func traits(_ names: [String]) -> Self { copy(traits: names) } + + /// Returns a copy that sets SwiftPM traits (`--traits`). + public func traits(_ names: String...) -> Self { traits(names) } + + /// Returns a copy that enables every package trait (`--enable-all-traits`). + public func enableAllTraits(_ enabled: Bool = true) -> Self { copy(enablesAllTraits: enabled) } + + /// Returns a copy that disables default package traits (`--disable-default-traits`). + public func disableDefaultTraits(_ enabled: Bool = true) -> Self { copy(disablesDefaultTraits: enabled) } + + /// Returns a copy that builds package tests during `swift build` (`--build-tests`). + public func buildTests(_ enabled: Bool = true) -> Self { copy(buildsTests: enabled) } + + /// Returns a copy that enables code coverage for supported SwiftPM operations. + public func codeCoverage(_ enabled: Bool = true) -> Self { copy(enablesCodeCoverage: enabled) } + + /// Returns a copy that skips building tests before running them (`--skip-build`). + public func skipBuild(_ enabled: Bool = true) -> Self { copy(skipsBuild: enabled) } + + /// Returns a copy that lists tests instead of running them (`--list-tests`). + public func listTests(_ enabled: Bool = true) -> Self { copy(listsTests: enabled) } + + /// Returns a copy that filters tests by regular expression (`--filter`). + public func filter(_ pattern: String) -> Self { copy(filter: pattern) } + + /// Returns a copy that skips tests matching a regular expression (`--skip`). + public func skip(_ pattern: String) -> Self { copy(skip: pattern) } + + /// Returns a copy that sets the number of build jobs (`--jobs`). + public func jobs(_ count: Int) -> Self { copy(jobs: count) } + + /// Returns a copy that passes a flag through to Swift compiler invocations (`-Xswiftc`). + public func swiftCompilerFlag(_ value: String) -> Self { + copy(swiftCompilerFlags: state.swiftCompilerFlags + [value]) + } + + /// Returns a copy that passes flags through to Swift compiler invocations (`-Xswiftc`). + public func swiftCompilerFlags(_ values: [String]) -> Self { + copy(swiftCompilerFlags: state.swiftCompilerFlags + values) + } + + /// Returns a copy that passes a flag through to C compiler invocations (`-Xcc`). + public func cCompilerFlag(_ value: String) -> Self { copy(cCompilerFlags: state.cCompilerFlags + [value]) } + + /// Returns a copy that passes a flag through to linker invocations (`-Xlinker`). + public func linkerFlag(_ value: String) -> Self { copy(linkerFlags: state.linkerFlags + [value]) } + + /// Returns a copy that appends a raw argument before positional arguments. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw arguments before positional arguments. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a positional argument after modeled and raw options. + public func positionalArgument(_ value: String) -> Self { + copy(positionalArguments: state.positionalArguments + [value]) + } + + /// Returns a copy that appends positional arguments after modeled and raw options. + public func positionalArguments(_ values: [String]) -> Self { + copy(positionalArguments: state.positionalArguments + values) + } + + /// Builds the raw `swift` command represented by the current builder state. + /// + /// Arguments are emitted deterministically as subcommand, package subcommand, modeled options, + /// raw arguments, then positional arguments. + /// + /// - Returns: A ``Command`` ready for execution or pipeline composition. + public func command() -> Command { + var arguments = [state.subcommand.argument] + + if let packageSubcommand = state.packageSubcommand { + arguments.append(packageSubcommand) + } + + if let packagePath = state.packagePath { + arguments.append("--package-path") + arguments.append(packagePath) + } + + if let scratchPath = state.scratchPath { + arguments.append("--scratch-path") + arguments.append(scratchPath) + } + + if let configuration = state.configuration { + arguments.append("--configuration") + arguments.append(configuration.rawValue) + } + + if let target = state.target { + arguments.append("--target") + arguments.append(target) + } + + if let product = state.product { + arguments.append("--product") + arguments.append(product) + } + + if !state.traits.isEmpty { + arguments.append("--traits") + arguments.append(state.traits.joined(separator: ",")) + } + + if state.enablesAllTraits { arguments.append("--enable-all-traits") } + if state.disablesDefaultTraits { arguments.append("--disable-default-traits") } + if state.buildsTests { arguments.append("--build-tests") } + if state.enablesCodeCoverage { arguments.append("--enable-code-coverage") } + if state.skipsBuild { arguments.append("--skip-build") } + if state.listsTests { arguments.append("--list-tests") } + + if let filter = state.filter { + arguments.append("--filter") + arguments.append(filter) + } + + if let skip = state.skip { + arguments.append("--skip") + arguments.append(skip) + } + + if let jobs = state.jobs { + arguments.append("--jobs") + arguments.append(String(jobs)) + } + + for flag in state.swiftCompilerFlags { + arguments.append("-Xswiftc") + arguments.append(flag) + } + + for flag in state.cCompilerFlags { + arguments.append("-Xcc") + arguments.append(flag) + } + + for flag in state.linkerFlags { + arguments.append("-Xlinker") + arguments.append(flag) + } + + arguments.append(contentsOf: state.extraArguments) + arguments.append(contentsOf: state.positionalArguments) + + let base = Command("swift") + .args(arguments) + .stdout(state.stdoutDestination) + .stderr(state.stderrDestination) + + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + subcommand: SwiftSubcommand? = nil, + packageSubcommand: String?? = nil, + packagePath: String?? = nil, + scratchPath: String?? = nil, + configuration: SwiftBuildConfiguration?? = nil, + target: String?? = nil, + product: String?? = nil, + traits: [String]? = nil, + enablesAllTraits: Bool? = nil, + disablesDefaultTraits: Bool? = nil, + buildsTests: Bool? = nil, + enablesCodeCoverage: Bool? = nil, + skipsBuild: Bool? = nil, + listsTests: Bool? = nil, + filter: String?? = nil, + skip: String?? = nil, + jobs: Int?? = nil, + swiftCompilerFlags: [String]? = nil, + cCompilerFlags: [String]? = nil, + linkerFlags: [String]? = nil, + extraArguments: [String]? = nil, + positionalArguments: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + subcommand: subcommand ?? state.subcommand, + packageSubcommand: packageSubcommand ?? state.packageSubcommand, + packagePath: packagePath ?? state.packagePath, + scratchPath: scratchPath ?? state.scratchPath, + configuration: configuration ?? state.configuration, + target: target ?? state.target, + product: product ?? state.product, + traits: traits ?? state.traits, + enablesAllTraits: enablesAllTraits ?? state.enablesAllTraits, + disablesDefaultTraits: disablesDefaultTraits ?? state.disablesDefaultTraits, + buildsTests: buildsTests ?? state.buildsTests, + enablesCodeCoverage: enablesCodeCoverage ?? state.enablesCodeCoverage, + skipsBuild: skipsBuild ?? state.skipsBuild, + listsTests: listsTests ?? state.listsTests, + filter: filter ?? state.filter, + skip: skip ?? state.skip, + jobs: jobs ?? state.jobs, + swiftCompilerFlags: swiftCompilerFlags ?? state.swiftCompilerFlags, + cCompilerFlags: cCompilerFlags ?? state.cCompilerFlags, + linkerFlags: linkerFlags ?? state.linkerFlags, + extraArguments: extraArguments ?? state.extraArguments, + positionalArguments: positionalArguments ?? state.positionalArguments + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let subcommand: SwiftSubcommand + let packageSubcommand: String? + let packagePath: String? + let scratchPath: String? + let configuration: SwiftBuildConfiguration? + let target: String? + let product: String? + let traits: [String] + let enablesAllTraits: Bool + let disablesDefaultTraits: Bool + let buildsTests: Bool + let enablesCodeCoverage: Bool + let skipsBuild: Bool + let listsTests: Bool + let filter: String? + let skip: String? + let jobs: Int? + let swiftCompilerFlags: [String] + let cCompilerFlags: [String] + let linkerFlags: [String] + let extraArguments: [String] + let positionalArguments: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + subcommand: SwiftSubcommand = .version, + packageSubcommand: String? = nil, + packagePath: String? = nil, + scratchPath: String? = nil, + configuration: SwiftBuildConfiguration? = nil, + target: String? = nil, + product: String? = nil, + traits: [String] = [], + enablesAllTraits: Bool = false, + disablesDefaultTraits: Bool = false, + buildsTests: Bool = false, + enablesCodeCoverage: Bool = false, + skipsBuild: Bool = false, + listsTests: Bool = false, + filter: String? = nil, + skip: String? = nil, + jobs: Int? = nil, + swiftCompilerFlags: [String] = [], + cCompilerFlags: [String] = [], + linkerFlags: [String] = [], + extraArguments: [String] = [], + positionalArguments: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.subcommand = subcommand + self.packageSubcommand = packageSubcommand + self.packagePath = packagePath + self.scratchPath = scratchPath + self.configuration = configuration + self.target = target + self.product = product + self.traits = traits + self.enablesAllTraits = enablesAllTraits + self.disablesDefaultTraits = disablesDefaultTraits + self.buildsTests = buildsTests + self.enablesCodeCoverage = enablesCodeCoverage + self.skipsBuild = skipsBuild + self.listsTests = listsTests + self.filter = filter + self.skip = skip + self.jobs = jobs + self.swiftCompilerFlags = swiftCompilerFlags + self.cCompilerFlags = cCompilerFlags + self.linkerFlags = linkerFlags + self.extraArguments = extraArguments + self.positionalArguments = positionalArguments + } +} +#endif diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md index be7ad83..84a8f01 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md @@ -6,7 +6,7 @@ Pick exactly the typed shell wrappers your project needs. SwiftyShell is split into a small, always-available `Core` (commands, pipelines, contexts, errors, executors) and a set of **opt-in** typed -command families: ``Git``, ``Brew``, ``Grep``, ``Fzf``, and a collection of common +command families: ``Git``, ``Brew``, ``Grep``, ``Fzf``, ``Swift``, and a collection of common file/directory utilities (``Ls``, ``Cp``, ``Mv``, ``Mkdir``, ``Chmod``, ``Rm``, ``Pwd``, ``Jq``, ``Rsync``, ``Tar``, ``Zip``, ``Unzip``). @@ -47,6 +47,7 @@ let status = try await Git() | `Brew` | ``Brew`` Homebrew wrapper | | `Grep` | ``Grep`` typed grep wrapper | | `Fzf` | ``Fzf`` typed fuzzy-finder wrapper | +| `Swift` | ``Swift`` Swift toolchain and SwiftPM wrapper | | `Ls` | ``Ls`` | | `Cp` | ``Cp`` | | `Mv` | ``Mv`` | diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Swift.md b/Sources/SwiftyShell/SwiftyShell.docc/Swift.md new file mode 100644 index 0000000..6c1269f --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Swift.md @@ -0,0 +1,93 @@ +# ``Swift`` + +A fluent wrapper for the `swift` toolchain command. + +``Swift`` covers common Swift package automation: building, testing, running executable products, +and invoking Swift Package Manager subcommands. It models frequently used SwiftPM options such as +package path, configuration, targets/products, package traits, compiler flag forwarding, code +coverage, and test filtering. + +Build a package in release mode with warnings promoted to errors: + +```swift +try await Swift(context: context) + .build() + .configuration(.release) + .swiftCompilerFlag("-warnings-as-errors") + .run() +``` + +Run tests with all package traits and code coverage: + +```swift +try await Swift(context: context) + .test() + .enableAllTraits() + .codeCoverage() + .run() +``` + +Invoke a SwiftPM plugin or less common package command with raw arguments: + +```swift +try await Swift(context: context) + .package("generate-documentation") + .argument("--target") + .argument("SwiftyShell") + .run() +``` + +Use ``argument(_:)`` or ``arguments(_:)`` for newer SwiftPM flags that are not modeled yet. + +## Topics + +### Subcommands + +- ``SwiftSubcommand`` +- ``subcommand(_:)-(SwiftSubcommand)`` +- ``subcommand(_:)-(String)`` +- ``build()`` +- ``test()`` +- ``runProduct(_:)`` +- ``package(_:)`` +- ``repl()`` +- ``version()`` + +### Package Options + +- ``SwiftBuildConfiguration`` +- ``packagePath(_:)`` +- ``scratchPath(_:)`` +- ``configuration(_:)`` +- ``target(_:)`` +- ``product(_:)`` +- ``traits(_:)-([String])`` +- ``traits(_:)-(String...)`` +- ``enableAllTraits(_:)`` +- ``disableDefaultTraits(_:)`` +- ``buildTests(_:)`` +- ``codeCoverage(_:)`` +- ``jobs(_:)`` + +### Test Options + +- ``skipBuild(_:)`` +- ``listTests(_:)`` +- ``filter(_:)`` +- ``skip(_:)`` + +### Flag Forwarding And Raw Arguments + +- ``swiftCompilerFlag(_:)`` +- ``swiftCompilerFlags(_:)`` +- ``cCompilerFlag(_:)`` +- ``linkerFlag(_:)`` +- ``argument(_:)`` +- ``arguments(_:)`` +- ``positionalArgument(_:)`` +- ``positionalArguments(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md index d8d44a7..99d29a3 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md @@ -5,7 +5,7 @@ Type-safe shell support for Swift. ## Overview SwiftyShell's primary API is a family of typed wrappers — ``Git``, ``Grep``, ``Rg``, -``Brew``, ``Fzf``, ``Ls``, ``Cp``, ``Mkdir``, ``Chmod``, ``Rm``, ``Mv``, ``Pwd``, ``Jq``, +``Brew``, ``Fzf``, ``Swift``, ``Ls``, ``Cp``, ``Mkdir``, ``Chmod``, ``Rm``, ``Mv``, ``Pwd``, ``Jq``, ``Rsync``, ``Tar``, ``Zip``, ``Unzip`` — that model shell tools as Swift values. The compiler enforces which flags exist, which arguments are required, and what the result looks like. ``Command`` is the fluent escape hatch for tools that don't have a typed wrapper yet; it @@ -147,6 +147,12 @@ let output = try await Command("echo", arguments: "hello").run(in: context) - ``Brew`` - ``BrewSubcommand`` +### Swift Toolchain + +- ``Swift`` +- ``SwiftSubcommand`` +- ``SwiftBuildConfiguration`` + ### Common File-System Commands - ``Ls`` diff --git a/Tests/SwiftyShellTests/Swift/SwiftTests.swift b/Tests/SwiftyShellTests/Swift/SwiftTests.swift new file mode 100644 index 0000000..f38af63 --- /dev/null +++ b/Tests/SwiftyShellTests/Swift/SwiftTests.swift @@ -0,0 +1,166 @@ +#if Swift +import Testing +@testable import SwiftyShell + +struct SwiftCommandTests { + @Test func defaultsToVersionCommand() { + let command = Swift().command() + + #expect(command.executableName == "swift") + #expect(command.arguments == ["--version"]) + } + + @Test func buildsReleasePackageBuildCommand() { + let command = Swift() + .build() + .packagePath("/workspace") + .scratchPath("/tmp/build") + .configuration(.release) + .product("SwiftyShell") + .traits("Git", "Rsync") + .disableDefaultTraits() + .jobs(8) + .swiftCompilerFlag("-warnings-as-errors") + .command() + + #expect( + command.arguments == [ + "build", + "--package-path", "/workspace", + "--scratch-path", "/tmp/build", + "--configuration", "release", + "--product", "SwiftyShell", + "--traits", "Git,Rsync", + "--disable-default-traits", + "--jobs", "8", + "-Xswiftc", "-warnings-as-errors", + ] + ) + } + + @Test func buildsTestCommandWithCoverageAndFilters() { + let command = Swift() + .test() + .enableAllTraits() + .codeCoverage() + .skipBuild() + .listTests() + .filter("CommandTests") + .skip("SlowTests") + .command() + + #expect( + command.arguments == [ + "test", + "--enable-all-traits", + "--enable-code-coverage", + "--skip-build", + "--list-tests", + "--filter", "CommandTests", + "--skip", "SlowTests", + ] + ) + } + + @Test func buildsRunCommandWithProductAndExecutableArguments() { + let command = Swift() + .runProduct("tool") + .configuration(.debug) + .positionalArguments(["--input", "file.txt"]) + .command() + + #expect(command.arguments == ["run", "--configuration", "debug", "tool", "--input", "file.txt"]) + } + + @Test func buildsPackageSubcommandAndRawPluginArguments() { + let command = Swift() + .package("generate-documentation") + .argument("--target") + .argument("SwiftyShell") + .command() + + #expect(command.arguments == ["package", "generate-documentation", "--target", "SwiftyShell"]) + } + + @Test func buildsCompilerAndLinkerFlagForwarding() { + let command = Swift() + .build() + .swiftCompilerFlags(["-warnings-as-errors", "-strict-concurrency=complete"]) + .cCompilerFlag("-DDEBUG") + .linkerFlag("-dead_strip") + .command() + + #expect( + command.arguments == [ + "build", + "-Xswiftc", "-warnings-as-errors", + "-Xswiftc", "-strict-concurrency=complete", + "-Xcc", "-DDEBUG", + "-Xlinker", "-dead_strip", + ] + ) + } + + @Test func buildsModeledAndCustomSubcommands() { + let cases: [(SwiftSubcommand, String)] = [ + (.version, "--version"), + (.build, "build"), + (.test, "test"), + (.run, "run"), + (.package, "package"), + (.repl, "repl"), + (.custom("format"), "format"), + ] + + for (subcommand, expected) in cases { + #expect(Swift().subcommand(subcommand).command().arguments == [expected]) + } + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { + var command: Command? + var workingDirectory: String? + + func record(_ command: Command, context: ShellContext) { + self.command = command + self.workingDirectory = context.workingDirectory + } + } + + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, context in + await recorder.record(command, context: context) + return ShellOutput(stdout: "Swift version 6.1\n", stderr: "", exitCode: 0) + }, + workingDirectory: "/context" + ) + + let output = try await Swift(context: context) + .executable("/usr/bin/swift") + .workingDirectory("/override") + .timeout(5) + .outputLimit(1024) + .version() + .run() + + let command = await recorder.command + #expect(output.stdout == "Swift version 6.1\n") + #expect(command?.executableName == "swift") + #expect(command?.executableOverride == "/usr/bin/swift") + #expect(command?.workingDirectoryOverride == "/override") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["--version"]) + #expect(await recorder.workingDirectory == "/context") + } + + @Test func runsSwiftVersionWhenAvailable() async throws { + let output = try await Swift().version().run() + + #expect(output.exitCode == 0) + #expect(output.stdout.contains("Swift")) + } +} +#endif