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

codeigniter4 / CodeIgniter4 / 12282713649

11 Dec 2024 06:34PM UTC coverage: 84.432% (+0.007%) from 84.425%
12282713649

Pull #9302

github

web-flow
Merge c866e750c into 0b0126cf8
Pull Request #9302: refactor: fix `phpstan` only boolean allowed

108 of 115 new or added lines in 35 files covered. (93.91%)

1 existing line in 1 file now uncovered.

20420 of 24185 relevant lines covered (84.43%)

189.63 hits per line

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

88.4
/system/CLI/CLI.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\CLI;
15

16
use CodeIgniter\CLI\Exceptions\CLIException;
17
use InvalidArgumentException;
18
use Throwable;
19

20
/**
21
 * Set of static methods useful for CLI request handling.
22
 *
23
 * Portions of this code were initially from the FuelPHP Framework,
24
 * version 1.7.x, and used here under the MIT license they were
25
 * originally made available under. Reference: http://fuelphp.com
26
 *
27
 * Some of the code in this class is Windows-specific, and not
28
 * possible to test using travis-ci. It has been phpunit-annotated
29
 * to prevent messing up code coverage.
30
 *
31
 * @see \CodeIgniter\CLI\CLITest
32
 */
33
class CLI
34
{
35
    /**
36
     * Is the readline library on the system?
37
     *
38
     * @var bool
39
     *
40
     * @deprecated 4.4.2 Should be protected, and no longer used.
41
     * @TODO Fix to camelCase in the next major version.
42
     */
43
    public static $readline_support = false;
44

45
    /**
46
     * The message displayed at prompts.
47
     *
48
     * @var string
49
     *
50
     * @deprecated 4.4.2 Should be protected.
51
     * @TODO Fix to camelCase in the next major version.
52
     */
53
    public static $wait_msg = 'Press any key to continue...';
54

55
    /**
56
     * Has the class already been initialized?
57
     *
58
     * @var bool
59
     */
60
    protected static $initialized = false;
61

62
    /**
63
     * Foreground color list
64
     *
65
     * @var array<string, string>
66
     *
67
     * @TODO Fix to camelCase in the next major version.
68
     */
69
    protected static $foreground_colors = [
70
        'black'        => '0;30',
71
        'dark_gray'    => '1;30',
72
        'blue'         => '0;34',
73
        'dark_blue'    => '0;34',
74
        'light_blue'   => '1;34',
75
        'green'        => '0;32',
76
        'light_green'  => '1;32',
77
        'cyan'         => '0;36',
78
        'light_cyan'   => '1;36',
79
        'red'          => '0;31',
80
        'light_red'    => '1;31',
81
        'purple'       => '0;35',
82
        'light_purple' => '1;35',
83
        'yellow'       => '0;33',
84
        'light_yellow' => '1;33',
85
        'light_gray'   => '0;37',
86
        'white'        => '1;37',
87
    ];
88

89
    /**
90
     * Background color list
91
     *
92
     * @var array<string, string>
93
     *
94
     * @TODO Fix to camelCase in the next major version.
95
     */
96
    protected static $background_colors = [
97
        'black'      => '40',
98
        'red'        => '41',
99
        'green'      => '42',
100
        'yellow'     => '43',
101
        'blue'       => '44',
102
        'magenta'    => '45',
103
        'cyan'       => '46',
104
        'light_gray' => '47',
105
    ];
106

107
    /**
108
     * List of array segments.
109
     *
110
     * @var array
111
     */
112
    protected static $segments = [];
113

114
    /**
115
     * @var array
116
     */
117
    protected static $options = [];
118

119
    /**
120
     * Helps track internally whether the last
121
     * output was a "write" or a "print" to
122
     * keep the output clean and as expected.
123
     *
124
     * @var string|null
125
     */
126
    protected static $lastWrite;
127

128
    /**
129
     * Height of the CLI window
130
     *
131
     * @var int|null
132
     */
133
    protected static $height;
134

135
    /**
136
     * Width of the CLI window
137
     *
138
     * @var int|null
139
     */
140
    protected static $width;
141

142
    /**
143
     * Whether the current stream supports colored output.
144
     *
145
     * @var bool
146
     */
147
    protected static $isColored = false;
148

149
    /**
150
     * Input and Output for CLI.
151
     */
152
    protected static ?InputOutput $io = null;
153

154
    /**
155
     * Static "constructor".
156
     *
157
     * @return void
158
     */
159
    public static function init()
160
    {
161
        if (is_cli()) {
36✔
162
            // Readline is an extension for PHP that makes interactivity with PHP
163
            // much more bash-like.
164
            // http://www.php.net/manual/en/readline.installation.php
165
            static::$readline_support = extension_loaded('readline');
36✔
166

167
            // clear segments & options to keep testing clean
168
            static::$segments = [];
36✔
169
            static::$options  = [];
36✔
170

171
            // Check our stream resource for color support
172
            static::$isColored = static::hasColorSupport(STDOUT);
36✔
173

174
            static::parseCommandLine();
36✔
175

176
            static::$initialized = true;
36✔
177
        } elseif (! defined('STDOUT')) {
×
178
            // If the command is being called from a controller
179
            // we need to define STDOUT ourselves
180
            // For "! defined('STDOUT')" see: https://github.com/codeigniter4/CodeIgniter4/issues/7047
181
            define('STDOUT', 'php://output'); // @codeCoverageIgnore
×
182
        }
183

184
        static::resetInputOutput();
36✔
185
    }
186

187
    /**
188
     * Get input from the shell, using readline or the standard STDIN
189
     *
190
     * Named options must be in the following formats:
191
     * php index.php user -v --v -name=John --name=John
192
     *
193
     * @param string|null $prefix You may specify a string with which to prompt the user.
194
     */
195
    public static function input(?string $prefix = null): string
196
    {
197
        return static::$io->input($prefix);
×
198
    }
199

200
    /**
201
     * Asks the user for input.
202
     *
203
     * Usage:
204
     *
205
     * // Takes any input
206
     * $color = CLI::prompt('What is your favorite color?');
207
     *
208
     * // Takes any input, but offers default
209
     * $color = CLI::prompt('What is your favourite color?', 'white');
210
     *
211
     * // Will validate options with the in_list rule and accept only if one of the list
212
     * $color = CLI::prompt('What is your favourite color?', array('red','blue'));
213
     *
214
     * // Do not provide options but requires a valid email
215
     * $email = CLI::prompt('What is your email?', null, 'required|valid_email');
216
     *
217
     * @param string                  $field      Output "field" question
218
     * @param list<int|string>|string $options    String to a default value, array to a list of options (the first option will be the default value)
219
     * @param array|string|null       $validation Validation rules
220
     *
221
     * @return string The user input
222
     */
223
    public static function prompt(string $field, $options = null, $validation = null): string
224
    {
225
        $extraOutput = '';
10✔
226
        $default     = '';
10✔
227

228
        if (isset($validation) && ! is_array($validation) && ! is_string($validation)) {
10✔
229
            throw new InvalidArgumentException('$rules can only be of type string|array');
×
230
        }
231

232
        if (! is_array($validation)) {
10✔
233
            $validation = ((string) $validation !== '') ? explode('|', $validation) : [];
10✔
234
        }
235

236
        if (is_string($options)) {
10✔
237
            $extraOutput = ' [' . static::color($options, 'green') . ']';
2✔
238
            $default     = $options;
2✔
239
        }
240

241
        if (is_array($options) && $options !== []) {
10✔
242
            $opts               = $options;
4✔
243
            $extraOutputDefault = static::color((string) $opts[0], 'green');
4✔
244

245
            unset($opts[0]);
4✔
246

247
            if ($opts === []) {
4✔
248
                $extraOutput = $extraOutputDefault;
×
249
            } else {
250
                $extraOutput  = '[' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']';
4✔
251
                $validation[] = 'in_list[' . implode(', ', $options) . ']';
4✔
252
            }
253

254
            $default = $options[0];
4✔
255
        }
256

257
        static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
10✔
258

259
        // Read the input from keyboard.
260
        $input = trim(static::$io->input());
10✔
261
        $input = ($input === '') ? (string) $default : $input;
10✔
262

263
        if ($validation !== []) {
10✔
264
            while (! static::validate('"' . trim($field) . '"', $input, $validation)) {
4✔
265
                $input = static::prompt($field, $options, $validation);
1✔
266
            }
267
        }
268

269
        return $input;
10✔
270
    }
271

272
    /**
273
     * prompt(), but based on the option's key
274
     *
275
     * @param array|string      $text       Output "field" text or an one or two value array where the first value is the text before listing the options
276
     *                                      and the second value the text before asking to select one option. Provide empty string to omit
277
     * @param array             $options    A list of options (array(key => description)), the first option will be the default value
278
     * @param array|string|null $validation Validation rules
279
     *
280
     * @return string The selected key of $options
281
     */
282
    public static function promptByKey($text, array $options, $validation = null): string
283
    {
284
        if (is_string($text)) {
4✔
285
            $text = [$text];
3✔
286
        } elseif (! is_array($text)) {
1✔
287
            throw new InvalidArgumentException('$text can only be of type string|array');
×
288
        }
289

290
        CLI::isZeroOptions($options);
4✔
291

292
        if ($line = array_shift($text)) {
4✔
293
            CLI::write($line);
4✔
294
        }
295

296
        CLI::printKeysAndValues($options);
4✔
297

298
        return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation);
4✔
299
    }
300

301
    /**
302
     * This method is the same as promptByKey(), but this method supports multiple keys, separated by commas.
303
     *
304
     * @param string $text    Output "field" text or an one or two value array where the first value is the text before listing the options
305
     *                        and the second value the text before asking to select one option. Provide empty string to omit
306
     * @param array  $options A list of options (array(key => description)), the first option will be the default value
307
     *
308
     * @return array The selected key(s) and value(s) of $options
309
     */
310
    public static function promptByMultipleKeys(string $text, array $options): array
311
    {
312
        CLI::isZeroOptions($options);
3✔
313

314
        $extraOutputDefault = static::color('0', 'green');
3✔
315
        $opts               = $options;
3✔
316
        unset($opts[0]);
3✔
317

318
        if ($opts === []) {
3✔
319
            $extraOutput = $extraOutputDefault;
×
320
        } else {
321
            $optsKey = [];
3✔
322

323
            foreach (array_keys($opts) as $key) {
3✔
324
                $optsKey[] = $key;
3✔
325
            }
326
            $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $optsKey) . ']';
3✔
327
            $extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput;
3✔
328
        }
329

330
        CLI::write($text);
3✔
331
        CLI::printKeysAndValues($options);
3✔
332
        CLI::newLine();
3✔
333

334
        $input = static::prompt($extraOutput);
3✔
335
        $input = ($input === '') ? '0' : $input; // 0 is default
3✔
336

337
        // validation
338
        while (true) {
3✔
339
            $pattern = preg_match_all('/^\d+(,\d+)*$/', trim($input));
3✔
340

341
            // separate input by comma and convert all to an int[]
342
            $inputToArray = array_map(static fn ($value) => (int) $value, explode(',', $input));
3✔
343
            // find max from key of $options
344
            $maxOptions = array_key_last($options);
3✔
345
            // find max from input
346
            $maxInput = max($inputToArray);
3✔
347

348
            // return the prompt again if $input contain(s) non-numeric character, except a comma.
349
            // And if max from $options less than max from input,
350
            // it means user tried to access null value in $options
351
            if (! $pattern || $maxOptions < $maxInput) {
3✔
352
                static::error('Please select correctly.');
×
353
                CLI::newLine();
×
354

355
                $input = static::prompt($extraOutput);
×
356
                $input = ($input === '') ? '0' : $input;
×
357
            } else {
358
                break;
3✔
359
            }
360
        }
361

362
        $input = [];
3✔
363

364
        foreach ($options as $key => $description) {
3✔
365
            foreach ($inputToArray as $inputKey) {
3✔
366
                if ($key === $inputKey) {
3✔
367
                    $input[$key] = $description;
3✔
368
                }
369
            }
370
        }
371

372
        return $input;
3✔
373
    }
374

375
    // --------------------------------------------------------------------
376
    // Utility for promptBy...
377
    // --------------------------------------------------------------------
378

379
    /**
380
     * Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array.
381
     */
382
    private static function isZeroOptions(array $options): void
383
    {
384
        if ($options === []) {
7✔
385
            throw new InvalidArgumentException('No options to select from were provided');
×
386
        }
387
    }
388

389
    /**
390
     * Print each key and value one by one
391
     */
392
    private static function printKeysAndValues(array $options): void
393
    {
394
        // +2 for the square brackets around the key
395
        $keyMaxLength = max(array_map(mb_strwidth(...), array_keys($options))) + 2;
7✔
396

397
        foreach ($options as $key => $description) {
7✔
398
            $name = str_pad('  [' . $key . ']  ', $keyMaxLength + 4, ' ');
7✔
399
            CLI::write(CLI::color($name, 'green') . CLI::wrap($description, 125, $keyMaxLength + 4));
7✔
400
        }
401
    }
402

403
    // --------------------------------------------------------------------
404
    // End Utility for promptBy...
405
    // --------------------------------------------------------------------
406

407
    /**
408
     * Validate one prompt "field" at a time
409
     *
410
     * @param string       $field Prompt "field" output
411
     * @param string       $value Input value
412
     * @param array|string $rules Validation rules
413
     */
414
    protected static function validate(string $field, string $value, $rules): bool
415
    {
416
        $label      = $field;
4✔
417
        $field      = 'temp';
4✔
418
        $validation = service('validation', null, false);
4✔
419
        $validation->setRules([
4✔
420
            $field => [
4✔
421
                'label' => $label,
4✔
422
                'rules' => $rules,
4✔
423
            ],
4✔
424
        ]);
4✔
425
        $validation->run([$field => $value]);
4✔
426

427
        if ($validation->hasError($field)) {
4✔
428
            static::error($validation->getError($field));
1✔
429

430
            return false;
1✔
431
        }
432

433
        return true;
4✔
434
    }
435

436
    /**
437
     * Outputs a string to the CLI without any surrounding newlines.
438
     * Useful for showing repeating elements on a single line.
439
     *
440
     * @return void
441
     */
442
    public static function print(string $text = '', ?string $foreground = null, ?string $background = null)
443
    {
444
        if ((string) $foreground !== '' || (string) $background !== '') {
11✔
445
            $text = static::color($text, $foreground, $background);
2✔
446
        }
447

448
        static::$lastWrite = null;
11✔
449

450
        static::fwrite(STDOUT, $text);
11✔
451
    }
452

453
    /**
454
     * Outputs a string to the cli on its own line.
455
     *
456
     * @return void
457
     */
458
    public static function write(string $text = '', ?string $foreground = null, ?string $background = null)
459
    {
460
        if ((string) $foreground !== '' || (string) $background !== '') {
168✔
461
            $text = static::color($text, $foreground, $background);
110✔
462
        }
463

464
        if (static::$lastWrite !== 'write') {
168✔
465
            $text              = PHP_EOL . $text;
11✔
466
            static::$lastWrite = 'write';
11✔
467
        }
468

469
        static::fwrite(STDOUT, $text . PHP_EOL);
168✔
470
    }
471

472
    /**
473
     * Outputs an error to the CLI using STDERR instead of STDOUT
474
     *
475
     * @return void
476
     */
477
    public static function error(string $text, string $foreground = 'light_red', ?string $background = null)
478
    {
479
        // Check color support for STDERR
480
        $stdout            = static::$isColored;
29✔
481
        static::$isColored = static::hasColorSupport(STDERR);
29✔
482

483
        if ($foreground !== '' || (string) $background !== '') {
29✔
484
            $text = static::color($text, $foreground, $background);
29✔
485
        }
486

487
        static::fwrite(STDERR, $text . PHP_EOL);
29✔
488

489
        // return STDOUT color support
490
        static::$isColored = $stdout;
29✔
491
    }
492

493
    /**
494
     * Beeps a certain number of times.
495
     *
496
     * @param int $num The number of times to beep
497
     *
498
     * @return void
499
     */
500
    public static function beep(int $num = 1)
501
    {
502
        echo str_repeat("\x07", $num);
2✔
503
    }
504

505
    /**
506
     * Waits a certain number of seconds, optionally showing a wait message and
507
     * waiting for a key press.
508
     *
509
     * @param int  $seconds   Number of seconds
510
     * @param bool $countdown Show a countdown or not
511
     *
512
     * @return void
513
     */
514
    public static function wait(int $seconds, bool $countdown = false)
515
    {
516
        if ($countdown) {
10✔
517
            $time = $seconds;
1✔
518

519
            while ($time > 0) {
1✔
520
                static::fwrite(STDOUT, $time . '... ');
1✔
521
                sleep(1);
1✔
522
                $time--;
1✔
523
            }
524

525
            static::write();
1✔
526
        } elseif ($seconds > 0) {
10✔
527
            sleep($seconds);
9✔
528
        } else {
529
            static::write(static::$wait_msg);
1✔
530
            static::$io->input();
1✔
531
        }
532
    }
533

534
    /**
535
     * if operating system === windows
536
     *
537
     * @deprecated 4.3.0 Use `is_windows()` instead
538
     */
539
    public static function isWindows(): bool
540
    {
541
        return is_windows();
×
542
    }
543

544
    /**
545
     * Enter a number of empty lines
546
     *
547
     * @return void
548
     */
549
    public static function newLine(int $num = 1)
550
    {
551
        // Do it once or more, write with empty string gives us a new line
552
        for ($i = 0; $i < $num; $i++) {
111✔
553
            static::write();
111✔
554
        }
555
    }
556

557
    /**
558
     * Clears the screen of output
559
     *
560
     * @return void
561
     */
562
    public static function clearScreen()
563
    {
564
        // Unix systems, and Windows with VT100 Terminal support (i.e. Win10)
565
        // can handle CSI sequences. For lower than Win10 we just shove in 40 new lines.
566
        is_windows() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT)
×
567
            ? static::newLine(40)
×
568
            : static::fwrite(STDOUT, "\033[H\033[2J");
×
569
    }
570

571
    /**
572
     * Returns the given text with the correct color codes for a foreground and
573
     * optionally a background color.
574
     *
575
     * @param string      $text       The text to color
576
     * @param string      $foreground The foreground color
577
     * @param string|null $background The background color
578
     * @param string|null $format     Other formatting to apply. Currently only 'underline' is understood
579
     *
580
     * @return string The color coded string
581
     */
582
    public static function color(string $text, string $foreground, ?string $background = null, ?string $format = null): string
583
    {
584
        if (! static::$isColored || $text === '') {
780✔
585
            return $text;
16✔
586
        }
587

588
        if (! array_key_exists($foreground, static::$foreground_colors)) {
766✔
589
            throw CLIException::forInvalidColor('foreground', $foreground);
1✔
590
        }
591

592
        if ((string) $background !== '' && ! array_key_exists($background, static::$background_colors)) {
765✔
593
            throw CLIException::forInvalidColor('background', $background);
2✔
594
        }
595

596
        $newText = '';
764✔
597

598
        // Detect if color method was already in use with this text
599
        if (str_contains($text, "\033[0m")) {
764✔
600
            $pattern = '/\\033\\[0;.+?\\033\\[0m/u';
4✔
601

602
            preg_match_all($pattern, $text, $matches);
4✔
603
            $coloredStrings = $matches[0];
4✔
604

605
            // No colored string found. Invalid strings with no `\033[0;??`.
606
            if ($coloredStrings === []) {
4✔
607
                return $newText . self::getColoredText($text, $foreground, $background, $format);
×
608
            }
609

610
            $nonColoredText = preg_replace(
4✔
611
                $pattern,
4✔
612
                '<<__colored_string__>>',
4✔
613
                $text
4✔
614
            );
4✔
615
            $nonColoredChunks = preg_split(
4✔
616
                '/<<__colored_string__>>/u',
4✔
617
                $nonColoredText
4✔
618
            );
4✔
619

620
            foreach ($nonColoredChunks as $i => $chunk) {
4✔
621
                if ($chunk !== '') {
4✔
622
                    $newText .= self::getColoredText($chunk, $foreground, $background, $format);
4✔
623
                }
624

625
                if (isset($coloredStrings[$i])) {
4✔
626
                    $newText .= $coloredStrings[$i];
4✔
627
                }
628
            }
629
        } else {
630
            $newText .= self::getColoredText($text, $foreground, $background, $format);
764✔
631
        }
632

633
        return $newText;
764✔
634
    }
635

636
    private static function getColoredText(string $text, string $foreground, ?string $background, ?string $format): string
637
    {
638
        $string = "\033[" . static::$foreground_colors[$foreground] . 'm';
764✔
639

640
        if ((string) $background !== '') {
764✔
641
            $string .= "\033[" . static::$background_colors[$background] . 'm';
23✔
642
        }
643

644
        if ($format === 'underline') {
764✔
645
            $string .= "\033[4m";
2✔
646
        }
647

648
        return $string . $text . "\033[0m";
764✔
649
    }
650

651
    /**
652
     * Get the number of characters in string having encoded characters
653
     * and ignores styles set by the color() function
654
     */
655
    public static function strlen(?string $string): int
656
    {
657
        if ((string) $string === '') {
35✔
658
            return 0;
18✔
659
        }
660

661
        foreach (static::$foreground_colors as $color) {
35✔
662
            $string = strtr($string, ["\033[" . $color . 'm' => '']);
35✔
663
        }
664

665
        foreach (static::$background_colors as $color) {
35✔
666
            $string = strtr($string, ["\033[" . $color . 'm' => '']);
35✔
667
        }
668

669
        $string = strtr($string, ["\033[4m" => '', "\033[0m" => '']);
35✔
670

671
        return mb_strwidth($string);
35✔
672
    }
673

674
    /**
675
     * Checks whether the current stream resource supports or
676
     * refers to a valid terminal type device.
677
     *
678
     * @param resource $resource
679
     */
680
    public static function streamSupports(string $function, $resource): bool
681
    {
682
        if (ENVIRONMENT === 'testing') {
59✔
683
            // In the current setup of the tests we cannot fully check
684
            // if the stream supports the function since we are using
685
            // filtered streams.
686
            return function_exists($function);
59✔
687
        }
688

689
        return function_exists($function) && @$function($resource); // @codeCoverageIgnore
×
690
    }
691

692
    /**
693
     * Returns true if the stream resource supports colors.
694
     *
695
     * This is tricky on Windows, because Cygwin, Msys2 etc. emulate pseudo
696
     * terminals via named pipes, so we can only check the environment.
697
     *
698
     * Reference: https://github.com/composer/xdebug-handler/blob/master/src/Process.php
699
     *
700
     * @param resource $resource
701
     */
702
    public static function hasColorSupport($resource): bool
703
    {
704
        // Follow https://no-color.org/
705
        if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) {
62✔
706
            return false;
15✔
707
        }
708

709
        if (getenv('TERM_PROGRAM') === 'Hyper') {
59✔
710
            return true;
1✔
711
        }
712

713
        if (is_windows()) {
58✔
714
            // @codeCoverageIgnoreStart
715
            return static::streamSupports('sapi_windows_vt100_support', $resource)
×
716
                || isset($_SERVER['ANSICON'])
×
717
                || getenv('ANSICON') !== false
×
718
                || getenv('ConEmuANSI') === 'ON'
×
719
                || getenv('TERM') === 'xterm';
×
720
            // @codeCoverageIgnoreEnd
721
        }
722

723
        return static::streamSupports('stream_isatty', $resource);
58✔
724
    }
725

726
    /**
727
     * Attempts to determine the width of the viewable CLI window.
728
     */
729
    public static function getWidth(int $default = 80): int
730
    {
731
        if (static::$width === null) {
13✔
732
            static::generateDimensions();
3✔
733
        }
734

735
        return static::$width ?: $default;
13✔
736
    }
737

738
    /**
739
     * Attempts to determine the height of the viewable CLI window.
740
     */
741
    public static function getHeight(int $default = 32): int
742
    {
743
        if (static::$height === null) {
1✔
744
            static::generateDimensions();
1✔
745
        }
746

747
        return static::$height ?: $default;
1✔
748
    }
749

750
    /**
751
     * Populates the CLI's dimensions.
752
     *
753
     * @return void
754
     */
755
    public static function generateDimensions()
756
    {
757
        try {
758
            if (is_windows()) {
3✔
759
                // Shells such as `Cygwin` and `Git bash` returns incorrect values
760
                // when executing `mode CON`, so we use `tput` instead
761
                if (getenv('TERM') || (($shell = getenv('SHELL')) && preg_match('/(?:bash|zsh)(?:\.exe)?$/', $shell))) {
×
762
                    static::$height = (int) exec('tput lines');
×
763
                    static::$width  = (int) exec('tput cols');
×
764
                } else {
765
                    $return = -1;
×
766
                    $output = [];
×
767
                    exec('mode CON', $output, $return);
×
768

769
                    // Look for the next lines ending in ": <number>"
770
                    // Searching for "Columns:" or "Lines:" will fail on non-English locales
NEW
771
                    if ($return === 0 && $output !== [] && preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches)) {
×
772
                        static::$height = (int) $matches[1];
×
773
                        static::$width  = (int) $matches[2];
×
774
                    }
775
                }
776
            } elseif (($size = exec('stty size')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) {
3✔
777
                static::$height = (int) $matches[1];
3✔
778
                static::$width  = (int) $matches[2];
3✔
779
            } else {
780
                static::$height = (int) exec('tput lines');
×
781
                static::$width  = (int) exec('tput cols');
3✔
782
            }
783
        } catch (Throwable $e) {
×
784
            // Reset the dimensions so that the default values will be returned later.
785
            // Then let the developer know of the error.
786
            static::$height = null;
×
787
            static::$width  = null;
×
788
            log_message('error', (string) $e);
×
789
        }
790
    }
791

792
    /**
793
     * Displays a progress bar on the CLI. You must call it repeatedly
794
     * to update it. Set $thisStep = false to erase the progress bar.
795
     *
796
     * @param bool|int $thisStep
797
     *
798
     * @return void
799
     */
800
    public static function showProgress($thisStep = 1, int $totalSteps = 10)
801
    {
802
        static $inProgress = false;
2✔
803

804
        // restore cursor position when progress is continuing.
805
        if ($inProgress !== false && $inProgress <= $thisStep) {
2✔
806
            static::fwrite(STDOUT, "\033[1A");
1✔
807
        }
808
        $inProgress = $thisStep;
2✔
809

810
        if ($thisStep !== false) {
2✔
811
            // Don't allow div by zero or negative numbers....
812
            $thisStep   = abs($thisStep);
1✔
813
            $totalSteps = $totalSteps < 1 ? 1 : $totalSteps;
1✔
814

815
            $percent = (int) (($thisStep / $totalSteps) * 100);
1✔
816
            $step    = (int) round($percent / 10);
1✔
817

818
            // Write the progress bar
819
            static::fwrite(STDOUT, "[\033[32m" . str_repeat('#', $step) . str_repeat('.', 10 - $step) . "\033[0m]");
1✔
820
            // Textual representation...
821
            static::fwrite(STDOUT, sprintf(' %3d%% Complete', $percent) . PHP_EOL);
1✔
822
        } else {
823
            static::fwrite(STDOUT, "\007");
1✔
824
        }
825
    }
826

827
    /**
828
     * Takes a string and writes it to the command line, wrapping to a maximum
829
     * width. If no maximum width is specified, will wrap to the window's max
830
     * width.
831
     *
832
     * If an int is passed into $pad_left, then all strings after the first
833
     * will pad with that many spaces to the left. Useful when printing
834
     * short descriptions that need to start on an existing line.
835
     */
836
    public static function wrap(?string $string = null, int $max = 0, int $padLeft = 0): string
837
    {
838
        if ((string) $string === '') {
12✔
839
            return '';
5✔
840
        }
841

842
        if ($max === 0) {
12✔
843
            $max = self::getWidth();
1✔
844
        }
845

846
        if (self::getWidth() < $max) {
12✔
847
            $max = self::getWidth();
12✔
848
        }
849

850
        $max -= $padLeft;
12✔
851

852
        $lines = wordwrap($string, $max, PHP_EOL);
12✔
853

854
        if ($padLeft > 0) {
12✔
855
            $lines = explode(PHP_EOL, $lines);
12✔
856

857
            $first = true;
12✔
858

859
            array_walk($lines, static function (&$line) use ($padLeft, &$first): void {
12✔
860
                if (! $first) {
12✔
861
                    $line = str_repeat(' ', $padLeft) . $line;
5✔
862
                } else {
863
                    $first = false;
12✔
864
                }
865
            });
12✔
866

867
            $lines = implode(PHP_EOL, $lines);
12✔
868
        }
869

870
        return $lines;
12✔
871
    }
872

873
    // --------------------------------------------------------------------
874
    // Command-Line 'URI' support
875
    // --------------------------------------------------------------------
876

877
    /**
878
     * Parses the command line it was called from and collects all
879
     * options and valid segments.
880
     *
881
     * @return void
882
     */
883
    protected static function parseCommandLine()
884
    {
885
        $args = $_SERVER['argv'] ?? [];
36✔
886
        array_shift($args); // scrap invoking program
36✔
887
        $optionValue = false;
36✔
888

889
        foreach ($args as $i => $arg) {
36✔
890
            // If there's no "-" at the beginning, then
891
            // this is probably an argument or an option value
892
            if (mb_strpos($arg, '-') !== 0) {
28✔
893
                if ($optionValue) {
27✔
894
                    // We have already included this in the previous
895
                    // iteration, so reset this flag
896
                    $optionValue = false;
22✔
897
                } else {
898
                    // Yup, it's a segment
899
                    static::$segments[] = $arg;
8✔
900
                }
901

902
                continue;
27✔
903
            }
904

905
            $arg   = ltrim($arg, '-');
25✔
906
            $value = null;
25✔
907

908
            if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) {
25✔
909
                $value       = $args[$i + 1];
22✔
910
                $optionValue = true;
22✔
911
            }
912

913
            static::$options[$arg] = $value;
25✔
914
        }
915
    }
916

917
    /**
918
     * Returns the command line string portions of the arguments, minus
919
     * any options, as a string. This is used to pass along to the main
920
     * CodeIgniter application.
921
     */
922
    public static function getURI(): string
923
    {
924
        return implode('/', static::$segments);
1✔
925
    }
926

927
    /**
928
     * Returns an individual segment.
929
     *
930
     * This ignores any options that might have been dispersed between
931
     * valid segments in the command:
932
     *
933
     *  // segment(3) is 'three', not '-f' or 'anOption'
934
     *  > php spark one two -f anOption three
935
     *
936
     * **IMPORTANT:** The index here is one-based instead of zero-based.
937
     *
938
     * @return string|null
939
     */
940
    public static function getSegment(int $index)
941
    {
942
        return static::$segments[$index - 1] ?? null;
2✔
943
    }
944

945
    /**
946
     * Returns the raw array of segments found.
947
     */
948
    public static function getSegments(): array
949
    {
950
        return static::$segments;
12✔
951
    }
952

953
    /**
954
     * Gets a single command-line option. Returns TRUE if the option
955
     * exists, but doesn't have a value, and is simply acting as a flag.
956
     *
957
     * @return string|true|null
958
     */
959
    public static function getOption(string $name)
960
    {
961
        if (! array_key_exists($name, static::$options)) {
88✔
962
            return null;
87✔
963
        }
964

965
        // If the option didn't have a value, simply return TRUE
966
        // so they know it was set, otherwise return the actual value.
967
        $val = static::$options[$name] ?? true;
2✔
968

969
        return $val;
2✔
970
    }
971

972
    /**
973
     * Returns the raw array of options found.
974
     */
975
    public static function getOptions(): array
976
    {
977
        return static::$options;
12✔
978
    }
979

980
    /**
981
     * Returns the options as a string, suitable for passing along on
982
     * the CLI to other commands.
983
     *
984
     * @param bool $useLongOpts Use '--' for long options?
985
     * @param bool $trim        Trim final string output?
986
     */
987
    public static function getOptionString(bool $useLongOpts = false, bool $trim = false): string
988
    {
989
        if (static::$options === []) {
4✔
990
            return '';
1✔
991
        }
992

993
        $out = '';
3✔
994

995
        foreach (static::$options as $name => $value) {
3✔
996
            if ($useLongOpts && mb_strlen($name) > 1) {
3✔
997
                $out .= "--{$name} ";
3✔
998
            } else {
999
                $out .= "-{$name} ";
3✔
1000
            }
1001

1002
            if ($value === null) {
3✔
1003
                continue;
2✔
1004
            }
1005

1006
            if (mb_strpos($value, ' ') !== false) {
3✔
1007
                $out .= "\"{$value}\" ";
1✔
1008
            } elseif ($value !== null) {
3✔
1009
                $out .= "{$value} ";
3✔
1010
            }
1011
        }
1012

1013
        return $trim ? trim($out) : $out;
3✔
1014
    }
1015

1016
    /**
1017
     * Returns a well formatted table
1018
     *
1019
     * @param array $tbody List of rows
1020
     * @param array $thead List of columns
1021
     *
1022
     * @return void
1023
     */
1024
    public static function table(array $tbody, array $thead = [])
1025
    {
1026
        // All the rows in the table will be here until the end
1027
        $tableRows = [];
34✔
1028

1029
        // We need only indexes and not keys
1030
        if ($thead !== []) {
34✔
1031
            $tableRows[] = array_values($thead);
32✔
1032
        }
1033

1034
        foreach ($tbody as $tr) {
34✔
1035
            $tableRows[] = array_values($tr);
34✔
1036
        }
1037

1038
        // Yes, it really is necessary to know this count
1039
        $totalRows = count($tableRows);
34✔
1040

1041
        // Store all columns lengths
1042
        // $all_cols_lengths[row][column] = length
1043
        $allColsLengths = [];
34✔
1044

1045
        // Store maximum lengths by column
1046
        // $max_cols_lengths[column] = length
1047
        $maxColsLengths = [];
34✔
1048

1049
        // Read row by row and define the longest columns
1050
        for ($row = 0; $row < $totalRows; $row++) {
34✔
1051
            $column = 0; // Current column index
34✔
1052

1053
            foreach ($tableRows[$row] as $col) {
34✔
1054
                // Sets the size of this column in the current row
1055
                $allColsLengths[$row][$column] = static::strlen((string) $col);
34✔
1056

1057
                // If the current column does not have a value among the larger ones
1058
                // or the value of this is greater than the existing one
1059
                // then, now, this assumes the maximum length
1060
                if (! isset($maxColsLengths[$column]) || $allColsLengths[$row][$column] > $maxColsLengths[$column]) {
34✔
1061
                    $maxColsLengths[$column] = $allColsLengths[$row][$column];
34✔
1062
                }
1063

1064
                // We can go check the size of the next column...
1065
                $column++;
34✔
1066
            }
1067
        }
1068

1069
        // Read row by row and add spaces at the end of the columns
1070
        // to match the exact column length
1071
        for ($row = 0; $row < $totalRows; $row++) {
34✔
1072
            $column = 0;
34✔
1073

1074
            foreach ($tableRows[$row] as $col) {
34✔
1075
                $diff = $maxColsLengths[$column] - static::strlen((string) $col);
34✔
1076

1077
                if ($diff !== 0) {
34✔
1078
                    $tableRows[$row][$column] .= str_repeat(' ', $diff);
33✔
1079
                }
1080

1081
                $column++;
34✔
1082
            }
1083
        }
1084

1085
        $table = '';
34✔
1086
        $cols  = '';
34✔
1087

1088
        // Joins columns and append the well formatted rows to the table
1089
        for ($row = 0; $row < $totalRows; $row++) {
34✔
1090
            // Set the table border-top
1091
            if ($row === 0) {
34✔
1092
                $cols = '+';
34✔
1093

1094
                foreach ($tableRows[$row] as $col) {
34✔
1095
                    $cols .= str_repeat('-', static::strlen((string) $col) + 2) . '+';
34✔
1096
                }
1097
                $table .= $cols . PHP_EOL;
34✔
1098
            }
1099

1100
            // Set the columns borders
1101
            $table .= '| ' . implode(' | ', $tableRows[$row]) . ' |' . PHP_EOL;
34✔
1102

1103
            // Set the thead and table borders-bottom
1104
            if (($row === 0 && $thead !== []) || ($row + 1 === $totalRows)) {
34✔
1105
                $table .= $cols . PHP_EOL;
34✔
1106
            }
1107
        }
1108

1109
        static::write($table);
34✔
1110
    }
1111

1112
    /**
1113
     * While the library is intended for use on CLI commands,
1114
     * commands can be called from controllers and elsewhere
1115
     * so we need a way to allow them to still work.
1116
     *
1117
     * For now, just echo the content, but look into a better
1118
     * solution down the road.
1119
     *
1120
     * @param resource $handle
1121
     *
1122
     * @return void
1123
     */
1124
    protected static function fwrite($handle, string $string)
1125
    {
1126
        static::$io->fwrite($handle, $string);
192✔
1127
    }
1128

1129
    /**
1130
     * Testing purpose only
1131
     *
1132
     * @testTag
1133
     */
1134
    public static function setInputOutput(InputOutput $io): void
1135
    {
1136
        static::$io = $io;
2✔
1137
    }
1138

1139
    /**
1140
     * Testing purpose only
1141
     *
1142
     * @testTag
1143
     */
1144
    public static function resetInputOutput(): void
1145
    {
1146
        static::$io = new InputOutput();
37✔
1147
    }
1148
}
1149

1150
// Ensure the class is initialized. Done outside of code coverage
1151
CLI::init(); // @codeCoverageIgnore
10✔
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