Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 1 addition & 18 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 20 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
91 changes: 91 additions & 0 deletions Sources/XSTooling/ProcessCommand+Process.swift
Original file line number Diff line number Diff line change
@@ -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<Data> {
AsyncStream<Data> { 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
109 changes: 109 additions & 0 deletions Sources/XSTooling/ProcessCommand+Subprocess.swift
Original file line number Diff line number Diff line change
@@ -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
47 changes: 9 additions & 38 deletions Sources/XSTooling/ProcessCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Loading