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(); ->spawn();
(new Command('cat')) (new Command('cat'))
->stdin(Stdio::stream($echo->stdout)) ->stdin($echo->stdout)
->status(); ->status();
// Hello, World! // Hello, World!

View File

@@ -4,7 +4,15 @@ declare(strict_types=1);
namespace Nih\CommandBuilder; namespace Nih\CommandBuilder;
final class ChildStderr use Override;
final class ChildStderr implements StdioInterface
{ {
use StreamReadable; 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; namespace Nih\CommandBuilder;
final class ChildStdin use Override;
final class ChildStdin implements StdioInterface
{ {
use StreamWritable; 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; namespace Nih\CommandBuilder;
final class ChildStdout use Override;
final class ChildStdout implements StdioInterface
{ {
use StreamReadable; 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; namespace Nih\CommandBuilder;
use Override;
use Stringable; use Stringable;
use ValueError; use ValueError;
@@ -20,9 +21,9 @@ final class Command implements Stringable
private bool $environmentInherit = true; private bool $environmentInherit = true;
private ?string $cwd = null; private ?string $cwd = null;
private ?Stdio $stdin = null; private ?StdioInterface $stdin = null;
private ?Stdio $stdout = null; private ?StdioInterface $stdout = null;
private ?Stdio $stderr = null; private ?StdioInterface $stderr = null;
/** /**
* Constructs a new Command for launching the program at path program, with * 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 * Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output. * 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; $this->stdin = $stdin;
return $this; return $this;
} }
@@ -220,14 +215,8 @@ final class Command implements Stringable
* Defaults to inherit when used with spawn or status, and defaults to piped * Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output. * 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; $this->stdout = $stdout;
return $this; return $this;
} }
@@ -238,14 +227,8 @@ final class Command implements Stringable
* Defaults to inherit when used with spawn or status, and defaults to piped * Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output. * 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; $this->stderr = $stderr;
return $this; return $this;
} }
@@ -267,20 +250,18 @@ final class Command implements Stringable
* Executes the command as a child process, returning a handle to it. * Executes the command as a child process, returning a handle to it.
* *
* By default, stdin, stdout and stderr are inherited from the parent. * 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 public function spawn(): Child
{ {
return $this->spawnWithDescriptorSpec([ return $this->spawnWithDescriptorSpec([
$this->stdin instanceof Stdio $this->stdin instanceof StdioInterface
? $this->stdin->descriptorSpec ? $this->stdin->getDescriptionSpec(0)
: STDIN, : STDIN,
$this->stdout instanceof Stdio $this->stdout instanceof StdioInterface
? $this->stdout->descriptorSpec ? $this->stdout->getDescriptionSpec(1)
: STDOUT, : STDOUT,
$this->stderr instanceof Stdio $this->stderr instanceof StdioInterface
? $this->stderr->descriptorSpec ? $this->stderr->getDescriptionSpec(2)
: STDERR, : STDERR,
]); ]);
} }
@@ -290,8 +271,6 @@ final class Command implements Stringable
* collecting its status. * collecting its status.
* *
* By default, stdin, stdout and stderr are inherited from the parent. * 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 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 * By default, stdout and stderr are captured (and used to provide the
* resulting output). Stdin is not inherited from the parent. * resulting output). Stdin is not inherited from the parent.
*
* @param bool $shell Run the command with or without a shell
*/ */
public function output(): Output public function output(): Output
{ {
return $this->spawnWithDescriptorSpec([ return $this->spawnWithDescriptorSpec([
$this->stdin instanceof Stdio $this->stdin instanceof StdioInterface
? $this->stdin->descriptorSpec ? $this->stdin->getDescriptionSpec(0)
: ['pipe', 'r'], : ['pipe', 'r'],
$this->stdout instanceof Stdio $this->stdout instanceof StdioInterface
? $this->stdout->descriptorSpec ? $this->stdout->getDescriptionSpec(1)
: ['pipe', 'w'], : ['pipe', 'w'],
$this->stderr instanceof Stdio $this->stderr instanceof StdioInterface
? $this->stderr->descriptorSpec ? $this->stderr->getDescriptionSpec(2)
: ['pipe', 'w'], : ['pipe', 'w'],
])->waitWithOutput(); ])->waitWithOutput();
} }
@@ -365,6 +342,7 @@ final class Command implements Stringable
return $this->cwd; return $this->cwd;
} }
#[Override]
public function __toString(): string public function __toString(): string
{ {
return implode(' ', [ return implode(' ', [
@@ -375,17 +353,6 @@ final class Command implements Stringable
private function spawnWithDescriptorSpec(array $descriptorSpec): Child 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. // Find executable if path is not absolute.
$program = $this->program; $program = $this->program;
if ($program[0] !== DIRECTORY_SEPARATOR) { 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 * their exit status is exposed through the status method, or the wait method of
* a Child process. * a Child process.
*/ */
final class ExitStatus final readonly class ExitStatus
{ {
public function __construct( public function __construct(
private readonly ?int $code, private ?int $code,
private readonly ?int $signal = null, private ?int $signal = null,
private readonly ?int $stoppedSignal = null, private ?int $stoppedSignal = null,
) { ) {
} }

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Nih\CommandBuilder; namespace Nih\CommandBuilder;
final class Output final readonly class Output
{ {
public function __construct( public function __construct(
public readonly ?string $stdout, public ?string $stdout,
public readonly ?string $stderr, public ?string $stderr,
public readonly ExitStatus $status, public ExitStatus $status,
) { ) {
} }
} }

View File

@@ -4,78 +4,128 @@ declare(strict_types=1);
namespace Nih\CommandBuilder; namespace Nih\CommandBuilder;
use InvalidArgumentException;
use Override;
use Stringable; use Stringable;
/** /**
* Describes what to do with a standard I/O stream for a child process when * 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}. * 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. * 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. * 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 * This stream will be ignored. This is the equivalent of attaching the
* stream to `/dev/null`. * 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 * Like piped, but instead of capturing the stream into a handle, read
* and/or write from/to a file. * 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 * 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)) { $isResource = is_resource($stream)
$stream = $stream->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];
}
}