Files

167 lines
6.4 KiB
Swift

/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Foundation
/// A command-line tool that exited with a non-zero status.
struct ShellError: Error, CustomStringConvertible {
var commandLine: String
var exitCode: Int32
var standardError: String
var description: String {
"`\(commandLine)` failed with exit code \(exitCode):\n\(standardError)"
}
}
/// Runs `tool` (looked up on `PATH`) with `arguments` and returns its standard output.
@discardableResult
func shell(_ tool: String, _ arguments: [String],
in directory: Path? = nil) async throws -> String {
let result = try await runTool(tool, arguments, in: directory)
guard result.status == 0 else {
throw ShellError(commandLine: commandLine(tool, arguments), exitCode: result.status,
standardError: result.standardError)
}
return result.standardOutput
}
/// `tool` and `arguments` as a shell command line you can copy and run: arguments with spaces or
/// other special characters are single-quoted.
func commandLine(_ tool: String, _ arguments: [String]) -> String {
([tool] + arguments).map(shellQuoted).joined(separator: " ")
}
/// `argument`, single-quoted when the shell would otherwise split or expand it.
func shellQuoted(_ argument: String) -> String {
let plain = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_./=:,+@%"))
let isPlain = !argument.isEmpty && argument.unicodeScalars.allSatisfy(plain.contains)
if isPlain {
return argument
}
// Inside single quotes nothing is special except `'` itself: close the quote, add an escaped
// quote, and reopen (`it's` → `'it'\''s'`).
return "'" + argument.replacingOccurrences(of: "'", with: #"'\''"#) + "'"
}
/// The command line `runTool` logs with `--verbose`, including its working directory and log
/// file: `cd <directory> && <tool> <arguments> > <log file> 2>&1`.
func displayedCommandLine(_ tool: String, _ arguments: [String], in directory: Path?,
logFile: Path?) -> String {
var line = commandLine(tool, arguments)
if let directory {
line = "cd \(shellQuoted(directory)) && " + line
}
if let logFile {
line += " > \(shellQuoted(logFile)) 2>&1"
}
return line
}
/// A finished tool's exit status and output.
struct ToolResult: Sendable {
var status: Int32
var standardOutput: String
var standardError: String
}
/// Runs `tool` like `shell`, but returns a non-zero status instead of throwing, for checks where
/// failing is a result. With `logFile`, both streams are written to that file instead of being
/// kept, so the returned output is empty.
///
/// With `--verbose`, the command line is logged first, as a shell command you can copy and run.
///
/// It waits for the exit with `terminationHandler`: `waitUntilExit` hung when many tools ran at
/// once from Swift concurrency.
func runTool(_ tool: String, _ arguments: [String], in directory: Path? = nil,
logFile: Path? = nil) async throws -> ToolResult {
logger.debug("Running: \(displayedCommandLine(tool, arguments, in: directory, logFile: logFile))")
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = [tool] + arguments
if let directory {
process.currentDirectoryURL = URL(fileURLWithPath: directory)
}
let outputPipe = Pipe(), errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
let log = try logFile.map(LogFile.init)
async let stdout = readAll(outputPipe, into: log)
async let stderr = readAll(errorPipe, into: log)
let status: Int32
do {
status = try await withCheckedThrowingContinuation { continuation in
process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) }
do { try process.run() } catch { continuation.resume(throwing: error) }
}
} catch {
// The tool never started: close the write ends so the reads above finish.
try? outputPipe.fileHandleForWriting.close()
try? errorPipe.fileHandleForWriting.close()
_ = await (stdout, stderr)
throw error
}
let (outputData, errorData) = await (stdout, stderr)
return ToolResult(status: status,
standardOutput: String(decoding: outputData, as: UTF8.self),
standardError: String(decoding: errorData, as: UTF8.self))
}
/// A log file that both of a tool's output streams write to, from two threads. Writes are
/// serialized, and the file is opened for appending, so chunks never overwrite each other.
private final class LogFile: @unchecked Sendable {
private let handle: FileHandle
private let lock = NSLock()
/// Creates (or empties) the file at `path`.
init(_ path: Path) throws {
let descriptor = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_APPEND, 0o644)
guard descriptor >= 0 else {
throw ZipBuilderError("Couldn't create the log file \(path).")
}
handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
}
/// Appends `data`. A chunk that can't be written (say, the disk is full) is dropped: the log is
/// for reading afterwards, and losing part of it shouldn't fail the build.
func write(_ data: Data) {
lock.withLock { try? handle.write(contentsOf: data) }
}
}
/// Reads `pipe` until it closes, on a dispatch thread so the blocking reads stay off the
/// concurrency pool. With `log`, chunks go to the log instead of being kept.
private func readAll(_ pipe: Pipe, into log: LogFile?) async -> Data {
await withCheckedContinuation { continuation in
DispatchQueue.global().async {
var data = Data()
while true {
let chunk = pipe.fileHandleForReading.availableData
if chunk.isEmpty {
break // the write end closed: the tool exited, or never started
}
if let log {
log.write(chunk)
} else {
data.append(chunk)
}
}
continuation.resume(returning: data)
}
}
}