454 lines
13 KiB
PHP
454 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Nih\CommandBuilder;
|
||
|
||
use InvalidArgumentException;
|
||
use Override;
|
||
use Stringable;
|
||
use ValueError;
|
||
|
||
/**
|
||
* A process builder, providing fine-grained control over how a new process
|
||
* should be spawned.
|
||
*/
|
||
final class Command implements Stringable
|
||
{
|
||
public readonly string $program;
|
||
|
||
/**
|
||
* @var list<string>
|
||
*/
|
||
private array $args = [];
|
||
|
||
/**
|
||
* @var array<string, string|null>
|
||
*/
|
||
private ?array $environment = null;
|
||
|
||
private bool $environmentInherit = true;
|
||
|
||
private ?string $cwd = 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
|
||
* the following default configuration:
|
||
*
|
||
* - No arguments to the program
|
||
* - Inherit the current process's environment
|
||
* - Inherit the current process's working directory
|
||
* - Inherit stdin/stdout/stderr for spawn or status, but create pipes for
|
||
* output
|
||
*
|
||
* Builder methods are provided to change these defaults and otherwise
|
||
* configure the process.
|
||
*
|
||
* If program is not an absolute path, the `PATH` will be searched.
|
||
*/
|
||
public function __construct(string $program)
|
||
{
|
||
if (strlen($program) === 0) {
|
||
throw new ValueError('Empty program name');
|
||
}
|
||
|
||
$this->program = $program;
|
||
}
|
||
|
||
/**
|
||
* Adds an argument to pass to the program.
|
||
*
|
||
* Only one argument can be passed per use. So instead of:
|
||
*
|
||
* ```
|
||
* $command->arg('-C /path/to/repo');
|
||
* ```
|
||
*
|
||
* usage would be:
|
||
*
|
||
* ```
|
||
* $command
|
||
* ->arg('-C')
|
||
* ->arg('/path/to/repo');
|
||
* ```
|
||
*
|
||
* To pass multiple arguments see {@see Command::args()}.
|
||
*
|
||
* Note that the arguments are not passed through a shell, but given
|
||
* literally to the program. This means that shell syntax like quotes,
|
||
* escaped characters, word splitting, glob patterns, variable substitution,
|
||
* etc. have no effect.
|
||
*/
|
||
public function arg(string|Stringable $arg): static
|
||
{
|
||
$this->args[] = (string) $arg;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Adds multiple arguments to pass to the program.
|
||
*
|
||
* To pass a single argument see {@see Command::arg()}.
|
||
*
|
||
* Note that the arguments are not passed through a shell, but given
|
||
* literally to the program. This means that shell syntax like quotes,
|
||
* escaped characters, word splitting, glob patterns, variable substitution,
|
||
* etc. have no effect.
|
||
*
|
||
* @param iterable<mixed, string|Stringable> $args
|
||
*/
|
||
public function args(iterable $args): static
|
||
{
|
||
foreach ($args as $arg) {
|
||
$this->args[] = (string) $arg;
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Inserts or updates an explicit environment variable mapping.
|
||
*
|
||
* This method allows you to add an environment variable mapping to the
|
||
* spawned process or overwrite a previously set value. You can use
|
||
* {@see Command::envs()} to set multiple environment variables
|
||
* simultaneously.
|
||
*
|
||
* Child processes will inherit environment variables from their parent
|
||
* process by default. Environment variables explicitly set using
|
||
* {@see Command::env()} take precedence over inherited variables. You can
|
||
* disable environment variable inheritance entirely using
|
||
* {@see Command::envClear()} or for a single key using
|
||
* {@see Command::envRemove()}.
|
||
*/
|
||
public function env(string $key, string|Stringable $val): static
|
||
{
|
||
$this->environment[$key] = (string) $val;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Inserts or updates multiple environment variable mappings.
|
||
*
|
||
* This method allows you to add multiple environment variable mappings to
|
||
* the spawned process or overwrite previously set values. You can use
|
||
* {@see Command::env()} to set a single environment variable.
|
||
*
|
||
* Child processes will inherit environment variables from their parent
|
||
* process by default. Environment variables explicitly set using
|
||
* {@see Command::envs()} take precedence over inherited variables. You can
|
||
* disable environment variable inheritance entirely using
|
||
* {@see Command::envClear()} or for a single key using
|
||
* {@see Command::envRemove()}.
|
||
*
|
||
* @param iterable<string, string|Stringable> $vars
|
||
*/
|
||
public function envs(iterable $vars): static
|
||
{
|
||
foreach ($vars as $key => $val) {
|
||
$this->environment[$key] = (string) $val;
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Removes an explicitly set environment variable and prevents inheriting it
|
||
* from a parent process.
|
||
*
|
||
* This method will remove the explicit value of an environment variable set
|
||
* via {@see Command::env()} or {@see Command::envs()}. In addition, it will
|
||
* prevent the spawned child process from inheriting that environment
|
||
* variable from its parent process.
|
||
*
|
||
* After calling {@see Command::envRemove()}, the value associated with its
|
||
* key from {@see Command::getEnvs()} will be `NULL`.
|
||
*
|
||
* To clear all explicitly set environment variables and disable all
|
||
* environment variable inheritance, you can use {@see Command::envClear()}.
|
||
*/
|
||
public function envRemove(string $key): static
|
||
{
|
||
$this->environment[$key] = null;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Clears all explicitly set environment variables and prevents inheriting
|
||
* any parent process environment variables.
|
||
*
|
||
* This method will remove all explicitly added environment variables set
|
||
* via {@see Command::env()} or {@see Command::envs()}. In addition, it will
|
||
* prevent the spawned child process from inheriting any environment
|
||
* variable from its parent process.
|
||
*
|
||
* After calling {@see Command::envClear()}, the array from
|
||
* {@see Command::getEnvs()} will be empty.
|
||
*
|
||
* You can use {@see Command::envRemove()} to clear a single mapping.
|
||
*/
|
||
public function envClear(): static
|
||
{
|
||
$this->environmentInherit = false;
|
||
$this->environment = [];
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Sets the working directory for the child process.
|
||
*/
|
||
public function currentDir(string|Stringable $dir): static
|
||
{
|
||
$this->cwd = (string) $dir;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Configuration for the child process’s standard input (stdin) handle.
|
||
*
|
||
* Defaults to inherit when used with spawn or status, and defaults to piped
|
||
* when used with output.
|
||
*
|
||
* @param StdioInterface|resource $stdin
|
||
*
|
||
* @throws InvalidArgumentException
|
||
* When the provided stdin handle is neither a StdioInterface nor a live
|
||
* stream resource.
|
||
*/
|
||
public function stdin(mixed $stdin): static
|
||
{
|
||
$this->stdin = $stdin instanceof StdioInterface
|
||
? $stdin
|
||
: Stdio::stream($stdin);
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Configuration for the child process’s standard output (stdout) handle.
|
||
*
|
||
* Defaults to inherit when used with spawn or status, and defaults to piped
|
||
* when used with output.
|
||
*
|
||
* @param StdioInterface|resource $stdout
|
||
*
|
||
* @throws InvalidArgumentException
|
||
* When the provided stdout handle is neither a StdioInterface nor a live
|
||
* stream resource.
|
||
*/
|
||
public function stdout(mixed $stdout): static
|
||
{
|
||
$this->stdout = $stdout instanceof StdioInterface
|
||
? $stdout
|
||
: Stdio::stream($stdout);
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Configuration for the child process’s standard error (stderr) handle.
|
||
*
|
||
* Defaults to inherit when used with spawn or status, and defaults to piped
|
||
* when used with output.
|
||
*
|
||
* @param StdioInterface|resource $stderr
|
||
*
|
||
* @throws InvalidArgumentException
|
||
* When the provided stderr handle is neither a StdioInterface nor a live
|
||
* stream resource.
|
||
*/
|
||
public function stderr(mixed $stderr): static
|
||
{
|
||
$this->stderr = $stderr instanceof StdioInterface
|
||
? $stderr
|
||
: Stdio::stream($stderr);
|
||
|
||
return $this;
|
||
}
|
||
|
||
// TODO: Allow capturing arbitrary descriptors (proc_open supports this)?
|
||
// public function descriptor(int $fd, Stdio $stdio): static
|
||
// {
|
||
// match ($fd) {
|
||
// 0 => $this->stdin($stdio),
|
||
// 1 => $this->stdout($stdio),
|
||
// 2 => $this->stderr($stdio),
|
||
// default => $this->fd[$fd] = $stdio,
|
||
// };
|
||
//
|
||
// return $this;
|
||
// }
|
||
|
||
/**
|
||
* Executes the command as a child process, returning a handle to it.
|
||
*
|
||
* By default, stdin, stdout and stderr are inherited from the parent.
|
||
*
|
||
* @throws ChildException
|
||
*/
|
||
public function spawn(): Child
|
||
{
|
||
return $this->spawnWithDescriptorSpec([
|
||
$this->stdin instanceof StdioInterface
|
||
? $this->stdin->getDescriptiorSpec(0)
|
||
: STDIN,
|
||
$this->stdout instanceof StdioInterface
|
||
? $this->stdout->getDescriptiorSpec(1)
|
||
: STDOUT,
|
||
$this->stderr instanceof StdioInterface
|
||
? $this->stderr->getDescriptiorSpec(2)
|
||
: STDERR,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Executes a command as a child process, waiting for it to finish and
|
||
* collecting its status.
|
||
*
|
||
* By default, stdin, stdout and stderr are inherited from the parent.
|
||
*
|
||
* @throws ChildException
|
||
*/
|
||
public function status(): ExitStatus
|
||
{
|
||
return $this->spawn()->wait();
|
||
}
|
||
|
||
/**
|
||
* Executes the command as a child process, waiting for it to finish and
|
||
* collecting all of its output.
|
||
*
|
||
* By default, stdout and stderr are captured (and used to provide the
|
||
* resulting output). Stdin is not inherited from the parent.
|
||
*
|
||
* @throws ChildException
|
||
*/
|
||
public function output(): Output
|
||
{
|
||
return $this->spawnWithDescriptorSpec([
|
||
$this->stdin instanceof StdioInterface
|
||
? $this->stdin->getDescriptiorSpec(0)
|
||
: ['pipe', 'r'],
|
||
$this->stdout instanceof StdioInterface
|
||
? $this->stdout->getDescriptiorSpec(1)
|
||
: ['pipe', 'w'],
|
||
$this->stderr instanceof StdioInterface
|
||
? $this->stderr->getDescriptiorSpec(2)
|
||
: ['pipe', 'w'],
|
||
])->waitWithOutput();
|
||
}
|
||
|
||
/**
|
||
* Returns the path to the program that was given to the constructor.
|
||
*/
|
||
public function getProgram(): string
|
||
{
|
||
return $this->program;
|
||
}
|
||
|
||
/**
|
||
* Returns the arguments that will be passed to the program.
|
||
*
|
||
* This does not include the path to the program as the first argument.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function getArgs(): array
|
||
{
|
||
return $this->args;
|
||
}
|
||
|
||
/**
|
||
* Returns the environment variables set for the child process.
|
||
*
|
||
* Environment variables explicitly set using {@see Command::env(),
|
||
* {@see Command::envs()}, and {@see Command::envRemove} can be retrieved
|
||
* with this method.
|
||
*
|
||
* Note that this output does not include environment variables inherited
|
||
* from the parent process.
|
||
*
|
||
* @return array<string, string|null>
|
||
*/
|
||
public function getEnvs(): array
|
||
{
|
||
return $this->environment ?? [];
|
||
}
|
||
|
||
/**
|
||
* Returns the working directory for the child process.
|
||
*
|
||
* This returns `NULL` if the working directory will not be changed.
|
||
*/
|
||
public function getCurrentDir(): ?string
|
||
{
|
||
return $this->cwd;
|
||
}
|
||
|
||
#[Override]
|
||
public function __toString(): string
|
||
{
|
||
return implode(' ', [
|
||
escapeshellarg($this->program),
|
||
...array_map(escapeshellarg(...), $this->args)
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @param array<int, resource|list<string>> $descriptorSpec
|
||
*/
|
||
private function spawnWithDescriptorSpec(array $descriptorSpec): Child
|
||
{
|
||
// Find executable if path is not absolute.
|
||
$program = $this->program;
|
||
if ($program[0] !== DIRECTORY_SEPARATOR) {
|
||
$path = getenv('PATH');
|
||
if (is_string($path)) {
|
||
foreach (explode(':', $path) as $path) {
|
||
$path = $path . DIRECTORY_SEPARATOR . $program;
|
||
if (is_executable($path)) {
|
||
$program = $path;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle environment inheritance.
|
||
$environment = $this->environment;
|
||
if (is_array($environment) && $this->environmentInherit) {
|
||
foreach (getenv() as $key => $val) {
|
||
if (!array_key_exists($key, $environment)) {
|
||
$environment[$key] = $val;
|
||
}
|
||
}
|
||
}
|
||
|
||
$command = [$program];
|
||
foreach ($this->args as $arg) {
|
||
$command[] = $arg;
|
||
}
|
||
|
||
try {
|
||
set_error_handler(ChildException::handleError(...));
|
||
$proc = proc_open(
|
||
$command,
|
||
$descriptorSpec,
|
||
$pipes,
|
||
$this->cwd,
|
||
$environment,
|
||
);
|
||
} finally {
|
||
restore_error_handler();
|
||
}
|
||
|
||
assert($proc !== false);
|
||
|
||
return new Child($proc, $pipes);
|
||
}
|
||
}
|