• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

azjezz / psl / 22521133387

28 Feb 2026 12:53PM UTC coverage: 98.016% (+0.5%) from 97.532%
22521133387

Pull #586

github

azjezz
code clean up

Signed-off-by: azjezz <azjezz@protonmail.com>
Pull Request #586: add more tests

8 of 11 new or added lines in 5 files covered. (72.73%)

1 existing line in 1 file now uncovered.

7511 of 7663 relevant lines covered (98.02%)

43.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.69
/src/Psl/Process/Command.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Psl\Process;
6

7
use Psl\DateTime\Duration;
8
use Psl\Dict;
9
use Psl\Env;
10
use Psl\Filesystem;
11
use Psl\IO;
12
use Psl\OS;
13
use Psl\Str;
14

15
use function defined;
16
use function is_resource;
17
use function proc_open;
18

19
use const STDERR;
20
use const STDIN;
21
use const STDOUT;
22

23
final readonly class Command
24
{
25
    /**
26
     * @param non-empty-string $program
27
     * @param list<string> $arguments
28
     * @param array<string, string> $environment
29
     */
30
    private function __construct(
31
        private string $program,
32
        private array $arguments,
33
        private array $environment,
34
        private null|string $workingDirectory,
35
        private Stdio $stdin,
36
        private Stdio $stdout,
37
        private Stdio $stderr,
38
        private bool $shell,
39
    ) {}
102✔
40

41
    /**
42
     * Create a new command for the given program.
43
     *
44
     * The program will be executed directly without a shell, preventing shell injection.
45
     * Arguments should be added via {@see withArgument()} or {@see withArguments()}.
46
     *
47
     * @param non-empty-string $program
48
     */
49
    public static function create(string $program): self
50
    {
51
        return new self(
99✔
52
            program: $program,
99✔
53
            arguments: [],
99✔
54
            environment: [],
99✔
55
            workingDirectory: null,
99✔
56
            stdin: Stdio::null(),
99✔
57
            stdout: Stdio::piped(),
99✔
58
            stderr: Stdio::piped(),
99✔
59
            shell: false,
99✔
60
        );
99✔
61
    }
62

63
    /**
64
     * Create a new command to be interpreted by the system shell.
65
     *
66
     * On Unix, this runs through `/bin/sh -c`. On Windows, through `cmd.exe`.
67
     * This allows shell features like pipes, globbing, and variable expansion.
68
     *
69
     * @param non-empty-string $command
70
     */
71
    public static function shell(string $command): self
72
    {
73
        return new self(
3✔
74
            program: $command,
3✔
75
            arguments: [],
3✔
76
            environment: [],
3✔
77
            workingDirectory: null,
3✔
78
            stdin: Stdio::null(),
3✔
79
            stdout: Stdio::piped(),
3✔
80
            stderr: Stdio::piped(),
3✔
81
            shell: true,
3✔
82
        );
3✔
83
    }
84

85
    /**
86
     * Adds an argument to pass to the program.
87
     */
88
    public function withArgument(string $argument): self
89
    {
90
        return new self(
67✔
91
            program: $this->program,
67✔
92
            arguments: [...$this->arguments, $argument],
67✔
93
            environment: $this->environment,
67✔
94
            workingDirectory: $this->workingDirectory,
67✔
95
            stdin: $this->stdin,
67✔
96
            stdout: $this->stdout,
67✔
97
            stderr: $this->stderr,
67✔
98
            shell: $this->shell,
67✔
99
        );
67✔
100
    }
101

102
    /**
103
     * Adds multiple arguments to pass to the program.
104
     *
105
     * @param list<string> $arguments
106
     */
107
    public function withArguments(array $arguments): self
108
    {
109
        return new self(
25✔
110
            program: $this->program,
25✔
111
            arguments: [...$this->arguments, ...$arguments],
25✔
112
            environment: $this->environment,
25✔
113
            workingDirectory: $this->workingDirectory,
25✔
114
            stdin: $this->stdin,
25✔
115
            stdout: $this->stdout,
25✔
116
            stderr: $this->stderr,
25✔
117
            shell: $this->shell,
25✔
118
        );
25✔
119
    }
120

121
    /**
122
     * Sets an environment variable for the child process.
123
     */
124
    public function withEnvironmentVariable(string $name, string $value): self
125
    {
126
        return new self(
4✔
127
            program: $this->program,
4✔
128
            arguments: $this->arguments,
4✔
129
            environment: Dict\merge($this->environment, [$name => $value]),
4✔
130
            workingDirectory: $this->workingDirectory,
4✔
131
            stdin: $this->stdin,
4✔
132
            stdout: $this->stdout,
4✔
133
            stderr: $this->stderr,
4✔
134
            shell: $this->shell,
4✔
135
        );
4✔
136
    }
137

138
    /**
139
     * Sets multiple environment variables for the child process.
140
     *
141
     * @param array<string, string> $variables
142
     */
143
    public function withEnvironmentVariables(array $variables): self
144
    {
145
        return new self(
27✔
146
            program: $this->program,
27✔
147
            arguments: $this->arguments,
27✔
148
            environment: Dict\merge($this->environment, $variables),
27✔
149
            workingDirectory: $this->workingDirectory,
27✔
150
            stdin: $this->stdin,
27✔
151
            stdout: $this->stdout,
27✔
152
            stderr: $this->stderr,
27✔
153
            shell: $this->shell,
27✔
154
        );
27✔
155
    }
156

157
    /**
158
     * Removes an environment variable for the child process.
159
     */
160
    public function withoutEnvironmentVariable(string $name): self
161
    {
162
        $environment = $this->environment;
1✔
163
        unset($environment[$name]);
1✔
164

165
        return new self(
1✔
166
            program: $this->program,
1✔
167
            arguments: $this->arguments,
1✔
168
            environment: $environment,
1✔
169
            workingDirectory: $this->workingDirectory,
1✔
170
            stdin: $this->stdin,
1✔
171
            stdout: $this->stdout,
1✔
172
            stderr: $this->stderr,
1✔
173
            shell: $this->shell,
1✔
174
        );
1✔
175
    }
176

177
    /**
178
     * Clears all environment variables, so the child process inherits nothing.
179
     */
180
    public function withClearedEnvironment(): self
181
    {
182
        return new self(
1✔
183
            program: $this->program,
1✔
184
            arguments: $this->arguments,
1✔
185
            environment: [],
1✔
186
            workingDirectory: $this->workingDirectory,
1✔
187
            stdin: $this->stdin,
1✔
188
            stdout: $this->stdout,
1✔
189
            stderr: $this->stderr,
1✔
190
            shell: $this->shell,
1✔
191
        );
1✔
192
    }
193

194
    /**
195
     * Sets the working directory for the child process.
196
     */
197
    public function withWorkingDirectory(string $directory): self
198
    {
199
        return new self(
9✔
200
            program: $this->program,
9✔
201
            arguments: $this->arguments,
9✔
202
            environment: $this->environment,
9✔
203
            workingDirectory: $directory,
9✔
204
            stdin: $this->stdin,
9✔
205
            stdout: $this->stdout,
9✔
206
            stderr: $this->stderr,
9✔
207
            shell: $this->shell,
9✔
208
        );
9✔
209
    }
210

211
    /**
212
     * Configures the stdin descriptor for the child process.
213
     */
214
    public function withStdin(Stdio $stdio): self
215
    {
216
        return new self(
7✔
217
            program: $this->program,
7✔
218
            arguments: $this->arguments,
7✔
219
            environment: $this->environment,
7✔
220
            workingDirectory: $this->workingDirectory,
7✔
221
            stdin: $stdio,
7✔
222
            stdout: $this->stdout,
7✔
223
            stderr: $this->stderr,
7✔
224
            shell: $this->shell,
7✔
225
        );
7✔
226
    }
227

228
    /**
229
     * Configures the stdout descriptor for the child process.
230
     */
231
    public function withStdout(Stdio $stdio): self
232
    {
233
        return new self(
19✔
234
            program: $this->program,
19✔
235
            arguments: $this->arguments,
19✔
236
            environment: $this->environment,
19✔
237
            workingDirectory: $this->workingDirectory,
19✔
238
            stdin: $this->stdin,
19✔
239
            stdout: $stdio,
19✔
240
            stderr: $this->stderr,
19✔
241
            shell: $this->shell,
19✔
242
        );
19✔
243
    }
244

245
    /**
246
     * Configures the stderr descriptor for the child process.
247
     */
248
    public function withStderr(Stdio $stdio): self
249
    {
250
        return new self(
19✔
251
            program: $this->program,
19✔
252
            arguments: $this->arguments,
19✔
253
            environment: $this->environment,
19✔
254
            workingDirectory: $this->workingDirectory,
19✔
255
            stdin: $this->stdin,
19✔
256
            stdout: $this->stdout,
19✔
257
            stderr: $stdio,
19✔
258
            shell: $this->shell,
19✔
259
        );
19✔
260
    }
261

262
    /**
263
     * @return non-empty-string
264
     */
265
    public function getProgram(): string
266
    {
267
        return $this->program;
2✔
268
    }
269

270
    /**
271
     * @return list<string>
272
     */
273
    public function getArguments(): array
274
    {
275
        return $this->arguments;
7✔
276
    }
277

278
    /**
279
     * @return array<string, string>
280
     */
281
    public function getEnvironmentVariables(): array
282
    {
283
        return $this->environment;
6✔
284
    }
285

286
    public function getWorkingDirectory(): null|string
287
    {
288
        return $this->workingDirectory;
2✔
289
    }
290

291
    /**
292
     * Spawns the process and returns a child handle.
293
     *
294
     * @throws Exception\StartFailedException If the process could not be started.
295
     * @throws Exception\RuntimeException If the working directory does not exist.
296
     */
297
    public function spawn(): ChildInterface
298
    {
299
        return $this->doSpawn($this->stdin, $this->stdout, $this->stderr);
39✔
300
    }
301

302
    /**
303
     * Spawns the process with piped stdout/stderr, collects all output, and waits for exit.
304
     *
305
     * Stdin is set to null. Stdout and stderr are forced to piped regardless of configuration.
306
     *
307
     * @throws Exception\StartFailedException If the process could not be started.
308
     * @throws Exception\RuntimeException If the working directory does not exist.
309
     * @throws Exception\TimeoutException If the timeout is reached.
310
     */
311
    public function output(null|Duration $timeout = null): Output
312
    {
313
        $child = $this->doSpawn(Stdio::null(), Stdio::piped(), Stdio::piped());
42✔
314

315
        return $child->waitWithOutput($timeout);
40✔
316
    }
317

318
    /**
319
     * Spawns the process with null stdio and waits for exit, returning only the exit status.
320
     *
321
     * @throws Exception\StartFailedException If the process could not be started.
322
     * @throws Exception\RuntimeException If the working directory does not exist.
323
     * @throws Exception\TimeoutException If the timeout is reached.
324
     */
325
    public function status(null|Duration $timeout = null): ExitStatus
326
    {
327
        $child = $this->doSpawn(Stdio::null(), Stdio::null(), Stdio::null());
8✔
328

329
        return $child->wait($timeout);
7✔
330
    }
331

332
    /**
333
     * @throws Exception\StartFailedException
334
     * @throws Exception\RuntimeException
335
     */
336
    private function doSpawn(Stdio $stdin, Stdio $stdout, Stdio $stderr): Internal\Child
337
    {
338
        if (Str\contains($this->program, "\0")) {
89✔
339
            throw new Exception\RuntimeException('Command line contains NULL bytes.');
1✔
340
        }
341

342
        foreach ($this->arguments as $argument) {
88✔
343
            if (Str\contains($argument, "\0")) {
86✔
344
                throw new Exception\RuntimeException('Command line contains NULL bytes.');
1✔
345
            }
346
        }
347

348
        $command = $this->buildCommand();
87✔
349

350
        $environment = Dict\merge(Env\get_vars(), $this->environment);
87✔
351
        $workingDirectory = $this->workingDirectory ?? Env\current_dir();
87✔
352
        if ('' === $workingDirectory || !Filesystem\is_directory($workingDirectory)) {
87✔
353
            throw new Exception\RuntimeException('Working directory does not exist.');
5✔
354
        }
355

356
        $descriptors = [
82✔
357
            0 => $this->buildDescriptor($stdin, 'r', 0),
82✔
358
            1 => $this->buildDescriptor($stdout, 'w', 1),
82✔
359
            2 => $this->buildDescriptor($stderr, 'w', 2),
82✔
360
        ];
82✔
361

362
        $options = [];
81✔
363
        // @codeCoverageIgnoreStart
364
        if (OS\is_windows()) {
365
            $options['blocking_pipes'] = false;
366

367
            if (!$this->shell) {
368
                // Safe mode uses the array form which bypasses the shell already,
369
                // but set this defensively to ensure cmd.exe is never involved.
370
                $options['bypass_shell'] = true;
371
            }
372
        }
373

374
        // @codeCoverageIgnoreEnd
375

376
        $pipes = [];
81✔
377
        $process = proc_open($command, $descriptors, $pipes, $workingDirectory, $environment, $options);
81✔
378
        // @codeCoverageIgnoreStart
379
        if (!is_resource($process)) {
380
            throw new Exception\StartFailedException('Failed to start the process.');
381
        }
382

383
        // @codeCoverageIgnoreEnd
384

385
        $stdinHandle = null;
81✔
386
        if ($stdin->isPiped() && isset($pipes[0])) {
81✔
387
            $stdinHandle = new IO\CloseWriteStreamHandle($pipes[0]);
3✔
388
        }
389

390
        $stdoutHandle = null;
81✔
391
        if ($stdout->isPiped() && isset($pipes[1])) {
81✔
392
            $stdoutHandle = new IO\CloseReadStreamHandle($pipes[1]);
59✔
393
        }
394

395
        $stderrHandle = null;
81✔
396
        if ($stderr->isPiped() && isset($pipes[2])) {
81✔
397
            $stderrHandle = new IO\CloseReadStreamHandle($pipes[2]);
58✔
398
        }
399

400
        return new Internal\Child($process, $stdinHandle, $stdoutHandle, $stderrHandle);
81✔
401
    }
402

403
    /**
404
     * Builds the command for proc_open.
405
     *
406
     * In safe mode, returns an array which lets PHP handle argument escaping
407
     * internally on all platforms. In shell mode, returns a string that PHP
408
     * passes through the system shell (/bin/sh on Unix, cmd.exe on Windows).
409
     *
410
     * @return list<string>|string
411
     */
412
    private function buildCommand(): array|string
413
    {
414
        if ($this->shell) {
87✔
415
            return $this->program;
2✔
416
        }
417

418
        return [$this->program, ...$this->arguments];
85✔
419
    }
420

421
    /**
422
     * @param 'r'|'w' $mode
423
     * @param 0|1|2 $fd
424
     *
425
     * @return array{0: 'pipe', 1: 'r'|'w'}|array{0: 'file', 1: non-empty-string, 2: 'r'|'w'}|object|resource
426
     */
427
    private function buildDescriptor(Stdio $stdio, string $mode, int $fd): mixed
428
    {
429
        if ($stdio->isPiped()) {
82✔
430
            return ['pipe', $mode];
63✔
431
        }
432

433
        if ($stdio->isTty()) {
80✔
434
            // @codeCoverageIgnoreStart
435
            if (OS\is_windows()) {
436
                throw new Exception\RuntimeException('TTY is not supported on Windows.');
437
            }
438

439
            // @codeCoverageIgnoreEnd
440

441
            return ['file', '/dev/tty', $mode];
×
442
        }
443

444
        if ($stdio->isInherit()) {
80✔
445
            return match ($fd) {
×
NEW
446
                0 => STDIN,
×
NEW
447
                1 => defined('STDOUT') ? STDOUT : ['file', 'php://stdout', 'w'],
×
NEW
448
                default => defined('STDERR') ? STDERR : ['file', 'php://stderr', 'w'],
×
UNCOV
449
            };
×
450
        }
451

452
        if ($stdio->isHandle()) {
80✔
453
            $handle = $stdio->getHandle();
3✔
454
            if (null !== $handle) {
3✔
455
                $stream = $handle->getStream();
3✔
456
                if (null !== $stream) {
3✔
457
                    return $stream;
2✔
458
                }
459
            }
460

461
            throw new Exception\RuntimeException('The stream handle is closed.');
1✔
462
        }
463

464
        // Null mode
465
        // @codeCoverageIgnoreStart
466
        if (OS\is_windows()) {
467
            return ['file', 'NUL', $mode];
468
        }
469

470
        // @codeCoverageIgnoreEnd
471

472
        return ['file', '/dev/null', $mode];
77✔
473
    }
474
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc