├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ └── checkCommit.yml ├── .gitignore ├── .swift-format ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Script │ ├── Builtins.swift │ ├── Executable.swift │ ├── File Operations.swift │ ├── List Comprehensions.swift │ ├── Output Capture.swift │ ├── Pipe.swift │ └── Script.swift ├── ScriptExample │ └── Main.swift └── Shwift │ ├── Builtins.swift │ ├── Context.swift │ ├── Environment.swift │ ├── IO.swift │ ├── Pipe.swift │ ├── Process.swift │ └── Support │ ├── Async Inbound Handler.swift │ ├── File Descriptor.swift │ ├── NIO Pipe Bootstrap.swift │ └── Posix Spawn.swift └── Tests └── ShwiftTests ├── Cat.txt ├── Recorder Tests.swift └── Shwift Tests.swift /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM swift:5.9-jammy 2 | # FROM swiftlang/swift:nightly-main-focal 3 | 4 | # Install test dependencies 5 | RUN DEBIAN_FRONTEND=noninteractive \ 6 | apt-get update && \ 7 | apt-get install -y \ 8 | xxd 9 | 10 | # Install swift-format 11 | RUN \ 12 | git clone https://github.com/apple/swift-format.git -b 509.0.0 /opt/swift-format && \ 13 | cd /opt/swift-format && \ 14 | # Build debug since release takes forever to build and there isn't a meaningful runtime performance delta 15 | swift build -c debug && \ 16 | cp .build/debug/swift-format /usr/bin/swift-format && \ 17 | rm -rf /opt/swift-format 18 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Shwift", 3 | "dockerFile": "Dockerfile", 4 | 5 | "capAdd": [ 6 | "SYS_PTRACE" 7 | ], 8 | "securityOpt": [ 9 | "seccomp=unconfined" 10 | ], 11 | 12 | "mounts": [ 13 | // Use a named volume for the build products for optimal performance (https://code.visualstudio.com/remote/advancedcontainers/improve-performance?WT.mc_id=javascript-14373-yolasors#_use-a-targeted-named-volume) 14 | "source=${localWorkspaceFolderBasename}-build,target=${containerWorkspaceFolder}/.build,type=volume" 15 | ], 16 | "remoteEnv": { 17 | // Useful for disambiguating devcontainers 18 | "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" 19 | }, 20 | 21 | "customizations": { 22 | "vscode": { 23 | "settings": { 24 | "lldb.library": "/usr/lib/liblldb.so", 25 | "lldb.launch.expressions": "native", 26 | 27 | "[swift]": { 28 | "editor.defaultFormatter": "vknabel.vscode-apple-swift-format" 29 | }, 30 | }, 31 | "extensions": [ 32 | "sswg.swift-lang", 33 | "vknabel.vscode-apple-swift-format", 34 | ], 35 | } 36 | }, 37 | } -------------------------------------------------------------------------------- /.github/workflows/checkCommit.yml: -------------------------------------------------------------------------------- 1 | name: Check Commit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | jobs: 10 | validate-formatting: 11 | name: Validate Formatting 12 | runs-on: ubuntu-22.04 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Initialize devcontainer 17 | uses: devcontainers/ci@v0.3 18 | with: 19 | push: never 20 | runCmd: | 21 | git status 22 | echo "Devcontainer Initialized." 23 | - name: Validate swift formatting 24 | uses: devcontainers/ci@v0.3 25 | with: 26 | push: never 27 | runCmd: | 28 | swift-format \ 29 | --in-place \ 30 | --recursive Sources Tests 31 | git config --global --add safe.directory /workspaces/Shwift 32 | git diff 33 | git diff-index --quiet HEAD -- 34 | 35 | test-macos: 36 | name: Run Tests on macOS 37 | strategy: 38 | fail-fast: false 39 | runs-on: macos-13 40 | steps: 41 | - uses: actions/checkout@v2 42 | - name: Install Correct Swift Version 43 | uses: compnerd/gha-setup-swift@main 44 | with: 45 | branch: swift-5.9-release 46 | tag: 5.9-RELEASE 47 | - run: swift build 48 | - run: swift test 49 | - run: swift run ScriptExample 50 | 51 | test-devcontainer: 52 | name: Run Tests in devcontainer 53 | strategy: 54 | fail-fast: false 55 | runs-on: ubuntu-22.04 56 | steps: 57 | - uses: actions/checkout@v2 58 | - name: Initialize devcontainer 59 | uses: devcontainers/ci@v0.3 60 | with: 61 | push: never 62 | runCmd: | 63 | echo "Devcontainer Initialized." 64 | - name: Test Shwift 65 | uses: devcontainers/ci@v0.3 66 | with: 67 | push: never 68 | runCmd: | 69 | swift build 70 | swift test 71 | swift run ScriptExample 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .vscode 7 | /build 8 | .swiftpm 9 | .xcode 10 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "lineLength": 100, 4 | "indentation": { 5 | "spaces": 2 6 | }, 7 | "tabWidth": 8, 8 | "maximumBlankLines": 1, 9 | "respectsExistingLineBreaks": true, 10 | "fileScopedDeclarationPrivacy": { 11 | "accessLevel": "private" 12 | }, 13 | "indentConditionalCompilationBlocks": true, 14 | "indentSwitchCaseLabels": false, 15 | "lineBreakAroundMultilineExpressionChainComponents": false, 16 | "lineBreakBeforeControlFlowKeywords": false, 17 | "lineBreakBeforeEachArgument": false, 18 | "lineBreakBeforeEachGenericRequirement": false, 19 | "prioritizeKeepingFunctionOutputTogether": false, 20 | "rules": { 21 | "AllPublicDeclarationsHaveDocumentation": false, 22 | "AlwaysUseLowerCamelCase": true, 23 | "AmbiguousTrailingClosureOverload": true, 24 | "BeginDocumentationCommentWithOneLineSummary": false, 25 | "DoNotUseSemicolons": true, 26 | "DontRepeatTypeInStaticProperties": true, 27 | "FileScopedDeclarationPrivacy": true, 28 | "FullyIndirectEnum": true, 29 | "GroupNumericLiterals": true, 30 | "IdentifiersMustBeASCII": true, 31 | "NeverForceUnwrap": false, 32 | "NeverUseForceTry": false, 33 | "NeverUseImplicitlyUnwrappedOptionals": false, 34 | "NoAccessLevelOnExtensionDeclaration": false, 35 | "NoBlockComments": false, 36 | "NoCasesWithOnlyFallthrough": true, 37 | "NoEmptyTrailingClosureParentheses": true, 38 | "NoLabelsInCasePatterns": true, 39 | "NoLeadingUnderscores": false, 40 | "NoParensAroundConditions": true, 41 | "NoVoidReturnOnFunctionSignature": true, 42 | "OneCasePerLine": true, 43 | "OneVariableDeclarationPerLine": true, 44 | "OnlyOneTrailingClosureArgument": true, 45 | "OrderedImports": false, 46 | "ReturnVoidInsteadOfEmptyTuple": true, 47 | "UseLetInEveryBoundCaseVariable": false, 48 | "UseShorthandTypeNames": false, 49 | "UseSingleLinePropertyGetter": true, 50 | "UseSynthesizedInitializer": true, 51 | "UseTripleSlashForDocumentationComments": false, 52 | "ValidateDocumentationComments": false 53 | } 54 | } -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 7 | "state": { 8 | "branch": null, 9 | "revision": "9f39744e025c7d377987f30b03770805dcb0bcd1", 10 | "version": "1.1.4" 11 | } 12 | }, 13 | { 14 | "package": "swift-nio", 15 | "repositoryURL": "https://github.com/apple/swift-nio.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "124119f0bb12384cef35aa041d7c3a686108722d", 19 | "version": "2.40.0" 20 | } 21 | }, 22 | { 23 | "package": "swift-system", 24 | "repositoryURL": "https://github.com/apple/swift-system", 25 | "state": { 26 | "branch": null, 27 | "revision": "836bc4557b74fe6d2660218d56e3ce96aff76574", 28 | "version": "1.1.1" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Shwift", 7 | platforms: [ 8 | .macOS(.v12) 9 | ], 10 | products: [ 11 | .library(name: "Script", targets: ["Script"]), 12 | .library(name: "Shwift", targets: ["Shwift"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), 16 | .package(url: "https://github.com/apple/swift-system", from: "1.1.1"), 17 | .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "Shwift", 22 | dependencies: [ 23 | .product(name: "SystemPackage", package: "swift-system"), 24 | .product(name: "NIO", package: "swift-nio"), 25 | .product(name: "_NIOConcurrency", package: "swift-nio"), 26 | ], 27 | cSettings: [ 28 | .define("_GNU_SOURCE", .when(platforms: [.linux])) 29 | ]), 30 | 31 | .target( 32 | name: "Script", 33 | dependencies: [ 34 | "Shwift", 35 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 36 | ]), 37 | 38 | .executableTarget( 39 | name: "ScriptExample", 40 | dependencies: [ 41 | "Script" 42 | ] 43 | ), 44 | .testTarget( 45 | name: "ShwiftTests", 46 | dependencies: ["Shwift"], 47 | resources: [ 48 | .copy("Cat.txt") 49 | ]), 50 | ] 51 | ) 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shwift 2 | 3 | ## Overview 4 | 5 | Shwift is a package which provides tools for shell scripting in Swift. 6 | 7 | For example, you can write the following Swift code to achieve `echo Foo Bar | sed s/Bar/Baz/`: 8 | ```swift 9 | try await echo("Foo", "Bar") | sed("s/Bar/Baz/") 10 | ``` 11 | 12 | While a bit more verbose, this is natively integrated into Swift and utilizes Swift's concurrency APIs. As a result, interacting with command-line tools becomes very natural, and you can do things like `echo("Foo", "Bar") | map { $0.replacingOccurences(of: "Bar", with: "Baz") }`. We've worked very hard to make the performance of `Shwift` analogous with the command line. So if you execute the line `try await cat("/dev/urandom") | xxd() | head("-n2")`, You won't read any more from `/dev/urandom` than if you executed the analogous command in the terminal. 13 | 14 | The `Script` module provides API that is as similar as possible to the terminal, but expressed in Swift. It leverages `swift-argument-parser` and is optimized for writing shell-script-like programs. Here is an example of a simple program you can write (a more detailed example can be found in the [`ScriptExample` target](https://github.com/GeorgeLyon/Shwift/blob/552b32eacbf02a20ae51cae316e47ec4223a2005/Sources/ScriptExample/Main.swift#L29)): 15 | 16 | ```swift 17 | import Script 18 | 19 | @main struct Main: Script { 20 | 21 | func run() async throws { 22 | /** 23 | Declare the executables first so that we fail fast if one is missing. 24 | 25 | We could also instead use the `execute("executable", ...)` form to resolve executables at invocation time. 26 | */ 27 | let echo = try await executable(named: "echo") 28 | 29 | try await echo("Foo", "Bar") | map { $0.replacingOccurrences(of: "Bar", with: "Baz") } 30 | } 31 | 32 | } 33 | ``` 34 | 35 | `Script` is implemented using the `Shwift` module, which implements the core functionality needed to call command-line tools and process their output. This module can be used directly if you want to interact with command-line tools in a more complex program. For example, you could implement a server which may call a command-line tool in response to an HTTP request. 36 | 37 | `Shwift` is more explicit about exactly what is being executed. You have a `Shwift.Context` which you can use to manage the lifetime of resources used by `Shwift` (for example, closing the `Shwift.Context` once it is no longer necessary). It also provides `Builtin`, which is a namespace for core functionality that is used to implement higher level Swift functions for interacting with command line tools in `Script`, like `map` and `reduce`. 38 | 39 | `Shwift` is build on top of `swift-nio` and as a result aims to be completely non-blocking, and thus suitable for use Swift programs which make extensive use of Swift's concurrency features, such as servers. 40 | -------------------------------------------------------------------------------- /Sources/Script/Builtins.swift: -------------------------------------------------------------------------------- 1 | import Shwift 2 | import SystemPackage 3 | 4 | // MARK: - Echo 5 | 6 | /** 7 | Prints a set of items to the specified shell output 8 | 9 | The API is meant to mirror `Swift.print`. 10 | */ 11 | public func echo( 12 | _ items: Any..., 13 | separator: String = " ", 14 | terminator: String = "\n" 15 | ) async throws { 16 | try await echo(items: items, separator: separator, terminator: terminator) 17 | } 18 | 19 | /** 20 | Prints a set of items to the specified shell output 21 | 22 | The API is meant to mirror `Swift.print`. 23 | */ 24 | @_disfavoredOverload 25 | public func echo( 26 | _ items: Any..., 27 | separator: String = " ", 28 | terminator: String = "\n" 29 | ) -> Shell.PipableCommand { 30 | Shell.PipableCommand { 31 | try await echo(items: items, separator: separator, terminator: terminator) 32 | } 33 | } 34 | 35 | private func echo( 36 | items: [Any], 37 | separator: String = " ", 38 | terminator: String = "\n" 39 | ) async throws { 40 | try await Shell.invoke { shell, invocation in 41 | try await invocation.builtin { channel in 42 | try await channel.output.withTextOutputStream { stream in 43 | /// We can't use `print` because it does not accept an array 44 | items 45 | .flatMap { [String(describing: $0), separator] } 46 | .dropLast() 47 | .forEach { stream.write($0) } 48 | stream.write(terminator) 49 | } 50 | } 51 | } 52 | } 53 | 54 | // MARK: - Cat 55 | 56 | /** 57 | Writes the any input to the specified output 58 | */ 59 | public func cat(to output: Output) async throws { 60 | try await Shell.invoke { _, invocation in 61 | try await output.withFileDescriptor(in: invocation.context) { output in 62 | struct Stream: TextOutputStream { 63 | let fileDescriptor: FileDescriptor 64 | var result: Result = .success(()) 65 | mutating func write(_ string: String) { 66 | guard case .success = result else { 67 | return 68 | } 69 | var mutableString = string 70 | do { 71 | _ = try mutableString.withUTF8 { buffer in 72 | try fileDescriptor.write(UnsafeRawBufferPointer(buffer)) 73 | } 74 | } catch { 75 | result = .failure(error) 76 | } 77 | } 78 | } 79 | var stream = Stream(fileDescriptor: output) 80 | try await invocation.builtin { handle in 81 | for try await line in handle.input.lines { 82 | print(line, to: &stream) 83 | try stream.result.get() 84 | } 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Script/Executable.swift: -------------------------------------------------------------------------------- 1 | import Shwift 2 | import SystemPackage 3 | 4 | // MARK: - Resolving Executables 5 | 6 | public func executable(named name: String) async throws -> Executable { 7 | guard let executable = try await executable(named: name, required: false) else { 8 | struct ExecutableNotFound: Error { 9 | let name: String 10 | } 11 | throw ExecutableNotFound(name: name) 12 | } 13 | return executable 14 | } 15 | 16 | /** 17 | - Parameters: 18 | - required: Should only ever be set to `false`, implying the initializer returns `nil` if the specified executable is not found. 19 | */ 20 | public func executable(named name: String, required: Bool) async throws -> Executable? { 21 | precondition(required == false) 22 | let path = Shell.current.environment.searchForExecutables(named: name).matches.first 23 | return path.map(Executable.init) 24 | } 25 | 26 | // MARK: - Invoking Executables by Name 27 | 28 | public func execute(_ executableName: String, _ arguments: String?...) async throws { 29 | try await execute(executableName, arguments: arguments) 30 | } 31 | 32 | public func execute(_ executableName: String, arguments: [String?]) async throws { 33 | try await executable(named: executableName)(arguments: arguments) 34 | } 35 | 36 | @_disfavoredOverload 37 | public func execute( 38 | _ executableName: String, 39 | _ arguments: String?... 40 | ) async throws -> Shell.PipableCommand { 41 | try await execute(executableName, arguments: arguments) 42 | } 43 | 44 | @_disfavoredOverload 45 | public func execute( 46 | _ executableName: String, 47 | arguments: [String?] 48 | ) async throws -> Shell.PipableCommand { 49 | Shell.PipableCommand { 50 | try await execute(executableName, arguments: arguments) 51 | } 52 | } 53 | 54 | // MARK: - Invoking Executables 55 | 56 | public struct Executable { 57 | public let path: FilePath 58 | public init(path: FilePath) { 59 | self.path = path 60 | } 61 | 62 | public func callAsFunction(_ arguments: String?...) async throws { 63 | try await callAsFunction(arguments: arguments) 64 | } 65 | 66 | public func callAsFunction(arguments: [String?]) async throws { 67 | try await Shell.invoke { shell, invocation in 68 | struct Logger: ProcessLogger { 69 | let executable: Executable 70 | let arguments: [String] 71 | let shell: Shell 72 | 73 | func failedToLaunchProcess(dueTo error: Error) { 74 | Shell.scriptForLogging 75 | .didFailToLaunch( 76 | executable, 77 | withArguments: arguments, 78 | in: shell.workingDirectory, 79 | dueTo: error) 80 | } 81 | 82 | func didLaunch(_ process: Process) { 83 | Shell.scriptForLogging 84 | .process( 85 | withID: process.id, 86 | didLaunchWith: executable, 87 | arguments: arguments, 88 | in: shell.workingDirectory) 89 | } 90 | 91 | func willWait(on process: Process) { 92 | 93 | } 94 | 95 | func process(_ process: Process, didTerminateWithError error: Error?) { 96 | Shell.scriptForLogging 97 | .process( 98 | withID: process.id, 99 | for: executable, 100 | withArguments: arguments, 101 | in: shell.workingDirectory, 102 | didComplete: error) 103 | } 104 | } 105 | /** 106 | - note: In shell scripts, specifying an environment variable which is not defined as an argument effectively skips that argument. For instance `echo Foo $NOT_DEFINED Bar` would be analogous to `echo Foo Bar`. We mirror this behavior in Script by allowing arguments to be `nil`. 107 | */ 108 | let arguments = arguments.compactMap { $0 } 109 | Shell.scriptForLogging 110 | .willLaunch( 111 | self, 112 | withArguments: arguments, 113 | in: shell.workingDirectory) 114 | try await Process.run( 115 | executablePath: path, 116 | arguments: arguments.compactMap { $0 }, 117 | environment: shell.environment, 118 | workingDirectory: shell.workingDirectory, 119 | fileDescriptorMapping: .init( 120 | standardInput: invocation.standardInput, 121 | standardOutput: invocation.standardOutput, 122 | standardError: invocation.standardError), 123 | logger: Logger(executable: self, arguments: arguments, shell: shell), 124 | in: invocation.context) 125 | 126 | } 127 | } 128 | 129 | @_disfavoredOverload 130 | public func callAsFunction(_ arguments: String?...) async throws -> Shell.PipableCommand { 131 | Shell.PipableCommand { 132 | try await callAsFunction(arguments: arguments) 133 | } 134 | } 135 | 136 | @_disfavoredOverload 137 | public func callAsFunction(arguments: [String?]) async throws -> Shell.PipableCommand { 138 | Shell.PipableCommand { 139 | try await callAsFunction(arguments: arguments) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/Script/File Operations.swift: -------------------------------------------------------------------------------- 1 | import Shwift 2 | import SystemPackage 3 | @_implementationOnly import Foundation 4 | 5 | // MARK: - Operators 6 | 7 | public func > (source: Shell.PipableCommand, path: FilePath) async throws { 8 | try await source 9 | | Shell.PipableCommand { 10 | try await Shell.invoke { shell, invocation in 11 | let absolutePath = shell.workingDirectory.pushing(path) 12 | try await Builtin.write( 13 | invocation.standardInput, 14 | to: absolutePath, 15 | in: invocation.context) 16 | } 17 | } 18 | } 19 | 20 | @_disfavoredOverload 21 | public func > (source: Shell.PipableCommand, path: FilePath) -> Shell.PipableCommand { 22 | Shell.PipableCommand { 23 | try await source > path 24 | } 25 | } 26 | 27 | public func >> (source: Shell.PipableCommand, path: FilePath) async throws { 28 | try await source 29 | | Shell.PipableCommand { 30 | try await Shell.invoke { shell, invocation in 31 | let absolutePath = shell.workingDirectory.pushing(path) 32 | try await Builtin.write( 33 | invocation.standardInput, 34 | to: absolutePath, 35 | append: true, 36 | in: invocation.context) 37 | } 38 | } 39 | } 40 | 41 | @_disfavoredOverload 42 | public func >> (source: Shell.PipableCommand, path: FilePath) -> Shell.PipableCommand { 43 | Shell.PipableCommand { 44 | try await source >> path 45 | } 46 | } 47 | 48 | public func < (destination: Shell.PipableCommand, path: FilePath) async throws -> T { 49 | try await Shell.PipableCommand { 50 | try await Shell.invoke { shell, invocation in 51 | let absolutePath = shell.workingDirectory.pushing(path) 52 | return try await Builtin.read( 53 | from: absolutePath, 54 | to: invocation.standardOutput, 55 | in: invocation.context) 56 | } 57 | } | destination 58 | } 59 | 60 | @_disfavoredOverload 61 | public func < (destination: Shell.PipableCommand, path: FilePath) -> Shell.PipableCommand { 62 | Shell.PipableCommand { 63 | try await destination < path 64 | } 65 | } 66 | 67 | // MARK: - Functions 68 | 69 | public func contents(of path: FilePath) async throws -> String { 70 | try await outputOf { 71 | try await Shell.invoke { shell, invocation in 72 | let absolutePath = shell.workingDirectory.pushing(path) 73 | return try await Builtin.read( 74 | from: absolutePath, 75 | to: invocation.standardOutput, 76 | in: invocation.context) 77 | } 78 | } 79 | } 80 | 81 | public func write( 82 | _ value: String, 83 | to path: FilePath 84 | ) async throws { 85 | try await echo(value) > path 86 | } 87 | 88 | public func item(at path: FilePath) -> Shell.Item { 89 | Shell.Item(path: Shell.current.workingDirectory.pushing(path)) 90 | } 91 | 92 | extension Shell { 93 | 94 | /** 95 | An item on the file system which may or may not exist 96 | */ 97 | public struct Item { 98 | 99 | public var exists: Bool { 100 | FileManager.default.fileExists(atPath: path.string) 101 | } 102 | 103 | public enum Kind { 104 | case file 105 | case directory 106 | } 107 | public var kind: Kind? { 108 | var isDirectory: ObjCBool = false 109 | if FileManager.default.fileExists(atPath: path.string, isDirectory: &isDirectory) { 110 | if isDirectory.boolValue { 111 | return .directory 112 | } else { 113 | return .file 114 | } 115 | } else { 116 | return nil 117 | } 118 | } 119 | 120 | public func delete() throws { 121 | try FileManager.default.removeItem(atPath: path.string) 122 | } 123 | 124 | public func deleteIfExists() throws { 125 | if exists { 126 | try FileManager.default.removeItem(atPath: path.string) 127 | } 128 | } 129 | 130 | fileprivate let path: FilePath 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Script/List Comprehensions.swift: -------------------------------------------------------------------------------- 1 | import Shwift 2 | 3 | /** 4 | By default, shell output is processed as a list of lines 5 | */ 6 | 7 | public func map(transform: @Sendable @escaping (String) async throws -> String) 8 | -> Shell.PipableCommand 9 | { 10 | compactMap(transform: transform) 11 | } 12 | 13 | public func compactMap(transform: @Sendable @escaping (String) async throws -> String?) 14 | -> Shell.PipableCommand 15 | { 16 | Shell.PipableCommand { 17 | try await Shell.invoke { shell, invocation in 18 | try await invocation.builtin { channel in 19 | for try await line in channel.input.lines.compactMap(transform) { 20 | try await channel.output.withTextOutputStream { stream in 21 | print(line, to: &stream) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | 29 | public func reduce( 30 | into initialResult: T, 31 | _ updateAccumulatingResult: @escaping (inout T, String) async throws -> Void 32 | ) -> Shell.PipableCommand { 33 | Shell.PipableCommand { 34 | try await Shell.invoke { _, invocation in 35 | try await invocation.builtin { channel in 36 | try await channel.input.lines.reduce(into: initialResult, updateAccumulatingResult) 37 | } 38 | } 39 | } 40 | } 41 | 42 | public func reduce( 43 | _ initialResult: T, 44 | _ nextPartialResult: @escaping (T, String) async throws -> T 45 | ) -> Shell.PipableCommand { 46 | Shell.PipableCommand { 47 | try await Shell.invoke { _, invocation in 48 | try await invocation.builtin { channel in 49 | try await channel.input.lines.reduce(initialResult, nextPartialResult) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Script/Output Capture.swift: -------------------------------------------------------------------------------- 1 | public func outputOf(_ operation: @escaping () async throws -> Void) async throws -> String { 2 | let lines = try await Shell.PipableCommand(operation) | reduce(into: []) { $0.append($1) } 3 | return lines.joined(separator: "\n") 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Script/Pipe.swift: -------------------------------------------------------------------------------- 1 | import Shwift 2 | 3 | @discardableResult 4 | public func | ( 5 | source: Shell.PipableCommand, 6 | destination: Shell.PipableCommand 7 | ) async throws -> T { 8 | try await pipe( 9 | .output, 10 | of: { 11 | try? await source.body() 12 | }, 13 | to: destination.body 14 | ).destination 15 | } 16 | 17 | @discardableResult 18 | @_disfavoredOverload 19 | public func | ( 20 | source: Shell.PipableCommand, 21 | destination: Shell.PipableCommand 22 | ) async throws -> Shell.PipableCommand { 23 | Shell.PipableCommand { 24 | try await source | destination 25 | } 26 | } 27 | 28 | /** 29 | Special form of `pipe` which allows piping to a builtin like `map` 30 | */ 31 | public func pipe( 32 | _ outputChannel: Shell.OutputChannel, 33 | of source: () async throws -> SourceOutcome, 34 | to destination: Shell.PipableCommand 35 | ) async throws -> (source: SourceOutcome, destination: DestinationOutcome) { 36 | try await pipe(outputChannel, of: source, to: { try await destination.body() }) 37 | } 38 | 39 | public func pipe( 40 | _ outputChannel: Shell.OutputChannel, 41 | of source: () async throws -> SourceOutcome, 42 | to destination: () async throws -> DestinationOutcome 43 | ) async throws -> (source: SourceOutcome, destination: DestinationOutcome) { 44 | try await Shell.invoke { shell, invocation in 45 | try await Builtin.pipe( 46 | { output in 47 | switch outputChannel { 48 | case .output: 49 | return try await subshell( 50 | standardOutput: .unmanaged(output), 51 | operation: source) 52 | case .error: 53 | return try await subshell( 54 | standardError: .unmanaged(output), 55 | operation: source) 56 | } 57 | }, 58 | to: { input in 59 | try await subshell( 60 | standardInput: .unmanaged(input), 61 | operation: destination) 62 | }) 63 | } 64 | } 65 | 66 | extension Shell { 67 | 68 | public enum OutputChannel { 69 | case output, error 70 | } 71 | 72 | /** 73 | We use this type to work around https://bugs.swift.org/browse/SR-14517 74 | 75 | Instead of having `|` take async autoclosure arguments, we have it take this type, and provide disfavored overloads which create `PipableCommand` for interesting APIs. Some API doesn't really make sense outside of a pipe expression, and we only provide the `PipableCommand` variant for such API. 76 | */ 77 | public struct PipableCommand { 78 | init(_ body: @escaping () async throws -> T) { 79 | self.body = body 80 | } 81 | let body: () async throws -> T 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Sources/Script/Script.swift: -------------------------------------------------------------------------------- 1 | import Shwift 2 | import Dispatch 3 | import SystemPackage 4 | 5 | @_implementationOnly import class Foundation.FileManager 6 | 7 | /** 8 | We consider the following to be part of our public API 9 | */ 10 | @_exported import ArgumentParser 11 | @_exported import struct SystemPackage.FilePath 12 | @_exported import struct Shwift.Environment 13 | @_exported import struct Shwift.Input 14 | @_exported import struct Shwift.Output 15 | @_exported import struct Shwift.Process 16 | 17 | // MARK: - Script 18 | 19 | public protocol Script: ParsableCommand { 20 | func run() async throws 21 | 22 | /** 23 | The top level shell for this script. 24 | This value is read once prior to calling `run` and saved, so the execution of the script will not reflect changes to the root shell that happen after the Script has started running. 25 | 26 | By default, reads the current state of the this process. 27 | */ 28 | var rootShell: Shell { get } 29 | 30 | /** 31 | Scripts can implement to run code before or after the body of the script has run, or post-process any errors encountered 32 | */ 33 | func wrapInvocation( 34 | _ invocation: () async throws -> T 35 | ) async throws -> T 36 | 37 | /** 38 | Called just before we attempt to launch a process. 39 | Can be used for logging. 40 | */ 41 | func willLaunch( 42 | _ executable: Executable, 43 | withArguments arguments: [String], 44 | in workingDirectory: FilePath) 45 | 46 | /** 47 | Called if our attempt to launch an executable failed. 48 | Can be used for logging. 49 | */ 50 | func didFailToLaunch( 51 | _ executable: Executable, 52 | withArguments arguments: [String], 53 | in workingDirectory: FilePath, 54 | dueTo error: Error) 55 | 56 | /** 57 | Called after we have launched a process. 58 | Can be used for logging. 59 | */ 60 | func process( 61 | withID processID: Process.ID, 62 | didLaunchWith executable: Executable, 63 | arguments: [String], 64 | in workingDirectory: FilePath) 65 | 66 | /** 67 | Called after a process has terminated. 68 | Can be used for logging. 69 | */ 70 | func process( 71 | withID processID: Process.ID, 72 | for executable: Executable, 73 | withArguments arguments: [String], 74 | in workingDirectory: FilePath, 75 | didComplete error: Error?) 76 | } 77 | 78 | extension Script { 79 | 80 | public var rootShell: Shell { 81 | Shell( 82 | workingDirectory: FilePath(FileManager.default.currentDirectoryPath), 83 | environment: .process, 84 | standardInput: .standardInput, 85 | standardOutput: .standardOutput, 86 | standardError: .standardError) 87 | } 88 | 89 | public func wrapInvocation( 90 | _ invocation: () async throws -> T 91 | ) async throws -> T { 92 | try await invocation() 93 | } 94 | 95 | } 96 | 97 | // MARK: - Logging Default Implementations 98 | 99 | extension Script { 100 | 101 | /** 102 | Called just before we attempt to launch a process. 103 | Can be used for logging. 104 | */ 105 | public func willLaunch( 106 | _ executable: Executable, 107 | withArguments arguments: [String], 108 | in workingDirectory: FilePath 109 | ) {} 110 | 111 | /** 112 | Called if our attempt to launch an executable failed. 113 | Can be used for logging. 114 | */ 115 | public func didFailToLaunch( 116 | _ executable: Executable, 117 | withArguments arguments: [String], 118 | in workingDirectory: FilePath, 119 | dueTo error: Error 120 | ) {} 121 | 122 | /** 123 | Called after we have launched a process. 124 | Can be used for logging. 125 | */ 126 | public func process( 127 | withID processID: Process.ID, 128 | didLaunchWith executable: Executable, 129 | arguments: [String], 130 | in workingDirectory: FilePath 131 | ) {} 132 | 133 | /** 134 | Called after a process has terminated. 135 | Can be used for logging. 136 | */ 137 | public func process( 138 | withID processID: Process.ID, 139 | for executable: Executable, 140 | withArguments arguments: [String], 141 | in workingDirectory: FilePath, 142 | didComplete error: Error? 143 | ) {} 144 | 145 | } 146 | 147 | // MARK: - Adapter for `ParsableCommand` 148 | 149 | extension Script { 150 | 151 | public func run() throws { 152 | /// Work around for https://forums.swift.org/t/interaction-between-async-main-and-async-overloads/52171 153 | let box = ErrorBox() 154 | let sem = DispatchSemaphore(value: 0) 155 | Task { 156 | defer { sem.signal() } 157 | do { 158 | let shell = self.rootShell 159 | try await Shell.$hostScript.withValue(self) { 160 | try await Shell.$taskLocal.withValue(shell) { 161 | try await self.wrapInvocation { 162 | try await run() 163 | } 164 | } 165 | } 166 | } catch Process.TerminationError.nonzeroTerminationStatus(let status) { 167 | /// Convert `Shell` error into one that `ArgumentParser` understands 168 | box.error = ExitCode(rawValue: status) 169 | } catch let error as SystemPackage.Errno { 170 | /// Convert `SystemPackage` error into one that `ArgumentParser` understands 171 | box.error = ExitCode(rawValue: error.rawValue) 172 | } catch { 173 | box.error = error 174 | } 175 | } 176 | sem.wait() 177 | if let error = box.error { 178 | throw error 179 | } 180 | } 181 | 182 | } 183 | 184 | private final class ErrorBox { 185 | var error: Error? = nil 186 | } 187 | 188 | // MARK: - Shell 189 | 190 | public struct Shell { 191 | 192 | public init( 193 | workingDirectory: FilePath, 194 | environment: Environment, 195 | standardInput: Input, 196 | standardOutput: Output, 197 | standardError: Output 198 | ) { 199 | self.workingDirectory = workingDirectory 200 | self.environment = environment 201 | self.standardInput = standardInput 202 | self.standardOutput = standardOutput 203 | self.standardError = standardError 204 | self.context = Context() 205 | } 206 | 207 | fileprivate(set) var workingDirectory: FilePath 208 | fileprivate(set) var environment: Environment 209 | fileprivate(set) var standardInput: Input 210 | fileprivate(set) var standardOutput: Output 211 | fileprivate(set) var standardError: Output 212 | fileprivate let context: Context 213 | 214 | struct Invocation { 215 | let standardInput: FileDescriptor 216 | let standardOutput: FileDescriptor 217 | let standardError: FileDescriptor 218 | let context: Context 219 | 220 | /** 221 | Convenience for builtin invocations 222 | */ 223 | func builtin( 224 | _ command: (Builtin.Channel) async throws -> T 225 | ) async throws -> T { 226 | try await Builtin.withChannel( 227 | input: standardInput, 228 | output: standardOutput, 229 | in: context, 230 | command) 231 | } 232 | } 233 | 234 | static var current: Shell { taskLocal } 235 | 236 | static var scriptForLogging: Script { hostScript } 237 | 238 | static func invoke( 239 | _ command: (Shell, Invocation) async throws -> T 240 | ) async throws -> T { 241 | let shell: Shell = .taskLocal 242 | return try await shell.standardInput.withFileDescriptor(in: shell.context) { input in 243 | try await shell.standardOutput.withFileDescriptor(in: shell.context) { output in 244 | try await shell.standardError.withFileDescriptor(in: shell.context) { error in 245 | try await command( 246 | shell, 247 | Invocation( 248 | standardInput: input, 249 | standardOutput: output, 250 | standardError: error, 251 | context: shell.context)) 252 | } 253 | } 254 | } 255 | } 256 | 257 | @TaskLocal 258 | fileprivate static var taskLocal: Shell! 259 | 260 | @TaskLocal 261 | fileprivate static var hostScript: Script! 262 | 263 | } 264 | 265 | // MARK: - Subshell 266 | 267 | public func subshell( 268 | pushing path: FilePath? = nil, 269 | updatingEnvironmentWith environmentUpdates: [String: String?] = [:], 270 | standardInput: Input? = nil, 271 | standardOutput: Output? = nil, 272 | standardError: Output? = nil, 273 | operation: () async throws -> T 274 | ) async throws -> T { 275 | var shell: Shell = .current 276 | if let path = path { 277 | shell.workingDirectory.push(path) 278 | } 279 | for (name, value) in environmentUpdates { 280 | shell.environment.setValue(value, forVariableNamed: name) 281 | } 282 | if let standardInput = standardInput { 283 | shell.standardInput = standardInput 284 | } 285 | if let standardOutput = standardOutput { 286 | shell.standardOutput = standardOutput 287 | } 288 | if let standardError = standardError { 289 | shell.standardError = standardError 290 | } 291 | return try await Shell.$taskLocal.withValue(shell, operation: operation) 292 | } 293 | 294 | @_disfavoredOverload 295 | public func subshell( 296 | pushing path: FilePath? = nil, 297 | updatingEnvironmentWith environmentUpdates: [String: String?] = [:], 298 | standardInput: Input? = nil, 299 | standardOutput: Output? = nil, 300 | standardError: Output? = nil, 301 | operation: @escaping () async throws -> T 302 | ) -> Shell.PipableCommand { 303 | Shell.PipableCommand { 304 | try await subshell( 305 | pushing: path, 306 | updatingEnvironmentWith: environmentUpdates, 307 | standardInput: standardInput, 308 | standardOutput: standardOutput, 309 | standardError: standardError, 310 | operation: operation) 311 | } 312 | } 313 | 314 | // MARK: - Shell State 315 | 316 | /** 317 | The current working directory of the current `Script`. 318 | */ 319 | public var workingDirectory: FilePath { 320 | Shell.current.workingDirectory 321 | } 322 | 323 | public var environment: Environment { 324 | Shell.current.environment 325 | } 326 | -------------------------------------------------------------------------------- /Sources/ScriptExample/Main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Script 3 | 4 | @main struct Main: Script { 5 | 6 | enum Test: EnumerableFlag { 7 | case echoToSed 8 | case echoToMap 9 | case countLines 10 | case writeToFile 11 | case infiniteInput 12 | case stressTest 13 | } 14 | @Flag var tests: [Test] = [.echoToSed, .echoToMap, .countLines] 15 | 16 | func run() async throws { 17 | /** 18 | Declare the executables first so that we fail fast if one is missing. 19 | 20 | We could also instead use the `execute("executable", ...)` form to resolve executables at invocation time 21 | */ 22 | let echo = try await executable(named: "echo") 23 | let sed = try await executable(named: "sed") 24 | let cat = try await executable(named: "cat") 25 | let head = try await executable(named: "head") 26 | let xxd = try await executable(named: "xxd", required: false) 27 | 28 | for test in tests { 29 | switch test { 30 | case .echoToSed: 31 | /// Piping between two executables 32 | try await echo("Foo", "Bar") | sed("s/Bar/Baz/") 33 | case .echoToMap: 34 | /// Piping to a builtin 35 | try await echo("Foo", "Bar") | map { $0.replacingOccurrences(of: "Bar", with: "Baz") } 36 | case .countLines: 37 | /// Getting a Swift value from an invocation 38 | let numberOfLines = 39 | try await echo("Foo", "Bar") | reduce(into: 0, { count, _ in count += 1 }) 40 | print(numberOfLines) 41 | case .writeToFile: 42 | try await echo("Foo") > "Test.txt" 43 | case .infiniteInput: 44 | /// Dealing with infinite input (error is ignored because `head` throws `EPIPE`) 45 | try? await cat("/dev/urandom") | xxd!() 46 | | map { line in 47 | "PREFIX: \(line)" 48 | } | head("-n2") 49 | /// Sleep so we can validate memory usage doesn't grow as a result of `cat /dev/urandom` 50 | try await Task.sleep(nanoseconds: 10_000_000_000) 51 | 52 | case .stressTest: 53 | for i in 0..<50_000 { 54 | try await withThrowingTaskGroup(of: (Int, Int).self) { group in 55 | for j in 0..<50 { 56 | print("(i, j) = (\(i), \(j))") 57 | group.addTask { 58 | try await echo("\(i),\(j):", "Foo", "Bar") | sed("s/Bar/Baz/") 59 | | map { $0.replacingOccurrences(of: "Baz", with: "Baz!") } 60 | return (i, j) 61 | } 62 | } 63 | for try await (i, j) in group { 64 | print("completed (\(i), \(j))") 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Shwift/Builtins.swift: -------------------------------------------------------------------------------- 1 | @_implementationOnly import NIO 2 | import SystemPackage 3 | @_implementationOnly import _NIOConcurrency 4 | 5 | /** 6 | A namespace for types involved in executing builtins 7 | */ 8 | public enum Builtin { 9 | 10 | public struct Channel { 11 | public let input: Input 12 | public let output: Output 13 | } 14 | public static func withChannel( 15 | input: SystemPackage.FileDescriptor, 16 | output: SystemPackage.FileDescriptor, 17 | in context: Context, 18 | _ operation: (Channel) async throws -> T, 19 | file: StaticString = #fileID, line: UInt = #line 20 | ) async throws -> T { 21 | let handler = AsyncInboundHandler() 22 | let nioChannel = try await NIOPipeBootstrap(group: context.eventLoopGroup) 23 | .channelOption(ChannelOptions.autoRead, value: false) 24 | .channelOption(ChannelOptions.allowRemoteHalfClosure, value: true) 25 | .channelInitializer { channel in 26 | /** 27 | Theoretically if we add this before a call to `channel.read`, it _should_ receive all data sent on the channel. Unfortunately we ran into a case where on Linux, adding the handler outside of the channel initializer made us miss some data. 28 | */ 29 | return channel.pipeline.addHandler(handler) 30 | } 31 | .duplicating( 32 | inputDescriptor: input, 33 | outputDescriptor: output) 34 | let inputBuffers = 35 | try! handler 36 | .prefix(while: { event in 37 | if case .userInboundEventTriggered(_, ChannelEvent.inputClosed) = event { 38 | return false 39 | } else { 40 | return true 41 | } 42 | }) 43 | .compactMap { event -> ByteBuffer? in 44 | switch event { 45 | case .handlerAdded(let context): 46 | /// Call `read` only if we access the byte buffers 47 | context.eventLoop.execute { 48 | context.read() 49 | } 50 | return nil 51 | case .channelRead(_, let buffer): 52 | return buffer 53 | case .channelReadComplete(let context): 54 | context.eventLoop.execute { 55 | context.read() 56 | } 57 | return nil 58 | default: 59 | return nil 60 | } 61 | } 62 | let channel = Channel( 63 | input: Input(byteBuffers: inputBuffers), 64 | output: Output(channel: nioChannel)) 65 | let result: Result 66 | do { 67 | result = .success(try await operation(channel)) 68 | } catch { 69 | result = .failure(error) 70 | } 71 | do { 72 | try await nioChannel.close() 73 | } catch ChannelError.alreadyClosed { 74 | /** 75 | I'm not sure why, but closing the channel occasionally throws an unexpected `alreadyClosed` error. We should get to the bottom of this, but in the meantime we can suppress this ostensibly benign error. 76 | */ 77 | print("\(file):\(line): Received unexpected alreadyClosed") 78 | } 79 | return try result.get() 80 | } 81 | } 82 | 83 | // MARK: - IO 84 | 85 | extension Builtin { 86 | 87 | public struct Input { 88 | 89 | public struct Lines: AsyncSequence { 90 | public typealias Element = String 91 | 92 | public struct AsyncIterator: AsyncIteratorProtocol { 93 | public mutating func next() async throws -> String? { 94 | try await segments.next() 95 | } 96 | fileprivate var segments: Segments.AsyncIterator 97 | } 98 | public func makeAsyncIterator() -> AsyncIterator { 99 | AsyncIterator(segments: segments.makeAsyncIterator()) 100 | } 101 | 102 | fileprivate init(byteBuffers: ByteBuffers) { 103 | segments = Segments(byteBuffers: byteBuffers, separator: "\n") 104 | } 105 | private let segments: Segments 106 | } 107 | 108 | public struct Segments: AsyncSequence { 109 | public typealias Element = String 110 | 111 | public struct AsyncIterator: AsyncIteratorProtocol { 112 | public mutating func next() async throws -> String? { 113 | try await iterator.next() 114 | } 115 | fileprivate var iterator: AsyncThrowingStream.AsyncIterator 116 | } 117 | public func makeAsyncIterator() -> AsyncIterator { 118 | let stream = AsyncThrowingStream { continuation in 119 | Task { 120 | do { 121 | var remainder: String = "" 122 | for try await buffer in byteBuffers { 123 | let readString = buffer.getString( 124 | at: buffer.readerIndex, 125 | length: buffer.readableBytes)! 126 | var substring = readString[readString.startIndex...] 127 | while let separatorRange = substring.range(of: separator) { 128 | let segment = substring[substring.startIndex.. Segments { 161 | segments(separatedBy: "\(separator)") 162 | } 163 | 164 | /// Segment this input using a separator. 165 | /// 166 | /// - Parameters: 167 | /// - separator: String separating segments of the input. Must not be empty 168 | /// - Returns: Segments segmented by the separator 169 | public func segments(separatedBy separator: String) -> Segments { 170 | Segments(byteBuffers: byteBuffers, separator: separator) 171 | } 172 | typealias ByteBuffers = AsyncCompactMapSequence< 173 | AsyncPrefixWhileSequence>, ByteBuffer 174 | > 175 | let byteBuffers: ByteBuffers 176 | } 177 | 178 | /** 179 | A type which can be used to write to a shell command's standard output or standard error 180 | */ 181 | public struct Output { 182 | 183 | public func withTextOutputStream(_ body: (inout TextOutputStream) -> Void) async throws { 184 | var stream = TextOutputStream(channel: channel) 185 | body(&stream) 186 | channel.flush() 187 | try await stream.lastFuture?.get() 188 | } 189 | 190 | public struct TextOutputStream: Swift.TextOutputStream { 191 | public mutating func write(_ string: String) { 192 | let buffer = channel.allocator.buffer(string: string) 193 | /// This future should implicitly be fulfilled after any previous future 194 | lastFuture = channel.write(NIOAny(buffer)) 195 | } 196 | fileprivate let channel: NIO.Channel 197 | fileprivate var lastFuture: EventLoopFuture? 198 | } 199 | 200 | fileprivate let channel: NIO.Channel 201 | } 202 | 203 | } 204 | 205 | // MARK: - File IO 206 | 207 | /** 208 | The core `Shell` library provides only two builtins: `read` and `write` for reading from and writing to files, respectively. We provide these because they are extremely fundamental functionality for shells and we can implement them using `NIO` without exposing this dependency to higher level frameworks. Other builtins should be implemented in higher level frameworks, like `Script` to avoid having duplicate APIs on `Shell`. 209 | */ 210 | extension Builtin { 211 | 212 | public static func read( 213 | from filePath: FilePath, 214 | to output: SystemPackage.FileDescriptor, 215 | in context: Context 216 | ) async throws { 217 | precondition(filePath.isAbsolute) 218 | try await Shwift.Input.nullDevice.withFileDescriptor(in: context) { nullDeviceInput in 219 | try await withChannel(input: nullDeviceInput, output: output, in: context) { channel in 220 | let output = channel.output 221 | let eventLoop = output.channel.eventLoop 222 | let fileHandle = try await context.fileIO.openFile( 223 | path: filePath.string, 224 | mode: .read, 225 | eventLoop: eventLoop 226 | ) 227 | .get() 228 | let result: Result 229 | do { 230 | result = .success( 231 | try await context.fileIO 232 | .readChunked( 233 | fileHandle: fileHandle, 234 | byteCount: .max, 235 | allocator: output.channel.allocator, 236 | eventLoop: eventLoop, 237 | chunkHandler: { buffer in 238 | output.channel.writeAndFlush(buffer) 239 | } 240 | ) 241 | .get()) 242 | } catch { 243 | result = .failure(error) 244 | } 245 | try fileHandle.close() 246 | try result.get() 247 | } 248 | } 249 | } 250 | 251 | /** 252 | Write this shell's `input` to the specified file, creating it if necessary. 253 | */ 254 | public static func write( 255 | _ input: SystemPackage.FileDescriptor, 256 | to filePath: FilePath, 257 | append: Bool = false, 258 | in context: Context 259 | ) async throws { 260 | precondition(filePath.isAbsolute) 261 | try await Shwift.Output.nullDevice.withFileDescriptor(in: context) { nullDeviceOutput in 262 | try await withChannel(input: input, output: nullDeviceOutput, in: context) { channel in 263 | let eventLoop = context.eventLoopGroup.next() 264 | let fileHandle = try await context.fileIO.openFile( 265 | path: filePath.string, 266 | mode: .write, 267 | flags: .posix( 268 | flags: O_CREAT | (append ? O_APPEND : O_TRUNC), 269 | mode: S_IWUSR | S_IRUSR | S_IRGRP | S_IROTH), 270 | eventLoop: eventLoop 271 | ) 272 | .get() 273 | let result: Result 274 | do { 275 | for try await buffer in channel.input.byteBuffers { 276 | try await context.fileIO.write( 277 | fileHandle: fileHandle, 278 | buffer: buffer, 279 | eventLoop: eventLoop 280 | ) 281 | .get() 282 | } 283 | result = .success(()) 284 | } catch { 285 | result = .failure(error) 286 | } 287 | try fileHandle.close() 288 | try result.get() 289 | } 290 | } 291 | } 292 | 293 | } 294 | -------------------------------------------------------------------------------- /Sources/Shwift/Context.swift: -------------------------------------------------------------------------------- 1 | @_implementationOnly import NIO 2 | 3 | import SystemPackage 4 | 5 | /** 6 | An object which manages the lifetime of resources required for non-blocking execution of `Shwift` operations. This includes an event loop and threads for nonblocking file IO. 7 | 8 | - note: `Context` shuts down asynchronously, so resources may not be immediately freed when this object is deinitialized (though this should happen quickly). 9 | */ 10 | public final class Context { 11 | let eventLoopGroup: EventLoopGroup 12 | let fileIO: NonBlockingFileIO 13 | private let threadPool: NIOThreadPool 14 | private let nullOutputDevice = ChannelOutputDevice(handler: NullDeviceHandler()) 15 | private let fatalOutputDevice = ChannelOutputDevice(handler: FatalDeviceHandler()) 16 | 17 | public init() { 18 | eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 19 | threadPool = NIOThreadPool(numberOfThreads: 6) 20 | threadPool.start() 21 | fileIO = NonBlockingFileIO(threadPool: threadPool) 22 | } 23 | deinit { 24 | eventLoopGroup.shutdownGracefully { error in 25 | precondition(error == nil) 26 | } 27 | threadPool.shutdownGracefully { error in 28 | precondition(error == nil) 29 | } 30 | } 31 | 32 | /** 33 | Creates a file descriptor representing a null output device which is valid for the duration of `operation` 34 | - note: In actuality, the null device is implemented as a pipe which discards anything written to it's write end. The problem with using something like `FileDescriptor.open("/dev/null", .writeOnly)` is that NIO on Linux uses `epoll` to read from file descriptors, and `epoll` is incompatible with `/dev/null`. 35 | */ 36 | func withNullOutputDevice( 37 | _ operation: (SystemPackage.FileDescriptor) async throws -> T 38 | ) async throws -> T { 39 | /** 40 | The file descriptor is guaranteed to be valid since we maintain a strong reference to `nullOutputDevice` for the duration of `operation`. 41 | */ 42 | return try await operation(nullOutputDevice.fileDescriptor(with: eventLoopGroup)) 43 | } 44 | 45 | /** 46 | Creates a file descriptor which will call `fatalError` if any output is written to it. This descriptor is valid for the duration of `operation`. 47 | */ 48 | func withFatalOutputDevice( 49 | _ operation: (SystemPackage.FileDescriptor) async throws -> T 50 | ) async throws -> T { 51 | /** 52 | The file descriptor is guaranteed to be valid since we maintain a strong reference to `fatalOutputDevice` for the duration of `operation`. 53 | */ 54 | return try await operation(fatalOutputDevice.fileDescriptor(with: eventLoopGroup)) 55 | } 56 | } 57 | 58 | // MARK: - Support 59 | 60 | private final class NullDeviceHandler: ChannelInboundHandler { 61 | typealias InboundIn = ByteBuffer 62 | func channelRead(context: ChannelHandlerContext, data: NIOAny) { 63 | /// Ignore 64 | } 65 | } 66 | 67 | private final class FatalDeviceHandler: ChannelInboundHandler { 68 | typealias InboundIn = ByteBuffer 69 | func channelRead(context: ChannelHandlerContext, data: NIOAny) { 70 | fatalError(String(buffer: unwrapInboundIn(data))) 71 | } 72 | } 73 | 74 | private actor ChannelOutputDevice { 75 | init(handler: Handler) { 76 | self.handler = handler 77 | } 78 | let handler: Handler 79 | 80 | /** 81 | - Returns: A file descriptor which is guaranteed to be valid as long as the callee is valid 82 | */ 83 | func fileDescriptor(with group: EventLoopGroup) async throws -> SystemPackage.FileDescriptor { 84 | if let (_, fileDescriptor) = state { 85 | return fileDescriptor 86 | } else { 87 | let channel: Channel 88 | let fileDescriptor: SystemPackage.FileDescriptor 89 | (channel, fileDescriptor) = try await SystemPackage.FileDescriptor.withPipe { 90 | [handler] pipe in 91 | let channel = try await NIOPipeBootstrap(group: group) 92 | .channelInitializer { channel in 93 | return channel.pipeline.addHandler(handler) 94 | } 95 | .duplicating( 96 | inputDescriptor: pipe.readEnd, 97 | /** 98 | We use the write end of the pipe because we need to specify _something_ as the channel output. This file descriptor should never be written to. 99 | */ 100 | outputDescriptor: pipe.writeEnd) 101 | let fileDescriptor = try pipe.writeEnd.duplicate() 102 | return (channel, fileDescriptor) 103 | } 104 | state = (channel, fileDescriptor) 105 | return fileDescriptor 106 | } 107 | } 108 | 109 | deinit { 110 | if let (_, fileDescriptor) = state { 111 | try! fileDescriptor.close() 112 | /// `channel` should be automatically closed by the event loop group being closed 113 | } 114 | } 115 | 116 | private var state: (channel: Channel, fileDescriptor: SystemPackage.FileDescriptor)? 117 | } 118 | -------------------------------------------------------------------------------- /Sources/Shwift/Environment.swift: -------------------------------------------------------------------------------- 1 | import SystemPackage 2 | 3 | @_implementationOnly import Foundation 4 | 5 | /** 6 | A type representing the enviornment variables associated with a shell command 7 | */ 8 | public struct Environment: ExpressibleByDictionaryLiteral { 9 | 10 | /** 11 | Create an empty environment. 12 | */ 13 | public init() { 14 | entries = [] 15 | } 16 | 17 | public init(dictionaryLiteral elements: (String, String)...) { 18 | entries = elements.map { Entry(string: "\($0.0)=\($0.1)") } 19 | } 20 | 21 | /** 22 | The environment associated with the current process at this moment in time. Modification to the process environment will not be reflected in this value once it is created. 23 | */ 24 | public static var process: Environment { 25 | var environment = Environment() 26 | var entry = environ 27 | while let cString = entry.pointee { 28 | defer { entry = entry.advanced(by: 1) } 29 | environment.entries.append(Entry(string: String(cString: cString))) 30 | } 31 | return environment 32 | } 33 | 34 | /** 35 | Update a variable in the environment. 36 | - Parameters: 37 | - name: The name of the variable to be updated 38 | - value: The new string value of the variable, or `nil` indicating that the entry for this variable should be removed 39 | */ 40 | public mutating func setValue(_ value: String?, forVariableNamed name: String) { 41 | self[name] = value 42 | } 43 | 44 | subscript(name: String) -> String? { 45 | get { 46 | for entry in entries { 47 | let components = entry.components 48 | if components.name == name { 49 | return String(components.value) 50 | } 51 | } 52 | return nil 53 | } 54 | set { 55 | let index = entries.firstIndex(where: { $0.components.name == name }) 56 | if let newValue = newValue { 57 | let entry = Entry(string: "\(name)=\(newValue)") 58 | if let index = index { 59 | entries[index] = entry 60 | } else { 61 | entries.append(entry) 62 | } 63 | } else if let index = index { 64 | entries.remove(at: index) 65 | } 66 | } 67 | } 68 | 69 | var strings: [String] { entries.map(\.string) } 70 | 71 | private init(entries: [Entry]) { 72 | self.entries = entries 73 | } 74 | private struct Entry { 75 | var components: (name: Substring, value: Substring) { 76 | let index = string.firstIndex(of: "=") ?? string.endIndex 77 | let name = string[string.startIndex.. SearchResults { 144 | let fileManager = FileManager.default 145 | var results = SearchResults() 146 | for searchPath in searchPaths { 147 | guard searchPath.isAbsolute else { 148 | results.log.append((searchPath, .pathIsNotAbsolute)) 149 | continue 150 | } 151 | 152 | let candidates: [URL] 153 | do { 154 | candidates = 155 | try fileManager 156 | .contentsOfDirectory( 157 | at: URL(fileURLWithPath: searchPath.string), 158 | includingPropertiesForKeys: [.isExecutableKey], 159 | options: .skipsSubdirectoryDescendants) 160 | } catch { 161 | results.log.append((searchPath, .encountered(error))) 162 | continue 163 | } 164 | 165 | for candidate in candidates { 166 | let filePath = FilePath(candidate.path) 167 | do { 168 | guard candidate.lastPathComponent == name else { 169 | continue 170 | } 171 | guard try candidate.resourceValues(forKeys: [.isExecutableKey]).isExecutable! else { 172 | results.log.append((filePath, .candidateIsNotExecuable)) 173 | continue 174 | } 175 | results.log.append((filePath, .found)) 176 | } catch { 177 | results.log.append((filePath, .encountered(error))) 178 | } 179 | } 180 | } 181 | return results 182 | } 183 | 184 | var searchPaths: [FilePath] { 185 | self["PATH"]? 186 | .components(separatedBy: ":") 187 | .map(FilePath.init(_:)) ?? [] 188 | } 189 | 190 | } 191 | -------------------------------------------------------------------------------- /Sources/Shwift/IO.swift: -------------------------------------------------------------------------------- 1 | import SystemPackage 2 | 3 | /** 4 | A value representing the input to a shell command 5 | */ 6 | public struct Input { 7 | 8 | /** 9 | An `Input` corresponding to `stdin` 10 | */ 11 | public static let standardInput = Input(kind: .standardInput) 12 | 13 | /** 14 | An input roughly analogous to using the null device (`/dev/null`). 15 | */ 16 | public static let nullDevice = Input(kind: .nullDevice) 17 | 18 | /** 19 | An input backed by an unmanaged file descriptor. It is the caller's responsibility to ensure that this file descriptor remains valid for as long as this input is in use. 20 | */ 21 | public static func unmanaged(_ fileDescriptor: SystemPackage.FileDescriptor) -> Input { 22 | Input(kind: .unmanaged(fileDescriptor)) 23 | } 24 | 25 | /** 26 | Creates a file descriptor for this `Input` which will be valid for the duration of `operation`. 27 | */ 28 | public func withFileDescriptor( 29 | in context: Context, 30 | _ operation: (SystemPackage.FileDescriptor) async throws -> T 31 | ) async throws -> T { 32 | switch kind { 33 | case .standardInput: 34 | return try await operation(.standardInput) 35 | case .nullDevice: 36 | /** 37 | - note: In actuality, this is implemented as a half-closed pipe. The problem with using something like `FileDescriptor.open("/dev/null", .readOnly)` is that NIO on Linux uses `epoll` to read from file descriptors, and `epoll` is not compatible with `/dev/null`. We use NIO to implement builtins, so we need this descriptor to be compatible with that implementation. 38 | */ 39 | let pipe = try FileDescriptor.pipe() 40 | do { 41 | try pipe.writeEnd.close() 42 | } catch { 43 | try! pipe.readEnd.close() 44 | throw error 45 | } 46 | return try await pipe.readEnd.closeAfter { 47 | try await operation(pipe.readEnd) 48 | } 49 | case .unmanaged(let fileDescriptor): 50 | return try await operation(fileDescriptor) 51 | } 52 | } 53 | 54 | fileprivate enum Kind { 55 | case standardInput 56 | case nullDevice 57 | case unmanaged(FileDescriptor) 58 | } 59 | fileprivate let kind: Kind 60 | } 61 | 62 | /** 63 | A value representing the output of a shell command 64 | */ 65 | public struct Output { 66 | 67 | /** 68 | An `Output` correpsonding to `stdout` 69 | */ 70 | public static let standardOutput = Output(kind: .standardOutput) 71 | 72 | /** 73 | An `Output` correpsonding to `stderr` 74 | */ 75 | public static let standardError = Output(kind: .standardError) 76 | 77 | /** 78 | An `Output` correpsonding to a null output device which drops any output it receives 79 | */ 80 | public static let nullDevice = Output(kind: .nullDevice) 81 | 82 | /** 83 | A special `Output` which aborts if any input is read. 84 | */ 85 | public static let fatalDevice = Output(kind: .fatalDevice) 86 | 87 | /** 88 | An output which records to a specified `Recording` 89 | */ 90 | public static func record(to recording: Recorder.Recording) -> Output { 91 | Output(kind: .recording(recording)) 92 | } 93 | 94 | /** 95 | A type which records the output of a shell command and can distinguish between standard output and standard error 96 | */ 97 | public actor Recorder { 98 | 99 | public init() {} 100 | 101 | public func write(to stream: inout T) async { 102 | for (_, buffer) in strings { 103 | buffer.write(to: &stream) 104 | } 105 | } 106 | 107 | /** 108 | A specialized value for recording output to a recorder 109 | */ 110 | public struct Recording { 111 | public func write(to stream: inout T) async { 112 | for (source, buffer) in await recorder.strings { 113 | if source == self.source { 114 | buffer.write(to: &stream) 115 | } 116 | } 117 | } 118 | 119 | fileprivate let recorder: Recorder 120 | fileprivate let source: Source 121 | } 122 | 123 | /** 124 | A `Recording` which records output to this recorder (simulating `stdout`) 125 | */ 126 | public var output: Recording { Recording(recorder: self, source: .output) } 127 | 128 | /** 129 | A `Recording` which records errors to this recorder (siulating `stderr`) 130 | */ 131 | public var error: Recording { Recording(recorder: self, source: .error) } 132 | 133 | /** 134 | Record data to the specific source 135 | */ 136 | public func record(_ string: String, from source: Source) { 137 | strings.append((source, string)) 138 | } 139 | 140 | /** 141 | Which source to record data to 142 | */ 143 | public enum Source { 144 | case output, error 145 | } 146 | fileprivate var strings: [(Source, String)] = [] 147 | } 148 | 149 | /** 150 | An output backed by an unmanaged file descriptor. It is the caller's responsibility to ensure that this file descriptor remains valid for as long as this input is in use. 151 | */ 152 | public static func unmanaged(_ fileDescriptor: SystemPackage.FileDescriptor) -> Output { 153 | Output(kind: .unmanaged(fileDescriptor)) 154 | } 155 | 156 | /** 157 | Creates a file decriptor representing this output which will be valid for the duration of `operation` 158 | - Parameters: 159 | - Context: The context to use to create the file descriptor 160 | */ 161 | public func withFileDescriptor( 162 | in context: Context, 163 | _ operation: (SystemPackage.FileDescriptor) async throws -> T 164 | ) async throws -> T { 165 | switch kind { 166 | case .standardOutput: 167 | return try await operation(.standardOutput) 168 | case .standardError: 169 | return try await operation(.standardError) 170 | case .nullDevice: 171 | return try await context.withNullOutputDevice(operation) 172 | case .fatalDevice: 173 | return try await context.withFatalOutputDevice(operation) 174 | case .recording(let recording): 175 | return try await Builtin.pipe( 176 | operation, 177 | to: { input in 178 | try await context.withNullOutputDevice { output in 179 | try await Builtin.withChannel(input: input, output: output, in: context) { channel in 180 | for try await buffer in channel.input.byteBuffers { 181 | await recording.recorder.record(String(buffer: buffer), from: recording.source) 182 | } 183 | } 184 | } 185 | } 186 | ).source 187 | case .unmanaged(let fileDescriptor): 188 | return try await operation(fileDescriptor) 189 | } 190 | } 191 | 192 | fileprivate enum Kind { 193 | case standardOutput 194 | case standardError 195 | case nullDevice 196 | case fatalDevice 197 | case unmanaged(FileDescriptor) 198 | case recording(Recorder.Recording) 199 | } 200 | fileprivate let kind: Kind 201 | } 202 | -------------------------------------------------------------------------------- /Sources/Shwift/Pipe.swift: -------------------------------------------------------------------------------- 1 | import SystemPackage 2 | 3 | extension Builtin { 4 | 5 | /** 6 | Creates two file descriptors which are valid for the duration of `source` and `destination`, respectively. Data written to `source` will be readable from `destination` just like a unix pipe. 7 | */ 8 | public static func pipe( 9 | _ source: (FileDescriptor) async throws -> SourceOutcome, 10 | to destination: (FileDescriptor) async throws -> DestinationOutcome 11 | ) async throws -> (source: SourceOutcome, destination: DestinationOutcome) { 12 | let pipe = try FileDescriptor.pipe() 13 | 14 | async let sourceOutcome: SourceOutcome = { 15 | defer { try! pipe.writeEnd.close() } 16 | return try await source(pipe.writeEnd) 17 | }() 18 | 19 | async let destinationOutcome: DestinationOutcome = { 20 | defer { try! pipe.readEnd.close() } 21 | return try await destination(pipe.readEnd) 22 | }() 23 | 24 | let sourceResult: Result 25 | do { 26 | sourceResult = .success(try await sourceOutcome) 27 | } catch { 28 | sourceResult = .failure(error) 29 | } 30 | 31 | let destinationResult: Result 32 | do { 33 | destinationResult = .success(try await destinationOutcome) 34 | } catch { 35 | destinationResult = .failure(error) 36 | } 37 | 38 | /// We use results to ensure we wait on both tasks even if one throws 39 | return (try sourceResult.get(), try destinationResult.get()) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Shwift/Process.swift: -------------------------------------------------------------------------------- 1 | @_implementationOnly import NIO 2 | import SystemPackage 3 | 4 | /** 5 | A value which represents a child process 6 | */ 7 | public struct Process { 8 | 9 | /** 10 | Runs an executable in a separate process, returns once that process terminates. 11 | */ 12 | public static func run( 13 | executablePath: FilePath, 14 | arguments: [String], 15 | environment: Environment, 16 | workingDirectory: FilePath, 17 | fileDescriptorMapping: FileDescriptorMapping, 18 | logger: ProcessLogger? = nil, 19 | in context: Context 20 | ) async throws { 21 | try await launch( 22 | executablePath: executablePath, 23 | arguments: arguments, 24 | environment: environment, 25 | workingDirectory: workingDirectory, 26 | fileDescriptorMapping: fileDescriptorMapping, 27 | logger: logger, 28 | in: context 29 | ) 30 | .value 31 | } 32 | 33 | /** 34 | Runs an executable in a separate process, and returns once that process has been launched. 35 | - Returns: A task which represents the running of the external process. 36 | */ 37 | public static func launch( 38 | executablePath: FilePath, 39 | arguments: [String], 40 | environment: Environment, 41 | workingDirectory: FilePath, 42 | fileDescriptorMapping: FileDescriptorMapping, 43 | logger: ProcessLogger? = nil, 44 | in context: Context 45 | ) async throws -> Task { 46 | let process: Process 47 | let monitor: FileDescriptorMonitor 48 | (process, monitor) = try await FileDescriptorMonitor.create(in: context) { 49 | monitoredDescriptor in 50 | do { 51 | var fileDescriptorMapping = fileDescriptorMapping 52 | /// Map the monitored descriptor to the lowest unmapped target descriptor 53 | let mappedFileDescriptors = Set(fileDescriptorMapping.entries.map(\.target)) 54 | fileDescriptorMapping.addMapping( 55 | from: monitoredDescriptor, 56 | to: (0...).first(where: { !mappedFileDescriptors.contains($0) })!) 57 | let process = try await Process( 58 | executablePath: executablePath, 59 | arguments: arguments, 60 | environment: environment, 61 | workingDirectory: workingDirectory, 62 | fileDescriptorMapping: fileDescriptorMapping, 63 | context: context) 64 | logger?.didLaunch(process) 65 | return process 66 | } catch { 67 | logger?.failedToLaunchProcess(dueTo: error) 68 | throw error 69 | } 70 | } 71 | return Task { 72 | try await withTaskCancellationHandler { 73 | try await monitor.wait() 74 | logger?.willWait(on: process) 75 | do { 76 | try await process.wait(in: context) 77 | logger?.process(process, didTerminateWithError: nil) 78 | } catch { 79 | logger?.process(process, didTerminateWithError: error) 80 | throw error 81 | } 82 | } onCancel: { 83 | process.terminate() 84 | } 85 | } 86 | } 87 | 88 | private init( 89 | executablePath: FilePath, 90 | arguments: [String], 91 | environment: Environment, 92 | workingDirectory: FilePath, 93 | fileDescriptorMapping: FileDescriptorMapping, 94 | context: Context 95 | ) async throws { 96 | var attributes = try PosixSpawn.Attributes() 97 | defer { try! attributes.destroy() } 98 | #if canImport(Darwin) 99 | try attributes.setFlags([ 100 | .closeFileDescriptorsByDefault, 101 | .setSignalMask, 102 | ]) 103 | #elseif canImport(Glibc) 104 | /// Linux does not support `closeFileDescriptorsByDefault`, so we emulate it below 105 | try attributes.setFlags([ 106 | .setSignalMask 107 | ]) 108 | #else 109 | #error("Unsupported Platform") 110 | #endif 111 | try attributes.setBlockedSignals(to: .none) 112 | 113 | var actions = try PosixSpawn.FileActions() 114 | defer { try! actions.destroy() } 115 | try actions.addChangeDirectory(to: workingDirectory) 116 | 117 | for entry in fileDescriptorMapping.entries { 118 | try actions.addDuplicate(entry.source, as: entry.target) 119 | } 120 | 121 | #if canImport(Darwin) 122 | /// Darwin support `closeFileDescriptorsByDefault`, so no need to emulate it 123 | #elseif canImport(Glibc) 124 | /// In order to emulate `POSIX_SPAWN_CLOEXEC_DEFAULT`, we use `posix_spawn_file_actions_addclosefrom_np`. 125 | /// This only works if passed a lower bound file descriptor, so this emulation only works if the file descriptors we are mapping have contiguous values starting at 0. 126 | /// Instead of supporting the general case (which is unlikely) we enforce that fileDescriptorMapping provides us our descriptors in order starting at 0. 127 | var availableFileDescriptors = (CInt(0)...).makeIterator() 128 | for (_, target) in fileDescriptorMapping.entries { 129 | guard target == availableFileDescriptors.next() else { 130 | /// `ENOSYS` seems like a good error to throw here: 131 | /// Reference: https://github.com/apple/swift-tools-support-core/blob/main/Sources/TSCclibc/process.c 132 | throw Errno(rawValue: ENOSYS) 133 | } 134 | } 135 | guard let lowestFileDescriptorValueToClose = availableFileDescriptors.next() else { 136 | throw Errno(rawValue: ENOSYS) 137 | } 138 | try actions.addCloseFileDescriptors(from: lowestFileDescriptorValueToClose) 139 | #else 140 | #error("Unsupported Platform") 141 | #endif 142 | 143 | id = ID( 144 | rawValue: try PosixSpawn.spawn( 145 | executablePath, 146 | arguments: [executablePath.string] + arguments, 147 | environment: environment.strings, 148 | fileActions: &actions, 149 | attributes: &attributes))! 150 | } 151 | 152 | private func terminate() { 153 | let returnValue = kill(id.rawValue, SIGTERM) 154 | assert(returnValue == 0) 155 | } 156 | 157 | /** 158 | Waits on the process. This call is nonblocking and expects that the process represented by `processID` has already terminated 159 | */ 160 | private func wait(in context: Context) async throws { 161 | /// Some key paths are different on Linux and macOS 162 | #if canImport(Darwin) 163 | let pid = \siginfo_t.si_pid 164 | let sigchldInfo = \siginfo_t.self 165 | let killingSignal = \siginfo_t.si_status 166 | #elseif canImport(Glibc) 167 | let pid = \siginfo_t._sifields._sigchld.si_pid 168 | let sigchldInfo = \siginfo_t._sifields._sigchld 169 | let killingSignal = \siginfo_t._sifields._rt.si_sigval.sival_int 170 | #else 171 | #error("Unsupported Platform") 172 | #endif 173 | 174 | var info = siginfo_t() 175 | while true { 176 | /** 177 | We use a process ID of `0` to detect the case when the child is not in a waitable state. 178 | Since we use the control channel to detect termination, this _shouldn't_ happen (unless the child decides to call `close(3)` for some reason). 179 | */ 180 | info[keyPath: pid] = 0 181 | do { 182 | errno = 0 183 | var flags = WEXITED | WNOHANG 184 | #if canImport(Glibc) 185 | flags |= __WALL 186 | #endif 187 | let returnValue = waitid(P_PID, id_t(id.rawValue), &info, flags) 188 | guard returnValue == 0 else { 189 | throw TerminationError.waitFailed(returnValue: returnValue, errno: errno) 190 | } 191 | } 192 | /** 193 | By monitoring a file descriptor to detect when a process has terminated, we introduce the possibility of performing a nonblocking wait on a process before it is actually ready to be waited on. This can happen if we win the race with the kernel setting the child process into a waitable state after the kernel closes the file descriptor we are monitoring (this is rare, but has been observed and should only ever result in a 1 second delay). This could also be caused by unusual behavior in the child process (for instance, iterating over all of its own descriptors and closing the ones it doesn't know about, including the one we use for monitoring; in this case the overhead of polling should still be minimal). 194 | */ 195 | guard info[keyPath: pid] != 0 else { 196 | /// Reset `info` 197 | info = siginfo_t() 198 | /// Wait for 1 second (we can't use `Task.sleep` because we want to wait on the child process even if it was cancelled) 199 | let _: Void = await withCheckedContinuation { continuation in 200 | context.eventLoopGroup.next().scheduleTask(in: .seconds(1)) { 201 | continuation.resume() 202 | } 203 | } 204 | /// Try `wait` again 205 | continue 206 | } 207 | /// If we reached this point, the process was successfully waited on 208 | break 209 | } 210 | 211 | /** 212 | If the task has been cancelled, we want cancellation to supercede the temination status of the executable (often a SIGTERM). 213 | */ 214 | try Task.checkCancellation() 215 | 216 | switch Int(info.si_code) { 217 | case Int(CLD_EXITED): 218 | let terminationStatus = info[keyPath: sigchldInfo].si_status 219 | guard terminationStatus == 0 else { 220 | throw TerminationError.nonzeroTerminationStatus(terminationStatus) 221 | } 222 | case Int(CLD_KILLED): 223 | throw TerminationError.uncaughtSignal(info[keyPath: killingSignal], coreDumped: false) 224 | case Int(CLD_DUMPED): 225 | throw TerminationError.uncaughtSignal(info[keyPath: killingSignal], coreDumped: true) 226 | default: 227 | fatalError() 228 | } 229 | } 230 | 231 | public struct ID: CustomStringConvertible { 232 | init?(rawValue: pid_t) { 233 | guard rawValue != -1 else { 234 | return nil 235 | } 236 | self.rawValue = rawValue 237 | } 238 | let rawValue: pid_t 239 | 240 | public var description: String { rawValue.description } 241 | } 242 | public let id: ID 243 | } 244 | 245 | // MARK: - Logging 246 | 247 | public protocol ProcessLogger { 248 | func failedToLaunchProcess(dueTo error: Error) 249 | func didLaunch(_ process: Process) 250 | func willWait(on process: Process) 251 | func process(_ process: Process, didTerminateWithError: Error?) 252 | } 253 | 254 | // MARK: - File Descriptor Mapping 255 | 256 | public extension Process { 257 | 258 | struct FileDescriptorMapping: ExpressibleByDictionaryLiteral { 259 | 260 | public init() { 261 | self.init(entries: []) 262 | } 263 | 264 | public init( 265 | standardInput: SystemPackage.FileDescriptor, 266 | standardOutput: SystemPackage.FileDescriptor, 267 | standardError: SystemPackage.FileDescriptor, 268 | additionalFileDescriptors: KeyValuePairs = [:] 269 | ) { 270 | self.init( 271 | entries: [ 272 | (source: standardInput, target: STDIN_FILENO), 273 | (source: standardOutput, target: STDOUT_FILENO), 274 | (source: standardError, target: STDERR_FILENO), 275 | ] + additionalFileDescriptors.map { (source: $0.value, target: $0.key) }) 276 | } 277 | 278 | public init(dictionaryLiteral elements: (CInt, SystemPackage.FileDescriptor)...) { 279 | self.init(entries: elements.map { (source: $0.1, target: $0.0) }) 280 | } 281 | 282 | private init(entries: [Entry]) { 283 | /// Ensure each file descriptor is only mapped to once 284 | precondition(Set(entries.map(\.target)).count == entries.count) 285 | self.entries = entries 286 | } 287 | 288 | public mutating func addMapping( 289 | from source: SystemPackage.FileDescriptor, 290 | to target: CInt 291 | ) { 292 | precondition(!entries.contains(where: { $0.target == target })) 293 | entries.append((source: source, target: target)) 294 | } 295 | 296 | fileprivate typealias Entry = (source: SystemPackage.FileDescriptor, target: CInt) 297 | fileprivate private(set) var entries: [Entry] 298 | } 299 | 300 | } 301 | 302 | // MARK: - Errors 303 | 304 | extension Process { 305 | 306 | struct SpawnError: Swift.Error { 307 | let file: String 308 | let line: Int 309 | let returnValue: Int 310 | let errorNumber: CInt 311 | } 312 | 313 | /** 314 | An error which caused a spawned process to terminate 315 | */ 316 | public enum TerminationError: Swift.Error { 317 | /** 318 | Waiting on the process failed. 319 | */ 320 | case waitFailed(returnValue: CInt, errno: CInt) 321 | 322 | /** 323 | The process terminated successfully, but the termination status was nonzero. 324 | */ 325 | case nonzeroTerminationStatus(CInt) 326 | 327 | /** 328 | The process terminated due to an uncaught signal. 329 | */ 330 | case uncaughtSignal(CInt, coreDumped: Bool) 331 | } 332 | 333 | } 334 | 335 | // MARK: - File Descriptor Monitor 336 | 337 | private struct FileDescriptorMonitor { 338 | 339 | static func create( 340 | in context: Context, 341 | _ forwardMonitoredDescriptor: (SystemPackage.FileDescriptor) async throws -> T 342 | ) async throws -> (outcome: T, monitor: FileDescriptorMonitor) { 343 | let future: EventLoopFuture 344 | let outcome: T 345 | (future, outcome) = try await FileDescriptor.withPipe { pipe in 346 | let channel = try await context.withNullOutputDevice { nullOutput in 347 | try await NIOPipeBootstrap(group: context.eventLoopGroup) 348 | .channelInitializer { channel in 349 | channel.pipeline.addHandler(Handler()) 350 | } 351 | .duplicating( 352 | inputDescriptor: pipe.readEnd, 353 | outputDescriptor: nullOutput) 354 | } 355 | let outcome = try await forwardMonitoredDescriptor(pipe.writeEnd) 356 | return (channel.closeFuture, outcome) 357 | } 358 | return (outcome, FileDescriptorMonitor(future: future)) 359 | } 360 | 361 | func wait() async throws { 362 | try await future.get() 363 | } 364 | 365 | private let future: EventLoopFuture 366 | 367 | private final class Handler: ChannelInboundHandler { 368 | typealias InboundIn = ByteBuffer 369 | func channelRead(context: ChannelHandlerContext, data: NIOAny) { 370 | /** 371 | Writing data on the monitor descriptor is probably an error. In the future we might want to make incoming data cancel the invocation. 372 | */ 373 | assertionFailure() 374 | } 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /Sources/Shwift/Support/Async Inbound Handler.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public final class AsyncInboundHandler: ChannelInboundHandler, AsyncSequence { 4 | 5 | public typealias BufferingPolicy = AsyncStream.Continuation.BufferingPolicy 6 | public init(bufferingPolicy: BufferingPolicy = .unbounded) { 7 | var continuation: AsyncStream.Continuation! 8 | stream = AsyncStream(bufferingPolicy: bufferingPolicy) { 9 | continuation = $0 10 | } 11 | self.continuation = continuation 12 | } 13 | private func yield(_ event: Element) { 14 | continuation.yield(event) 15 | } 16 | 17 | public struct AsyncIterator: AsyncIteratorProtocol { 18 | public mutating func next() async throws -> Element? { 19 | await wrapped.next() 20 | } 21 | fileprivate var wrapped: AsyncStream.AsyncIterator 22 | } 23 | public func makeAsyncIterator() -> AsyncIterator { 24 | AsyncIterator(wrapped: stream.makeAsyncIterator()) 25 | } 26 | 27 | private let stream: AsyncStream 28 | private let continuation: AsyncStream.Continuation 29 | } 30 | 31 | // MARK: - Event Forwarding 32 | 33 | extension AsyncInboundHandler { 34 | public enum Element { 35 | case handlerAdded(ChannelHandlerContext) 36 | case handlerRemoved(ChannelHandlerContext) 37 | case channelRead(ChannelHandlerContext, InboundIn) 38 | case channelReadComplete(ChannelHandlerContext) 39 | case errorCaught(ChannelHandlerContext, Swift.Error) 40 | case userInboundEventTriggered(ChannelHandlerContext, Any) 41 | } 42 | 43 | public func handlerAdded(context: ChannelHandlerContext) { 44 | yield(.handlerAdded(context)) 45 | } 46 | 47 | public func handlerRemoved(context: ChannelHandlerContext) { 48 | yield(.handlerRemoved(context)) 49 | } 50 | 51 | public func channelRead(context: ChannelHandlerContext, data: NIOAny) { 52 | yield(.channelRead(context, unwrapInboundIn(data))) 53 | } 54 | 55 | public func channelReadComplete(context: ChannelHandlerContext) { 56 | yield(.channelReadComplete(context)) 57 | } 58 | 59 | public func errorCaught(context: ChannelHandlerContext, error: Swift.Error) { 60 | yield(.errorCaught(context, error)) 61 | } 62 | 63 | public func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { 64 | yield(.userInboundEventTriggered(context, event)) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Shwift/Support/File Descriptor.swift: -------------------------------------------------------------------------------- 1 | import SystemPackage 2 | 3 | #if canImport(Glibc) 4 | import Glibc 5 | #endif 6 | 7 | extension FileDescriptor { 8 | 9 | static func withPipe( 10 | _ operation: ((readEnd: FileDescriptor, writeEnd: FileDescriptor)) async throws -> T 11 | ) async throws -> T { 12 | let pipe = try Self.pipe() 13 | return try await pipe.writeEnd.closeAfter { 14 | try await pipe.readEnd.closeAfter { 15 | try await operation((readEnd: pipe.readEnd, writeEnd: pipe.writeEnd)) 16 | } 17 | } 18 | } 19 | 20 | func closeAfter( 21 | _ operation: () async throws -> T 22 | ) async throws -> T { 23 | do { 24 | let outcome = try await operation() 25 | try close() 26 | return outcome 27 | } catch { 28 | try! close() 29 | throw error 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Shwift/Support/NIO Pipe Bootstrap.swift: -------------------------------------------------------------------------------- 1 | @_implementationOnly import NIO 2 | import SystemPackage 3 | 4 | extension NIOPipeBootstrap { 5 | 6 | /** 7 | Duplicates the provided file descriptors and creates a channel with the specified input and output. If creating the channel fails, both duplicate descriptors are closed. The caller is responsible for ensuring `inputDescriptor` and `outputDescriptor` are closed. 8 | */ 9 | func duplicating( 10 | inputDescriptor: SystemPackage.FileDescriptor, 11 | outputDescriptor: SystemPackage.FileDescriptor 12 | ) async throws -> Channel { 13 | let input = try inputDescriptor.duplicate() 14 | do { 15 | let output = try outputDescriptor.duplicate() 16 | do { 17 | return try await withPipes( 18 | inputDescriptor: input.rawValue, 19 | outputDescriptor: output.rawValue 20 | ) 21 | .get() 22 | /** 23 | On success, there is no need to close `input` and `output` as they are now owned by the channel 24 | */ 25 | } catch { 26 | try! output.close() 27 | throw error 28 | } 29 | } catch { 30 | try! input.close() 31 | throw error 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Shwift/Support/Posix Spawn.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Darwin) 2 | import Darwin 3 | 4 | /// Optionality of some times does not align in Darwin vs Glibc, so we make a typealias to allow us to refer to them consistently. 5 | private typealias PlatformType = U? 6 | private extension PlatformType { 7 | init() { 8 | self = nil 9 | } 10 | } 11 | #elseif canImport(Glibc) 12 | import Glibc 13 | 14 | private typealias PlatformType = U 15 | #else 16 | #error("Unsupported Platform") 17 | #endif 18 | 19 | import SystemPackage 20 | 21 | enum PosixSpawn { 22 | 23 | struct Flags: OptionSet { 24 | 25 | #if canImport(Darwin) 26 | static let closeFileDescriptorsByDefault = Flags(rawValue: POSIX_SPAWN_CLOEXEC_DEFAULT) 27 | #endif 28 | 29 | static let setSignalMask = Flags(rawValue: POSIX_SPAWN_SETSIGMASK) 30 | 31 | init(rawValue: Int32) { 32 | self.rawValue = rawValue 33 | } 34 | var rawValue: Int32 35 | } 36 | 37 | struct Attributes { 38 | 39 | init() throws { 40 | try Errno.check(posix_spawnattr_init(&rawValue)) 41 | } 42 | 43 | mutating func destroy() throws { 44 | try Errno.check(posix_spawnattr_destroy(&rawValue)) 45 | } 46 | 47 | mutating func setBlockedSignals(to signals: SignalSet) throws { 48 | try Errno.check( 49 | withUnsafePointer(to: signals.rawValue) { signals in 50 | posix_spawnattr_setsigmask(&rawValue, signals) 51 | }) 52 | } 53 | 54 | mutating func setFlags(_ flags: Flags) throws { 55 | try Errno.check(posix_spawnattr_setflags(&rawValue, Int16(flags.rawValue))) 56 | } 57 | 58 | fileprivate var rawValue: PlatformType = .init() 59 | } 60 | 61 | struct FileActions { 62 | 63 | init() throws { 64 | try Errno.check(posix_spawn_file_actions_init(&rawValue)) 65 | } 66 | 67 | mutating func destroy() throws { 68 | try Errno.check(posix_spawn_file_actions_destroy(&rawValue)) 69 | } 70 | 71 | mutating func addChangeDirectory(to filePath: FilePath) throws { 72 | try Errno.check( 73 | filePath.withPlatformString { 74 | posix_spawn_file_actions_addchdir_np(&rawValue, $0) 75 | }) 76 | } 77 | 78 | #if canImport(Glibc) 79 | mutating func addCloseFileDescriptors(from lowestFileDescriptorValueToClose: Int32) throws { 80 | try Errno.check( 81 | posix_spawn_file_actions_addclosefrom_np(&rawValue, lowestFileDescriptorValueToClose) 82 | ) 83 | } 84 | #endif 85 | 86 | mutating func addCloseFileDescriptor(_ value: Int32) throws { 87 | try Errno.check( 88 | posix_spawn_file_actions_addclose(&rawValue, value) 89 | ) 90 | } 91 | 92 | mutating func addDuplicate(_ source: FileDescriptor, as target: CInt) throws { 93 | try Errno.check(posix_spawn_file_actions_adddup2(&rawValue, source.rawValue, target)) 94 | } 95 | 96 | fileprivate var rawValue: PlatformType = .init() 97 | } 98 | 99 | public static func spawn( 100 | _ path: FilePath, 101 | arguments: [String], 102 | environment: [String], 103 | fileActions: inout FileActions, 104 | attributes: inout Attributes 105 | ) throws -> pid_t { 106 | var pid = pid_t() 107 | 108 | /// I'm not aware of a way to pass a string containing `NUL` to `posix_spawn` 109 | for string in [arguments, environment].flatMap({ $0 }) { 110 | if string.contains("\0") { 111 | throw InvalidParameter(parameter: string, issue: "contains NUL character") 112 | } 113 | } 114 | 115 | let cArguments = arguments.map { $0.withCString(strdup)! } 116 | defer { cArguments.forEach { $0.deallocate() } } 117 | let cEnvironment = environment.map { $0.withCString(strdup)! } 118 | defer { cEnvironment.forEach { $0.deallocate() } } 119 | 120 | try path.withPlatformString { path in 121 | try Errno.check( 122 | posix_spawn( 123 | &pid, 124 | path, 125 | &fileActions.rawValue, 126 | &attributes.rawValue, 127 | cArguments + [nil], 128 | cEnvironment + [nil])) 129 | } 130 | 131 | return pid 132 | } 133 | 134 | } 135 | 136 | // MARK: - Signals 137 | 138 | struct SignalSet { 139 | 140 | static var all: Self { 141 | get throws { 142 | try Self(sigfillset) 143 | } 144 | } 145 | 146 | static var none: Self { 147 | get throws { 148 | try Self(sigemptyset) 149 | } 150 | } 151 | 152 | private init(_ fn: (UnsafeMutablePointer) -> CInt) throws { 153 | rawValue = sigset_t() 154 | try Errno.check(fn(&rawValue)) 155 | } 156 | var rawValue: sigset_t 157 | } 158 | 159 | // MARK: - Support 160 | 161 | private extension Errno { 162 | static func check(_ value: CInt) throws { 163 | guard value == 0 else { 164 | throw Errno(rawValue: value) 165 | } 166 | } 167 | } 168 | 169 | private struct InvalidParameter: Error { 170 | let parameter: String 171 | let issue: String 172 | } 173 | -------------------------------------------------------------------------------- /Tests/ShwiftTests/Cat.txt: -------------------------------------------------------------------------------- 1 | Cat 2 | -------------------------------------------------------------------------------- /Tests/ShwiftTests/Recorder Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Shwift 3 | 4 | final class RecorderTests: XCTestCase { 5 | 6 | func testRecorder() async throws { 7 | let recorder = Shwift.Output.Recorder() 8 | await recorder.record("Output\n", from: .output) 9 | await recorder.record("Error\n", from: .error) 10 | var output = "" 11 | await recorder.output.write(to: &output) 12 | var error = "" 13 | await recorder.error.write(to: &error) 14 | var joined = "" 15 | await recorder.write(to: &joined) 16 | XCTAssertEqual("Output\n", output) 17 | XCTAssertEqual("Error\n", error) 18 | XCTAssertEqual("Output\nError\n", joined) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Tests/ShwiftTests/Shwift Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SystemPackage 3 | @testable import Shwift 4 | 5 | final class ShwiftCoreTests: XCTestCase { 6 | 7 | func testExecutable() async throws { 8 | try await XCTAssertOutput( 9 | of: { context, standardOutput in 10 | try await Process.run("echo", "Echo", standardOutput: standardOutput, in: context) 11 | }, 12 | is: """ 13 | Echo 14 | """) 15 | 16 | try await XCTAssertOutput( 17 | of: { context, standardOutput in 18 | try await Process.run( 19 | "cat", Self.supportFilePath, standardOutput: standardOutput, in: context) 20 | }, 21 | is: """ 22 | Cat 23 | """) 24 | 25 | try await XCTAssertOutput( 26 | of: { context, standardOutput in 27 | try await Process.run("echo", "Echo", standardOutput: standardOutput, in: context) 28 | try await Process.run( 29 | "cat", Self.supportFilePath, standardOutput: standardOutput, in: context) 30 | }, 31 | is: """ 32 | Echo 33 | Cat 34 | """) 35 | } 36 | 37 | func testFailure() async throws { 38 | try await XCTAssertOutput( 39 | of: { context, _ in 40 | try await Process.run("false", in: context) 41 | }, 42 | is: .failure) 43 | } 44 | 45 | func testExecutablePipe() async throws { 46 | try await XCTAssertOutput( 47 | of: { context, output in 48 | try await Builtin.pipe( 49 | { output in 50 | try await Process.run("echo", "Foo", standardOutput: output, in: context) 51 | }, 52 | to: { input in 53 | try await Process.run( 54 | "sed", "s/Foo/Bar/", standardInput: input, standardOutput: output, in: context) 55 | } 56 | ).destination 57 | }, 58 | is: """ 59 | Bar 60 | """) 61 | } 62 | 63 | func testInputSegmentation() async throws { 64 | try await XCTAssertOutput( 65 | of: { context, output in 66 | try await Builtin.pipe( 67 | { output in 68 | try await Process.run("echo", "-n", "Foo;-Bar;Baz", standardOutput: output, in: context) 69 | }, 70 | to: { input in 71 | try await Builtin.withChannel(input: input, output: output, in: context) { channel in 72 | for try await segment in channel.input.segments(separatedBy: ";-") { 73 | try await channel.output.withTextOutputStream { stream in 74 | print(segment, to: &stream) 75 | } 76 | } 77 | } 78 | } 79 | ).destination 80 | }, 81 | is: """ 82 | Foo 83 | Bar;Baz 84 | """) 85 | } 86 | 87 | func testBuiltinOutput() async throws { 88 | try await XCTAssertOutput( 89 | of: { context, output in 90 | try await Input.nullDevice.withFileDescriptor(in: context) { input in 91 | try await Builtin.withChannel(input: input, output: output, in: context) { channel in 92 | try await channel.output.withTextOutputStream { stream in 93 | print("Builtin \("(interpolated)")", to: &stream) 94 | } 95 | } 96 | } 97 | }, 98 | is: """ 99 | Builtin (interpolated) 100 | """) 101 | } 102 | 103 | func testReadFromFile() async throws { 104 | try await XCTAssertOutput( 105 | of: { context, output in 106 | try await Builtin.read(from: FilePath(Self.supportFilePath), to: output, in: context) 107 | }, 108 | is: """ 109 | Cat 110 | """) 111 | } 112 | 113 | private enum Outcome: ExpressibleByStringInterpolation { 114 | init(stringLiteral value: String) { 115 | self = .success(value) 116 | } 117 | case success(String) 118 | case failure 119 | } 120 | 121 | private func XCTAssertOutput( 122 | of operation: @escaping (Context, FileDescriptor) async throws -> Void, 123 | is expectedOutcome: Outcome, 124 | file: StaticString = #file, line: UInt = #line, 125 | function: StaticString = #function 126 | ) async throws { 127 | let e1 = expectation(description: "\(function):\(line)-operation") 128 | let e2 = expectation(description: "\(function):\(line)-gather") 129 | let context = Context() 130 | do { 131 | let output: String = try await Builtin.pipe( 132 | { output in 133 | defer { 134 | e1.fulfill() 135 | } 136 | try await operation(context, output) 137 | }, 138 | to: { input in 139 | defer { 140 | e2.fulfill() 141 | } 142 | do { 143 | return try await Output.nullDevice.withFileDescriptor(in: context) { output in 144 | try await Builtin.withChannel(input: input, output: output, in: context) { 145 | channel in 146 | let x = try await channel.input.lines 147 | .reduce(into: [], { $0.append($1) }) 148 | .joined(separator: "\n") 149 | return x 150 | } 151 | } 152 | } catch { 153 | XCTFail(file: file, line: line) 154 | throw error 155 | } 156 | } 157 | ) 158 | .destination 159 | switch expectedOutcome { 160 | case .success(let expected): 161 | XCTAssertEqual( 162 | output, 163 | expected, 164 | file: file, line: line) 165 | case .failure: 166 | XCTFail("Succeeded when expecting failure", file: file, line: line) 167 | } 168 | } catch { 169 | switch expectedOutcome { 170 | case .success: 171 | throw error 172 | case .failure: 173 | /// Failure was expected 174 | break 175 | } 176 | } 177 | await fulfillment(of: [e1, e2], timeout: 2) 178 | } 179 | 180 | private static let supportFilePath = Bundle.module.path(forResource: "Cat", ofType: "txt")! 181 | } 182 | 183 | private extension Shwift.Process { 184 | static let environment: Environment = .process 185 | static func run( 186 | _ executableName: String, 187 | _ arguments: String..., 188 | standardInput: FileDescriptor? = nil, 189 | standardOutput: FileDescriptor? = nil, 190 | in context: Context 191 | ) async throws { 192 | try await Input.nullDevice.withFileDescriptor(in: context) { inputNullDevice in 193 | try await Output.nullDevice.withFileDescriptor(in: context) { outputNullDevice in 194 | let fileDescriptorMapping: FileDescriptorMapping = [ 195 | STDIN_FILENO: standardInput ?? inputNullDevice, 196 | STDOUT_FILENO: standardOutput ?? outputNullDevice, 197 | ] 198 | try await run( 199 | executablePath: environment.searchForExecutables(named: executableName).matches.first!, 200 | arguments: arguments, 201 | environment: [:], 202 | workingDirectory: FilePath(FileManager.default.currentDirectoryPath), 203 | fileDescriptorMapping: fileDescriptorMapping, 204 | in: context) 205 | } 206 | } 207 | } 208 | } 209 | --------------------------------------------------------------------------------