refactor!: clean up stdio interface

with an interface no less!
This commit is contained in:
2026-02-11 22:03:44 +01:00
parent ad740afb5f
commit 3a5cad161d
10 changed files with 159 additions and 114 deletions

View File

@@ -11,7 +11,7 @@ $echo = (new Command('echo'))
->spawn();
(new Command('cat'))
->stdin(Stdio::stream($echo->stdout))
->stdin($echo->stdout)
->status();
// Hello, World!

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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,
) {
}

View File

@@ -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,
) {
}
}

View File

@@ -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;
}
};
}
}

20
src/StdioInterface.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
use InvalidArgumentException;
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}.
*/
interface StdioInterface
{
/**
* @return resource|array{0: 'file', 1: string, 2: string}|array{0: 'piped', 1: string}
*/
public function getDescriptionSpec(int $fd): mixed;
}

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class StdioPiped extends Stdio
{
public function __construct(public readonly string $mode)
{}
public function getDescriptorSpec(): array
{
return ['pipe', $this->mode];
}
}