Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
40 / 40
96.43% covered (success)
96.43%
54 / 56
59.38% covered (warning)
59.38%
19 / 32
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stdio
100.00% covered (success)
100.00%
40 / 40
96.43% covered (success)
96.43%
54 / 56
59.38% covered (warning)
59.38%
19 / 32
100.00% covered (success)
100.00%
12 / 12
128.79
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 write
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
4 / 4
0.00% covered (danger)
0.00%
0 / 2
100.00% covered (success)
100.00%
1 / 1
1
 read
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
8.12
 colors
100.00% covered (success)
100.00%
11 / 11
92.31% covered (success)
92.31%
12 / 13
71.43% covered (warning)
71.43%
5 / 7
100.00% covered (success)
100.00%
1 / 1
10.89
 interactive
100.00% covered (success)
100.00%
1 / 1
66.67% covered (warning)
66.67%
2 / 3
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 width
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
9 / 9
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
13.78
 line
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 stty
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
3
 stream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 stdout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stderr
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 stdin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 open
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
0.00% covered (danger)
0.00%
0 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use Override;
8use RuntimeException;
9
10/**
11 * The process streams, or other targets such as files or `/dev/tty`.
12 *
13 * Targets open on first use; one that cannot be opened throws a
14 * RuntimeException then. The output and the error output share one
15 * stream when their targets are the same. Colors are detected per
16 * stream unless `$colors` decides for both.
17 *
18 * @api
19 */
20final class Stdio implements Terminal
21{
22    /** @var null|resource */
23    private mixed $stream = null;
24
25    /** @var null|resource */
26    private mixed $errorStream = null;
27
28    /** @var null|resource */
29    private mixed $inputStream = null;
30
31    private ?int $width = null;
32
33    public function __construct(
34        private readonly string $output = 'php://stdout',
35        private readonly string $errorOutput = 'php://stderr',
36        private readonly string $input = 'php://stdin',
37        private readonly ?bool $colors = null,
38    ) {}
39
40    #[Override]
41    public function write(string $text, bool $error = false): void
42    {
43        $stream = $this->stream($error);
44
45        // A reader that went away, like `| head -1`, fails every write; the
46        // output is lost anyway and must not add a notice per line.
47        set_error_handler(static fn(): bool => true);
48
49        try {
50            fwrite($stream, $text);
51            fflush($stream);
52        } finally {
53            restore_error_handler();
54        }
55    }
56
57    /**
58     * Hidden input switches the terminal echo off with stty and throws a
59     * RuntimeException if that fails. On Windows, or without a terminal,
60     * the line is read visibly.
61     */
62    #[Override]
63    public function read(bool $hidden = false): ?string
64    {
65        $stream = $this->stdin();
66
67        // No stty on Windows: hidden input reads visibly there.
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
69            // @codeCoverageIgnoreStart
70            // Needs a real terminal; HiddenInputTest drives this in a child
71            // process, outside the coverage run.
72            $previous = $this->stty($stream, '-g');
73            $this->stty($stream, '-echo');
74
75            try {
76                return $this->line(fgets($stream));
77            } finally {
78                // Restore the saved terminal state rather than assuming
79                // echo was on, even when reading throws.
80                $this->stty($stream, $previous === '' ? 'echo' : $previous);
81                $this->write(PHP_EOL);
82            }
83
84            // @codeCoverageIgnoreEnd
85        }
86
87        return $this->line(fgets($stream));
88    }
89
90    /**
91     * `NO_COLOR` turns colors off, `FORCE_COLOR` on (`0` or `false` off);
92     * otherwise only a terminal gets them, unless `TERM` is `dumb`.
93     */
94    #[Override]
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
98            return $this->colors;
99        }
100
101        $noColor = getenv('NO_COLOR');
102
103        if ($noColor !== false && $noColor !== '') {
104            return false;
105        }
106
107        $force = getenv('FORCE_COLOR');
108
109        if ($force !== false) {
110            return $force !== '0' && strtolower($force) !== 'false';
111        }
112
113        $stream = $this->stream($error);
114        $terminal = stream_isatty($stream) && getenv('TERM') !== 'dumb';
115
116        // @codeCoverageIgnoreStart
117        if (DIRECTORY_SEPARATOR === '\\' && $terminal) {
118            // VT100 processing is off by default in cmd/PowerShell;
119            // enabling it reports whether the console supports it.
120            return sapi_windows_vt100_support($stream, enable: true);
121        }
122
123        // @codeCoverageIgnoreEnd
124
125        return $terminal;
126    }
127
128    /**
129     * True when both the input and the output are terminals.
130     */
131    #[Override]
132    public function interactive(): bool
133    {
134        return stream_isatty($this->stdin()) && stream_isatty($this->stdout());
135    }
136
137    /**
138     * `COLUMNS`, else the terminal's width, else 80; read once.
139     */
140    #[Override]
141    public function width(): int
142    {
143        if ($this->width !== null) {
144            return $this->width;
145        }
146
147        $columns = (int) getenv('COLUMNS');
148
149        // No tput on Windows; shell_exec would leak its error output.
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
151            // @codeCoverageIgnoreStart
152            /** @psalm-suppress ForbiddenCode A constant command; no input reaches the shell */
153            $columns = (int) shell_exec('tput cols 2>/dev/null');
154
155            // @codeCoverageIgnoreEnd
156        }
157
158        if ($columns < 1) {
159            // @codeCoverageIgnoreStart
160            $columns = 80;
161
162            // @codeCoverageIgnoreEnd
163        }
164
165        return $this->width = $columns;
166    }
167
168    private function line(string|false $line): ?string
169    {
170        return $line === false ? null : rtrim($line, characters: "\r\n");
171    }
172
173    /**
174     * Runs stty on the input terminal and returns its output.
175     *
176     * The stream becomes stty's STDIN: the process STDIN may be another
177     * file, e.g. when only the prompts read from `/dev/tty`. Failures
178     * throw, so a hidden prompt never reads while echo is still on.
179     *
180     * @codeCoverageIgnore
181     *
182     * @param resource $stream
183     */
184    private function stty(mixed $stream, string $arg): string
185    {
186        set_error_handler(static fn(): bool => true);
187
188        try {
189            $process = proc_open(['stty', $arg], [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
190        } finally {
191            restore_error_handler();
192        }
193
194        if ($process === false) {
195            throw new RuntimeException('Could not run stty to hide the input');
196        }
197
198        $output = (string) stream_get_contents($pipes[1]);
199        $error = trim((string) stream_get_contents($pipes[2]));
200
201        if (proc_close($process) !== 0) {
202            throw new RuntimeException('Could not switch the terminal echo for hidden input: ' . $error);
203        }
204
205        return trim($output);
206    }
207
208    /** @return resource */
209    private function stream(bool $error): mixed
210    {
211        return $error ? $this->stderr() : $this->stdout();
212    }
213
214    /** @return resource */
215    private function stdout(): mixed
216    {
217        return $this->stream ??= $this->open($this->output, 'w');
218    }
219
220    /** @return resource */
221    private function stderr(): mixed
222    {
223        // One stream for a shared target: a second one would truncate
224        // the file and overwrite what the first wrote.
225        return $this->errorStream ??= $this->errorOutput === $this->output
226            ? $this->stdout()
227            : $this->open($this->errorOutput, 'w');
228    }
229
230    /** @return resource */
231    private function stdin(): mixed
232    {
233        return $this->inputStream ??= $this->open($this->input, 'r');
234    }
235
236    /** @return resource */
237    private function open(string $target, string $mode): mixed
238    {
239        set_error_handler(static fn(): bool => true);
240
241        try {
242            $stream = fopen($target, $mode);
243        } finally {
244            restore_error_handler();
245        }
246
247        if ($stream === false) {
248            throw new RuntimeException("Could not open stream '{$target}'");
249        }
250
251        return $stream;
252    }
253}

Paths

Below are the source code lines that represent each code path as identified by Xdebug. Please note a path is not necessarily coterminous with a line, a line may contain multiple paths and therefore show up more than once. Please also be aware that some paths may include implicit rather than explicit branches, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Stdio->__construct
34        private readonly string $output = 'php://stdout',
35        private readonly string $errorOutput = 'php://stderr',
36        private readonly string $input = 'php://stdin',
37        private readonly ?bool $colors = null,
38    ) {}
Stdio->colors
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
 
98            return $this->colors;
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
 
101        $noColor = getenv('NO_COLOR');
102
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
104            return false;
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
 
101        $noColor = getenv('NO_COLOR');
102
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
107        $force = getenv('FORCE_COLOR');
108
109        if ($force !== false) {
 
110            return $force !== '0' && strtolower($force) !== 'false';
 
110            return $force !== '0' && strtolower($force) !== 'false';
 
110            return $force !== '0' && strtolower($force) !== 'false';
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
 
101        $noColor = getenv('NO_COLOR');
102
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
107        $force = getenv('FORCE_COLOR');
108
109        if ($force !== false) {
 
110            return $force !== '0' && strtolower($force) !== 'false';
 
110            return $force !== '0' && strtolower($force) !== 'false';
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
 
101        $noColor = getenv('NO_COLOR');
102
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
104            return false;
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
 
101        $noColor = getenv('NO_COLOR');
102
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
107        $force = getenv('FORCE_COLOR');
108
109        if ($force !== false) {
 
110            return $force !== '0' && strtolower($force) !== 'false';
 
110            return $force !== '0' && strtolower($force) !== 'false';
 
110            return $force !== '0' && strtolower($force) !== 'false';
95    public function colors(bool $error = false): bool
96    {
97        if ($this->colors !== null) {
 
101        $noColor = getenv('NO_COLOR');
102
103        if ($noColor !== false && $noColor !== '') {
 
103        if ($noColor !== false && $noColor !== '') {
 
107        $force = getenv('FORCE_COLOR');
108
109        if ($force !== false) {
 
110            return $force !== '0' && strtolower($force) !== 'false';
 
110            return $force !== '0' && strtolower($force) !== 'false';
Stdio->interactive
134        return stream_isatty($this->stdin()) && stream_isatty($this->stdout());
 
134        return stream_isatty($this->stdin()) && stream_isatty($this->stdout());
 
134        return stream_isatty($this->stdin()) && stream_isatty($this->stdout());
135    }
134        return stream_isatty($this->stdin()) && stream_isatty($this->stdout());
 
134        return stream_isatty($this->stdin()) && stream_isatty($this->stdout());
135    }
Stdio->line
168    private function line(string|false $line): ?string
169    {
170        return $line === false ? null : rtrim($line, characters: "\r\n");
 
170        return $line === false ? null : rtrim($line, characters: "\r\n");
 
170        return $line === false ? null : rtrim($line, characters: "\r\n");
171    }
168    private function line(string|false $line): ?string
169    {
170        return $line === false ? null : rtrim($line, characters: "\r\n");
 
170        return $line === false ? null : rtrim($line, characters: "\r\n");
 
170        return $line === false ? null : rtrim($line, characters: "\r\n");
171    }
Stdio->open
237    private function open(string $target, string $mode): mixed
238    {
239        set_error_handler(static fn(): bool => true);
240
241        try {
242            $stream = fopen($target, $mode);
243        } finally {
 
244            restore_error_handler();
237    private function open(string $target, string $mode): mixed
238    {
239        set_error_handler(static fn(): bool => true);
240
241        try {
242            $stream = fopen($target, $mode);
243        } finally {
 
243        } finally {
 
247        if ($stream === false) {
 
248            throw new RuntimeException("Could not open stream '{$target}'");
237    private function open(string $target, string $mode): mixed
238    {
239        set_error_handler(static fn(): bool => true);
240
241        try {
242            $stream = fopen($target, $mode);
243        } finally {
 
243        } finally {
 
247        if ($stream === false) {
 
251        return $stream;
252    }
Stdio->read
63    public function read(bool $hidden = false): ?string
64    {
65        $stream = $this->stdin();
66
67        // No stty on Windows: hidden input reads visibly there.
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
87        return $this->line(fgets($stream));
88    }
63    public function read(bool $hidden = false): ?string
64    {
65        $stream = $this->stdin();
66
67        // No stty on Windows: hidden input reads visibly there.
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
87        return $this->line(fgets($stream));
88    }
63    public function read(bool $hidden = false): ?string
64    {
65        $stream = $this->stdin();
66
67        // No stty on Windows: hidden input reads visibly there.
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
87        return $this->line(fgets($stream));
88    }
63    public function read(bool $hidden = false): ?string
64    {
65        $stream = $this->stdin();
66
67        // No stty on Windows: hidden input reads visibly there.
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
68        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
 
87        return $this->line(fgets($stream));
88    }
Stdio->stderr
225        return $this->errorStream ??= $this->errorOutput === $this->output
 
226            ? $this->stdout()
 
225        return $this->errorStream ??= $this->errorOutput === $this->output
226            ? $this->stdout()
227            : $this->open($this->errorOutput, 'w');
228    }
225        return $this->errorStream ??= $this->errorOutput === $this->output
 
225        return $this->errorStream ??= $this->errorOutput === $this->output
226            ? $this->stdout()
227            : $this->open($this->errorOutput, 'w');
 
225        return $this->errorStream ??= $this->errorOutput === $this->output
226            ? $this->stdout()
227            : $this->open($this->errorOutput, 'w');
228    }
Stdio->stdin
233        return $this->inputStream ??= $this->open($this->input, 'r');
234    }
Stdio->stdout
217        return $this->stream ??= $this->open($this->output, 'w');
218    }
Stdio->stream
209    private function stream(bool $error): mixed
210    {
211        return $error ? $this->stderr() : $this->stdout();
 
211        return $error ? $this->stderr() : $this->stdout();
 
211        return $error ? $this->stderr() : $this->stdout();
212    }
209    private function stream(bool $error): mixed
210    {
211        return $error ? $this->stderr() : $this->stdout();
 
211        return $error ? $this->stderr() : $this->stdout();
 
211        return $error ? $this->stderr() : $this->stdout();
212    }
Stdio->width
143        if ($this->width !== null) {
 
144            return $this->width;
143        if ($this->width !== null) {
 
147        $columns = (int) getenv('COLUMNS');
148
149        // No tput on Windows; shell_exec would leak its error output.
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
158        if ($columns < 1) {
 
165        return $this->width = $columns;
166    }
143        if ($this->width !== null) {
 
147        $columns = (int) getenv('COLUMNS');
148
149        // No tput on Windows; shell_exec would leak its error output.
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
158        if ($columns < 1) {
 
165        return $this->width = $columns;
166    }
143        if ($this->width !== null) {
 
147        $columns = (int) getenv('COLUMNS');
148
149        // No tput on Windows; shell_exec would leak its error output.
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
158        if ($columns < 1) {
 
165        return $this->width = $columns;
166    }
143        if ($this->width !== null) {
 
147        $columns = (int) getenv('COLUMNS');
148
149        // No tput on Windows; shell_exec would leak its error output.
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
150        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
 
158        if ($columns < 1) {
 
165        return $this->width = $columns;
166    }
Stdio->write
41    public function write(string $text, bool $error = false): void
42    {
43        $stream = $this->stream($error);
44
45        // A reader that went away, like `| head -1`, fails every write; the
46        // output is lost anyway and must not add a notice per line.
47        set_error_handler(static fn(): bool => true);
48
49        try {
50            fwrite($stream, $text);
51            fflush($stream);
52        } finally {
 
53            restore_error_handler();
41    public function write(string $text, bool $error = false): void
42    {
43        $stream = $this->stream($error);
44
45        // A reader that went away, like `| head -1`, fails every write; the
46        // output is lost anyway and must not add a notice per line.
47        set_error_handler(static fn(): bool => true);
48
49        try {
50            fwrite($stream, $text);
51            fflush($stream);
52        } finally {
 
52        } finally {
 
55    }
{closure:/workspace/celema/console/src/Stdio.php:239-239}
239        set_error_handler(static fn(): bool => true);
{closure:/workspace/celema/console/src/Stdio.php:47-47}
47        set_error_handler(static fn(): bool => true);