jakeheis / SwiftCLI

A powerful framework for developing CLIs in Swift
MIT License
861 stars 72 forks source link

Add support for redirecting captured output as it's being written #88

Closed msanders closed 4 years ago

msanders commented 4 years ago

This allows printing or redirecting output as it's being captured, analogous to the tee command. For example:

NSUnbufferedIO=YES softwareupdate --list | tee /dev/tty | grep -Fq $needle

Can be written as:

let result = try Task.capture(
    "softwareupdate",
    arguments: ["--list"],
    outputStream: Term.stdout,
    env: [
        "NSUnbufferedIO": "YES",
    ]
)

if result.output.contains(needle) { /* ... */ }

Currently to do this without a fork requires re-implementing Task.capture, CaptureStream, CaptureResult, and CaptureError.

I've also added support for passing in forwardInterrupt and env properties as arguments, can open a separate PR for that if necessary.

For CaptureStream, I used a block instead of another WritableStream parameter as that was more consistent with the surrounding code and similar to the readabilityHandler block on FileHandle.

jakeheis commented 4 years ago

I'm inclined to think that this might be better implemented as more general SplitStream that would also allow output to be written to multiple files at once. Something like:

public class SplitStream: WritableStream {

    public let writeHandle: FileHandle
    public let processObject: Any
    public var encoding: String.Encoding = .utf8

    private let queue = DispatchQueue(label: "com.jakeheis.SwiftCLI.SplitStream")
    private let semaphore = DispatchSemaphore(value: 0)

    public init(streams: [WritableStream]) {
        let pipe = Pipe()
        self.processObject = pipe
        self.writeHandle = pipe.fileHandleForWriting

        let readStream = ReadStream.for(fileHandle: pipe.fileHandleForReading)
        queue.async { [weak self] in
            while let data = readStream.readData() {
                streams.forEach { $0.writeData(data) }
            }
            self?.semaphore.signal()
        }
    }

    public convenience init(_ streams: WritableStream...) {
        self.init(streams: streams)
    }

}

// In use:

let capture = CaptureStream()
let result = Task(
    executable: "/bin/ls",
    arguments: ["."],
    stdout: SplitStream(Term.stdout, capture)
).runSync()

Would this accomplish what you're talking about? I think capture with the additional arguments you've added could be implemented with this sort of split stream

msanders commented 4 years ago

Thanks! That does make this simpler to implement, although unfortunately still requires duplicating an implementation of CaptureResult and CaptureError to take care of error handling. Would it be possible to make those initializers public instead, or is there something that would fit better with the existing API?

jakeheis commented 4 years ago

I think making the initializers public is probably the best call:

public struct CaptureResult {
     public init(stdout: CaptureStream, stderr: CaptureStream) { ... }
}
public struct CaptureError: ProcessError {
     public init(exitStatus: Int32, captured: CaptureResult) { ... }
}

// In use

let stdoutCap = CaptureStream()
let stderrCap = CaptureStream()
let result = Task(
    executable: "/bin/ls",
    arguments: ["."],
    stdout: SplitStream(Term.stdout, stdoutCap),
    stderr: stderrCap
).runSync()
// Build CaptureResult and CaptureError

Would something like that work for you?

msanders commented 4 years ago

Thanks, that does solve my use-case. Updated PR with proposed changes.

jakeheis commented 4 years ago

Left a few comments about conforming SplitStream to ProcessingStream, otherwise looks good!

msanders commented 4 years ago

Thanks, done.

jakeheis commented 4 years ago

Looks great, thank you for the contribution!

jakeheis commented 4 years ago

6.0.2