diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a809c41..a586a2a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: image: swift:6.3.3 steps: - uses: actions/checkout@v7 - - run: swift test + - run: swift test --traits EnableSubprocess Lint: runs-on: macOS-latest steps: diff --git a/.gitignore b/.gitignore index 0d97eff..9e071f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,23 +5,6 @@ ## User settings xcuserdata/ -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) -build/ -DerivedData/ -*.moved-aside -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 - ## Obj-C/Swift specific *.hmap @@ -39,7 +22,7 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins -# Package.resolved +Package.resolved # *.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata diff --git a/Makefile b/Makefile index 2b8400f..f8fa340 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ test: CONTAINER ?= container test-linux: - $(CONTAINER) run --rm -t --init -v "$(PWD):/src" -w /src swift:latest swift test + $(CONTAINER) run --rm -t --init -v "$(PWD):/src" -w /src swift:latest swift test --traits EnableSubprocess # MARK: - format diff --git a/Package.swift b/Package.swift index ddd3cbd..27daa57 100644 --- a/Package.swift +++ b/Package.swift @@ -14,19 +14,35 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "XSTooling", platforms: [ - .macOS(.v10_15) + .macOS(.v13) ], products: [ .library( name: "XSTooling", targets: ["XSTooling"]) ], - dependencies: [], + traits: [ + .trait( + name: "EnableSubprocess", + description: "Enable swift-subprocess dependency" + ) + // .default(enabledTraits: ["EnableSubprocess"]), // Local Development + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "1.0.0") + ], targets: [ .target( name: "XSTooling", - dependencies: [], - swiftSettings: swiftSettings), + dependencies: [ + .product( + name: "Subprocess", + package: "swift-subprocess", + condition: .when(traits: ["EnableSubprocess"]) + ) + ], + swiftSettings: swiftSettings, + ), .testTarget( name: "XSToolingTests", dependencies: ["XSTooling"], diff --git a/Sources/XSTooling/ProcessCommand+Process.swift b/Sources/XSTooling/ProcessCommand+Process.swift new file mode 100644 index 0000000..066efff --- /dev/null +++ b/Sources/XSTooling/ProcessCommand+Process.swift @@ -0,0 +1,91 @@ +#if !EnableSubprocess + +import Foundation + +extension ProcessCommand { + + func readProcess(standardError: FileHandle?) async throws -> ProcessOutput { + let pipe = Pipe() + let standardError: Any? = (standardError == FileHandle.standardOutput) ? pipe : standardError + async let output = pipe.fileHandleForReading.stream().reduce(into: Data()) { $0.append($1) } + try await runProcess(standardOutput: pipe, standardError: standardError) + let data = await output + return ProcessOutput(data: data) + } + + func runProcess(standardOutput: Any?, standardError: Any?) async throws { + let process = try makeProcess(standardOutput: standardOutput, standardError: standardError) + try await process.perform() + if process.terminationStatus != 0 { + throw ProcessError( + executableURL: executableURL, + arguments: arguments, + terminationStatus: process.terminationStatus, + terminationReason: process.terminationReason + ) + } + } + + private func makeProcess(standardOutput: Any?, standardError: Any?) throws -> Process { + let process = Process() + process.executableURL = executableURL + process.currentDirectoryURL = currentDirectoryURL + process.arguments = arguments + if let environment { + process.environment = environment + } + if let standardOutput { + process.standardOutput = standardOutput + } + if let standardError { + process.standardError = standardError + } + return process + } +} + +extension Process { + fileprivate func perform() async throws { + try Task.checkCancellation() + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.terminationHandler = { process in + process.terminationHandler = nil + continuation.resume() + } + do { + try self.run() + } catch { + self.terminationHandler = nil + continuation.resume(throwing: error) + } + } + } onCancel: { // can be canceled without starting + if self.isRunning { + self.terminate() // crash if not running + } + } + try Task.checkCancellation() + } +} + +extension FileHandle { + fileprivate func stream() -> AsyncStream { + AsyncStream { continuation in + continuation.onTermination = { [weak self] _ in + self?.readabilityHandler = nil // stop + } + self.readabilityHandler = { fileHandle in + let data = fileHandle.availableData + if data.isEmpty { + continuation.finish() + } else { + continuation.yield(data) + } + } + } + } +} + +#endif // !EnableSubprocess diff --git a/Sources/XSTooling/ProcessCommand+Subprocess.swift b/Sources/XSTooling/ProcessCommand+Subprocess.swift new file mode 100644 index 0000000..adfe5a2 --- /dev/null +++ b/Sources/XSTooling/ProcessCommand+Subprocess.swift @@ -0,0 +1,109 @@ +#if EnableSubprocess +public import Subprocess + +import Foundation + +#if canImport(System) +import System +#else +import SystemPackage +#endif + +extension ProcessCommand { + + public var subprocessConfiguration: Subprocess::Configuration { + Subprocess::Configuration( + executable: .path(FilePath(self.executableURL.path)), + arguments: Arguments(self.arguments), + environment: self.subprocessEnvironment, + workingDirectory: self.currentDirectoryURL.map { FilePath($0.path) }, + ) + } + + private var subprocessEnvironment: Subprocess::Environment { + guard let environment else { + return .inherit + } + let dict = environment.reduce( + into: [Environment.Key: String](minimumCapacity: environment.count), + { dict, element in + if let key = Environment.Key(rawValue: element.key) { + dict[key] = element.value + } + }) + return .custom(dict) + } + + func readSubprocess(standardError: FileHandle?, limit: Int) async throws -> ProcessOutput { + let values: (terminationStatus: TerminationStatus, standardOutput: Data) + if standardError == FileHandle.standardOutput { + let result = try await Subprocess::run( + self.subprocessConfiguration, + output: .data(limit: limit), + error: .combinedWithOutput, + ) + values = (result.terminationStatus, result.standardOutput) + } else { + let result = try await Subprocess::run( + self.subprocessConfiguration, + output: .data(limit: limit), + error: standardError?.fileDescriptorOutput ?? .currentStandardError, + ) + values = (result.terminationStatus, result.standardOutput) + } + try check(terminationStatus: values.terminationStatus) + return ProcessOutput(data: values.standardOutput) + } + + func runSubprocess( + standardOutput: FileHandle?, + standardError: FileHandle?, + ) async throws { + let result = try await Subprocess::run( + self.subprocessConfiguration, + output: standardOutput?.fileDescriptorOutput ?? .currentStandardOutput, + error: standardError?.fileDescriptorOutput ?? .currentStandardError, + ) + try check(terminationStatus: result.terminationStatus) + } + + private func check(terminationStatus: TerminationStatus) throws { + try Task.checkCancellation() + if terminationStatus.isSuccess { + return + } + throw ProcessError( + executableURL: executableURL, + arguments: arguments, + terminationStatus: terminationStatus.terminationCode, + terminationReason: terminationStatus.terminationReason, + ) + } +} + +extension TerminationStatus { + + fileprivate var terminationCode: Code { + switch self { + case .exited(let code), .signaled(let code): code + } + } + + fileprivate var terminationReason: Process.TerminationReason { + switch self { + case .exited: .exit + case .signaled: .uncaughtSignal + } + } +} + +extension FileHandle { + fileprivate var fileDescriptorOutput: FileDescriptorOutput { + .fileDescriptor( + FileDescriptor(rawValue: self.fileDescriptor), + closeAfterSpawningProcess: false + ) + } +} + +#endif // EnableSubprocess diff --git a/Sources/XSTooling/ProcessCommand.swift b/Sources/XSTooling/ProcessCommand.swift index c453093..0a5ad12 100644 --- a/Sources/XSTooling/ProcessCommand.swift +++ b/Sources/XSTooling/ProcessCommand.swift @@ -72,48 +72,19 @@ public struct ProcessCommand: Hashable, Sendable { // MARK: - Running public func read(standardError: FileHandle? = nil) async throws -> ProcessOutput { - let pipe = Pipe() - let standardError: Any? = (standardError == FileHandle.standardOutput) ? pipe : standardError - async let output = pipe.fileHandleForReading.stream().reduce(into: Data()) { $0.append($1) } - try await runProcess(standardOutput: pipe, standardError: standardError) - let data = await output - return ProcessOutput(data: data) + #if EnableSubprocess + try await readSubprocess(standardError: standardError, limit: Int.max) + #else + try await readProcess(standardError: standardError) + #endif } public func run(standardOutput: FileHandle? = nil, standardError: FileHandle? = nil) async throws { + #if EnableSubprocess + try await runSubprocess(standardOutput: standardOutput, standardError: standardError) + #else try await runProcess(standardOutput: standardOutput, standardError: standardError) - } - - // MARK: - Private - - private func runProcess(standardOutput: Any?, standardError: Any?) async throws { - let process = try makeProcess(standardOutput: standardOutput, standardError: standardError) - try await process.execute() - if process.terminationStatus != 0 { - throw ProcessError( - executableURL: executableURL, - arguments: arguments, - terminationStatus: process.terminationStatus, - terminationReason: process.terminationReason - ) - } - } - - private func makeProcess(standardOutput: Any?, standardError: Any?) throws -> Process { - let process = Process() - process.executableURL = executableURL - process.currentDirectoryURL = currentDirectoryURL - process.arguments = arguments - if let environment = environment { - process.environment = environment - } - if let standardOutput { - process.standardOutput = standardOutput - } - if let standardError { - process.standardError = standardError - } - return process + #endif } }