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}