Files
nih-php-command-builder/src/Command.php

439 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
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;
private array $args = [];
private ?array $environment = null;
private bool $environmentInherit = true;
private ?string $cwd = null;
private ?Stdio $stdin = null;
private ?Stdio $stdout = null;
private ?Stdio $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<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] = $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 processs standard input (stdin) handle.
*
* Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output.
*/
public function stdin(Stdio $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;
}
/**
* Configuration for the child processs standard output (stdout) handle.
*
* Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output.
*/
public function stdout(Stdio $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;
}
/**
* Configuration for the child processs standard error (stderr) handle.
*
* Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output.
*/
public function stderr(Stdio $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;
}
// 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.
*
* @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
: STDIN,
$this->stdout instanceof Stdio
? $this->stdout->descriptorSpec
: STDOUT,
$this->stderr instanceof Stdio
? $this->stderr->descriptorSpec
: 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.
*
* @param bool $shell Run the command with or without a shell
*/
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.
*
* @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
: ['pipe', 'r'],
$this->stdout instanceof Stdio
? $this->stdout->descriptorSpec
: ['pipe', 'w'],
$this->stderr instanceof Stdio
? $this->stderr->descriptorSpec
: ['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.
*/
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.
*/
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;
}
public function __toString(): string
{
return implode(' ', [
escapeshellarg($this->program),
...array_map(escapeshellarg(...), $this->args)
]);
}
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) {
$path = getenv('PATH');
if (is_string($path)) {
foreach (explode(':', $path) as $path) {
$path = $path . '/' . $program;
if (is_executable($path)) {
$program = $path;
break;
}
}
}
}
if (!is_executable($program)) {
throw new CommandException(sprintf(
'Program "%s" is not executable',
$program,
));
}
// 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;
}
}
}
$proc = proc_open(
[$program, ...$this->args],
$descriptorSpec,
$pipes,
$this->cwd,
$environment,
);
if ($proc === false) {
throw new CommandException(sprintf(
'Program "%s" failed to start',
$this->program,
));
}
return new Child($proc, $pipes);
}
}