From 3a5cad161d301defcb7c3fff2266c25061cf6eaf Mon Sep 17 00:00:00 2001 From: Jonas Kattendick Date: Wed, 11 Feb 2026 22:03:44 +0100 Subject: [PATCH] refactor!: clean up stdio interface with an interface no less! --- examples/plumbing.php | 2 +- src/ChildStderr.php | 10 +++- src/ChildStdin.php | 10 +++- src/ChildStdout.php | 10 +++- src/Command.php | 73 +++++++------------------- src/ExitStatus.php | 8 +-- src/Output.php | 8 +-- src/Stdio.php | 116 +++++++++++++++++++++++++++++------------ src/StdioInterface.php | 20 +++++++ src/StdioPiped.php | 16 ------ 10 files changed, 159 insertions(+), 114 deletions(-) create mode 100644 src/StdioInterface.php delete mode 100644 src/StdioPiped.php diff --git a/examples/plumbing.php b/examples/plumbing.php index b8d331f..b389fc8 100644 --- a/examples/plumbing.php +++ b/examples/plumbing.php @@ -11,7 +11,7 @@ $echo = (new Command('echo')) ->spawn(); (new Command('cat')) - ->stdin(Stdio::stream($echo->stdout)) + ->stdin($echo->stdout) ->status(); // Hello, World! diff --git a/src/ChildStderr.php b/src/ChildStderr.php index 88e3937..2318846 100644 --- a/src/ChildStderr.php +++ b/src/ChildStderr.php @@ -4,7 +4,15 @@ declare(strict_types=1); namespace Nih\CommandBuilder; -final class ChildStderr +use Override; + +final class ChildStderr implements StdioInterface { use StreamReadable; + + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + return $this->stream; + } } diff --git a/src/ChildStdin.php b/src/ChildStdin.php index 6e0676b..c2dc2bd 100644 --- a/src/ChildStdin.php +++ b/src/ChildStdin.php @@ -4,7 +4,15 @@ declare(strict_types=1); namespace Nih\CommandBuilder; -final class ChildStdin +use Override; + +final class ChildStdin implements StdioInterface { use StreamWritable; + + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + return $this->stream; + } } diff --git a/src/ChildStdout.php b/src/ChildStdout.php index 023ac61..6394def 100644 --- a/src/ChildStdout.php +++ b/src/ChildStdout.php @@ -4,7 +4,15 @@ declare(strict_types=1); namespace Nih\CommandBuilder; -final class ChildStdout +use Override; + +final class ChildStdout implements StdioInterface { use StreamReadable; + + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + return $this->stream; + } } diff --git a/src/Command.php b/src/Command.php index b762012..4886610 100644 --- a/src/Command.php +++ b/src/Command.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Nih\CommandBuilder; +use Override; use Stringable; use ValueError; @@ -20,9 +21,9 @@ final class Command implements Stringable private bool $environmentInherit = true; private ?string $cwd = null; - private ?Stdio $stdin = null; - private ?Stdio $stdout = null; - private ?Stdio $stderr = null; + private ?StdioInterface $stdin = null; + private ?StdioInterface $stdout = null; + private ?StdioInterface $stderr = null; /** * Constructs a new Command for launching the program at path program, with @@ -202,14 +203,8 @@ final class Command implements Stringable * Defaults to inherit when used with spawn or status, and defaults to piped * when used with output. */ - public function stdin(Stdio $stdin): static + public function stdin(StdioInterface $stdin): static { - $stdin = match ($stdin->type) { - Stdio::INHERIT => Stdio::stream(STDIN), - Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'r']), - default => $stdin, - }; - $this->stdin = $stdin; return $this; } @@ -220,14 +215,8 @@ final class Command implements Stringable * Defaults to inherit when used with spawn or status, and defaults to piped * when used with output. */ - public function stdout(Stdio $stdout): static + public function stdout(StdioInterface $stdout): static { - $stdout = match ($stdout->type) { - Stdio::INHERIT => Stdio::stream(STDOUT), - Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'w']), - default => $stdout, - }; - $this->stdout = $stdout; return $this; } @@ -238,14 +227,8 @@ final class Command implements Stringable * Defaults to inherit when used with spawn or status, and defaults to piped * when used with output. */ - public function stderr(Stdio $stderr): static + public function stderr(StdioInterface $stderr): static { - $stderr = match ($stderr->type) { - Stdio::INHERIT => Stdio::stream(STDERR), - Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'w']), - default => $stderr, - }; - $this->stderr = $stderr; return $this; } @@ -267,20 +250,18 @@ final class Command implements Stringable * Executes the command as a child process, returning a handle to it. * * By default, stdin, stdout and stderr are inherited from the parent. - * - * @param bool $shell Run the command with or without a shell */ public function spawn(): Child { return $this->spawnWithDescriptorSpec([ - $this->stdin instanceof Stdio - ? $this->stdin->descriptorSpec + $this->stdin instanceof StdioInterface + ? $this->stdin->getDescriptionSpec(0) : STDIN, - $this->stdout instanceof Stdio - ? $this->stdout->descriptorSpec + $this->stdout instanceof StdioInterface + ? $this->stdout->getDescriptionSpec(1) : STDOUT, - $this->stderr instanceof Stdio - ? $this->stderr->descriptorSpec + $this->stderr instanceof StdioInterface + ? $this->stderr->getDescriptionSpec(2) : STDERR, ]); } @@ -290,8 +271,6 @@ final class Command implements Stringable * collecting its status. * * By default, stdin, stdout and stderr are inherited from the parent. - * - * @param bool $shell Run the command with or without a shell */ public function status(): ExitStatus { @@ -304,20 +283,18 @@ final class Command implements Stringable * * By default, stdout and stderr are captured (and used to provide the * resulting output). Stdin is not inherited from the parent. - * - * @param bool $shell Run the command with or without a shell */ public function output(): Output { return $this->spawnWithDescriptorSpec([ - $this->stdin instanceof Stdio - ? $this->stdin->descriptorSpec + $this->stdin instanceof StdioInterface + ? $this->stdin->getDescriptionSpec(0) : ['pipe', 'r'], - $this->stdout instanceof Stdio - ? $this->stdout->descriptorSpec + $this->stdout instanceof StdioInterface + ? $this->stdout->getDescriptionSpec(1) : ['pipe', 'w'], - $this->stderr instanceof Stdio - ? $this->stderr->descriptorSpec + $this->stderr instanceof StdioInterface + ? $this->stderr->getDescriptionSpec(2) : ['pipe', 'w'], ])->waitWithOutput(); } @@ -365,6 +342,7 @@ final class Command implements Stringable return $this->cwd; } + #[Override] public function __toString(): string { return implode(' ', [ @@ -375,17 +353,6 @@ final class Command implements Stringable private function spawnWithDescriptorSpec(array $descriptorSpec): Child { - // Validate stream resources in descriptor spec. - foreach ($descriptorSpec as $descriptor => $spec) { - if (!is_array($spec) && !is_resource($spec)) { - throw new CommandException(sprintf( - 'Descriptor %d is not a valid stream resource: %s', - $descriptor, - get_debug_type($spec), - )); - } - } - // Find executable if path is not absolute. $program = $this->program; if ($program[0] !== DIRECTORY_SEPARATOR) { diff --git a/src/ExitStatus.php b/src/ExitStatus.php index 333718b..4cf5b09 100644 --- a/src/ExitStatus.php +++ b/src/ExitStatus.php @@ -12,12 +12,12 @@ namespace Nih\CommandBuilder; * their exit status is exposed through the status method, or the wait method of * a Child process. */ -final class ExitStatus +final readonly class ExitStatus { public function __construct( - private readonly ?int $code, - private readonly ?int $signal = null, - private readonly ?int $stoppedSignal = null, + private ?int $code, + private ?int $signal = null, + private ?int $stoppedSignal = null, ) { } diff --git a/src/Output.php b/src/Output.php index 643a40a..f42d969 100644 --- a/src/Output.php +++ b/src/Output.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Nih\CommandBuilder; -final class Output +final readonly class Output { public function __construct( - public readonly ?string $stdout, - public readonly ?string $stderr, - public readonly ExitStatus $status, + public ?string $stdout, + public ?string $stderr, + public ExitStatus $status, ) { } } diff --git a/src/Stdio.php b/src/Stdio.php index cedbc21..e8eecb0 100644 --- a/src/Stdio.php +++ b/src/Stdio.php @@ -4,78 +4,128 @@ declare(strict_types=1); namespace Nih\CommandBuilder; +use InvalidArgumentException; +use Override; use Stringable; /** * Describes what to do with a standard I/O stream for a child process when * passed to the stdin, stdout, and stderr methods of {@see Command}. + * + * @psalm-suppress UnusedClass */ -final class Stdio +abstract class Stdio { - public const INHERIT = 0; - public const PIPE = 1; - public const FILE = 2; - public const STREAM = 3; - - /** - * @param null - * |resource - * |array{0: 'file', 1: string, 2: string} - * |array{0: 'pipe',1: string} $descriptorSpec - */ - public function __construct( - public readonly int $type, - public readonly mixed $descriptorSpec, - ) { - } - /** * The child inherits from the corresponding parent descriptor. */ - public static function inherit(): self + public static function inherit(): StdioInterface { - return new self(self::INHERIT, null); + return new class implements StdioInterface { + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + return match ($fd) { + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + default => ['php://fd/' . $fd, 'w+'], + }; + } + }; } /** * A new pipe should be arranged to connect the parent and child processes. */ - public static function piped(): self + public static function piped(): StdioInterface { - return new self(self::PIPE, ['pipe', 'r']); + return new class implements StdioInterface { + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + return match ($fd) { + 0 => ['pipe', 'r'], + 1, 2 => ['pipe', 'w'], + default => ['pipe', 'w+'], + }; + } + }; } /** * This stream will be ignored. This is the equivalent of attaching the * stream to `/dev/null`. */ - public static function null(): self + public static function null(): StdioInterface { - return self::file('/dev/null', 'a+'); + return new class implements StdioInterface { + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + // TODO: Support windows (I think you just write to `nul` in any + // directory?) + return match ($fd) { + 0 => ['file', '/dev/null', 'r'], + 1, 2 => ['file', '/dev/null', 'w'], + default => ['file', '/dev/null', 'w+'], + }; + } + }; } /** * Like piped, but instead of capturing the stream into a handle, read * and/or write from/to a file. */ - public static function file(string|Stringable $file, string $mode): self + public static function file(string|Stringable $filename, string $mode): StdioInterface { - return new self(self::FILE, ['file', (string) $file, $mode]); + return new class((string) $filename, $mode) implements StdioInterface { + public function __construct( + private string $filename, + private string $mode, + ) {} + + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + return ['file', $this->filename, $this->mode]; + } + }; } /** * Like piped, but instead of capturing the stream into a handle, read - * and/or write from/to a stream. + * and/or write from/to a stream resource. * - * @param resource|StreamReadable|StreamWritable $stream + * @param resource $stream + * + * @throws InvalidArgumentException When stream is not a live stream resource */ - public static function stream($stream): self + public static function stream($stream): StdioInterface { - if (is_object($stream)) { - $stream = $stream->stream; + $isResource = is_resource($stream) + || ($stream !== null + && !is_scalar($stream) + && !is_array($stream) + && !is_object($stream)); + + if (!$isResource) { + throw new InvalidArgumentException('not a resource'); } - return new self(self::STREAM, $stream); - } + if (get_resource_type($stream) !== 'stream') { + throw new InvalidArgumentException('resource is not a stream or was closed'); + } + return new class($stream) implements StdioInterface { + public function __construct(private mixed $stream) {} + + #[Override] + public function getDescriptionSpec(int $fd): mixed + { + return $this->stream; + } + }; + } } diff --git a/src/StdioInterface.php b/src/StdioInterface.php new file mode 100644 index 0000000..fa847c7 --- /dev/null +++ b/src/StdioInterface.php @@ -0,0 +1,20 @@ +mode]; - } -}