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

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