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

codeigniter4 / CodeIgniter4 / 18389505594

09 Oct 2025 09:21PM UTC coverage: 84.39% (+0.03%) from 84.362%
18389505594

Pull #9751

github

web-flow
Merge 3d707799b into d945236b2
Pull Request #9751: refactor(app): Standardize subdomain detection logic

16 of 16 new or added lines in 3 files covered. (100.0%)

43 existing lines in 6 files now uncovered.

21236 of 25164 relevant lines covered (84.39%)

195.78 hits per line

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

88.33
/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 CodeIgniter\Exceptions\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 list<string>
111
     */
112
    protected static $segments = [];
113

114
    /**
115
     * @var array<string, string|null>
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()) {
41✔
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');
41✔
166

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

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

174
            static::parseCommandLine();
41✔
175

176
            static::$initialized = true;
41✔
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();
41✔
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 = '';
11✔
226
        $default     = '';
11✔
227

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

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

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

241
        if (is_array($options) && $options !== []) {
11✔
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 . ': ');
11✔
258

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

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

269
        return $input;
11✔
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)) !== null) {
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     = array_keys($opts);
3✔
322
            $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $optsKey) . ']';
3✔
323
            $extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput;
3✔
324
        }
325

326
        CLI::write($text);
3✔
327
        CLI::printKeysAndValues($options);
3✔
328
        CLI::newLine();
3✔
329

330
        $input = static::prompt($extraOutput);
3✔
331
        $input = ($input === '') ? '0' : $input; // 0 is default
3✔
332

333
        // validation
334
        while (true) {
3✔
335
            $pattern = preg_match_all('/^\d+(,\d+)*$/', trim($input));
3✔
336

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

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

UNCOV
351
                $input = static::prompt($extraOutput);
×
352
                $input = ($input === '') ? '0' : $input;
×
353
            } else {
354
                break;
3✔
355
            }
356
        }
357

358
        $input = [];
3✔
359

360
        foreach ($options as $key => $description) {
3✔
361
            foreach ($inputToArray as $inputKey) {
3✔
362
                if ($key === $inputKey) {
3✔
363
                    $input[$key] = $description;
3✔
364
                }
365
            }
366
        }
367

368
        return $input;
3✔
369
    }
370

371
    // --------------------------------------------------------------------
372
    // Utility for promptBy...
373
    // --------------------------------------------------------------------
374

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

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

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

399
    // --------------------------------------------------------------------
400
    // End Utility for promptBy...
401
    // --------------------------------------------------------------------
402

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

423
        if ($validation->hasError($field)) {
5✔
424
            static::error($validation->getError($field));
2✔
425

426
            return false;
2✔
427
        }
428

429
        return true;
5✔
430
    }
431

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

444
        static::$lastWrite = null;
11✔
445

446
        static::fwrite(STDOUT, $text);
11✔
447
    }
448

449
    /**
450
     * Outputs a string to the cli on its own line.
451
     *
452
     * @return void
453
     */
454
    public static function write(string $text = '', ?string $foreground = null, ?string $background = null)
455
    {
456
        if ((string) $foreground !== '' || (string) $background !== '') {
179✔
457
            $text = static::color($text, $foreground, $background);
116✔
458
        }
459

460
        if (static::$lastWrite !== 'write') {
179✔
461
            $text              = PHP_EOL . $text;
11✔
462
            static::$lastWrite = 'write';
11✔
463
        }
464

465
        static::fwrite(STDOUT, $text . PHP_EOL);
179✔
466
    }
467

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

479
        if ($foreground !== '' || (string) $background !== '') {
34✔
480
            $text = static::color($text, $foreground, $background);
34✔
481
        }
482

483
        static::fwrite(STDERR, $text . PHP_EOL);
34✔
484

485
        // return STDOUT color support
486
        static::$isColored = $stdout;
34✔
487
    }
488

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

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

515
            while ($time > 0) {
1✔
516
                static::fwrite(STDOUT, $time . '... ');
1✔
517
                sleep(1);
1✔
518
                $time--;
1✔
519
            }
520

521
            static::write();
1✔
522
        } elseif ($seconds > 0) {
10✔
523
            sleep($seconds);
9✔
524
        } else {
525
            static::write(static::$wait_msg);
1✔
526
            static::$io->input();
1✔
527
        }
528
    }
529

530
    /**
531
     * if operating system === windows
532
     *
533
     * @deprecated 4.3.0 Use `is_windows()` instead
534
     */
535
    public static function isWindows(): bool
536
    {
UNCOV
537
        return is_windows();
×
538
    }
539

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

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

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

584
        if (! array_key_exists($foreground, static::$foreground_colors)) {
803✔
585
            throw CLIException::forInvalidColor('foreground', $foreground);
1✔
586
        }
587

588
        if ((string) $background !== '' && ! array_key_exists($background, static::$background_colors)) {
802✔
589
            throw CLIException::forInvalidColor('background', $background);
2✔
590
        }
591

592
        $newText = '';
801✔
593

594
        // Detect if color method was already in use with this text
595
        if (str_contains($text, "\033[0m")) {
801✔
596
            $pattern = '/\\033\\[0;.+?\\033\\[0m/u';
4✔
597

598
            preg_match_all($pattern, $text, $matches);
4✔
599
            $coloredStrings = $matches[0];
4✔
600

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

606
            $nonColoredText = preg_replace(
4✔
607
                $pattern,
4✔
608
                '<<__colored_string__>>',
4✔
609
                $text,
4✔
610
            );
4✔
611
            $nonColoredChunks = preg_split(
4✔
612
                '/<<__colored_string__>>/u',
4✔
613
                $nonColoredText,
4✔
614
            );
4✔
615

616
            foreach ($nonColoredChunks as $i => $chunk) {
4✔
617
                if ($chunk !== '') {
4✔
618
                    $newText .= self::getColoredText($chunk, $foreground, $background, $format);
4✔
619
                }
620

621
                if (isset($coloredStrings[$i])) {
4✔
622
                    $newText .= $coloredStrings[$i];
4✔
623
                }
624
            }
625
        } else {
626
            $newText .= self::getColoredText($text, $foreground, $background, $format);
801✔
627
        }
628

629
        return $newText;
801✔
630
    }
631

632
    private static function getColoredText(string $text, string $foreground, ?string $background, ?string $format): string
633
    {
634
        $string = "\033[" . static::$foreground_colors[$foreground] . 'm';
801✔
635

636
        if ((string) $background !== '') {
801✔
637
            $string .= "\033[" . static::$background_colors[$background] . 'm';
23✔
638
        }
639

640
        if ($format === 'underline') {
801✔
641
            $string .= "\033[4m";
2✔
642
        }
643

644
        return $string . $text . "\033[0m";
801✔
645
    }
646

647
    /**
648
     * Get the number of characters in string having encoded characters
649
     * and ignores styles set by the color() function
650
     */
651
    public static function strlen(?string $string): int
652
    {
653
        if ((string) $string === '') {
37✔
654
            return 0;
20✔
655
        }
656

657
        foreach (static::$foreground_colors as $color) {
37✔
658
            $string = strtr($string, ["\033[" . $color . 'm' => '']);
37✔
659
        }
660

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

665
        $string = strtr($string, ["\033[4m" => '', "\033[0m" => '']);
37✔
666

667
        return mb_strwidth($string);
37✔
668
    }
669

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

UNCOV
685
        return function_exists($function) && @$function($resource); // @codeCoverageIgnore
×
686
    }
687

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

705
        if (getenv('TERM_PROGRAM') === 'Hyper') {
68✔
706
            return true;
1✔
707
        }
708

709
        if (is_windows()) {
67✔
710
            // @codeCoverageIgnoreStart
UNCOV
711
            return static::streamSupports('sapi_windows_vt100_support', $resource)
×
UNCOV
712
                || isset($_SERVER['ANSICON'])
×
UNCOV
713
                || getenv('ANSICON') !== false
×
UNCOV
714
                || getenv('ConEmuANSI') === 'ON'
×
715
                || getenv('TERM') === 'xterm';
×
716
            // @codeCoverageIgnoreEnd
717
        }
718

719
        return static::streamSupports('stream_isatty', $resource);
67✔
720
    }
721

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

731
        return static::$width ?: $default;
13✔
732
    }
733

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

743
        return static::$height ?: $default;
1✔
744
    }
745

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

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

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

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

806
        if ($thisStep !== false) {
2✔
807
            // Don't allow div by zero or negative numbers....
808
            $thisStep   = abs($thisStep);
1✔
809
            $totalSteps = $totalSteps < 1 ? 1 : $totalSteps;
1✔
810

811
            $percent = (int) (($thisStep / $totalSteps) * 100);
1✔
812
            $step    = (int) round($percent / 10);
1✔
813

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

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

838
        if ($max === 0) {
12✔
839
            $max = self::getWidth();
1✔
840
        }
841

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

846
        $max -= $padLeft;
12✔
847

848
        $lines = wordwrap($string, $max, PHP_EOL);
12✔
849

850
        if ($padLeft > 0) {
12✔
851
            $lines = explode(PHP_EOL, $lines);
12✔
852

853
            $first = true;
12✔
854

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

863
            $lines = implode(PHP_EOL, $lines);
12✔
864
        }
865

866
        return $lines;
12✔
867
    }
868

869
    // --------------------------------------------------------------------
870
    // Command-Line 'URI' support
871
    // --------------------------------------------------------------------
872

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

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

898
                continue;
32✔
899
            }
900

901
            $arg   = ltrim($arg, '-');
30✔
902
            $value = null;
30✔
903

904
            if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) {
30✔
905
                $value       = $args[$i + 1];
22✔
906
                $optionValue = true;
22✔
907
            }
908

909
            static::$options[$arg] = $value;
30✔
910
        }
911
    }
912

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

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

941
    /**
942
     * Returns the raw array of segments found.
943
     *
944
     * @return list<string>
945
     */
946
    public static function getSegments(): array
947
    {
948
        return static::$segments;
12✔
949
    }
950

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

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

967
        return $val;
2✔
968
    }
969

970
    /**
971
     * Returns the raw array of options found.
972
     *
973
     * @return array<string, string|null>
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 = [];
36✔
1028

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

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

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

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

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

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

1053
            foreach ($tableRows[$row] as $col) {
36✔
1054
                // Sets the size of this column in the current row
1055
                $allColsLengths[$row][$column] = static::strlen((string) $col);
36✔
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]) {
36✔
1061
                    $maxColsLengths[$column] = $allColsLengths[$row][$column];
36✔
1062
                }
1063

1064
                // We can go check the size of the next column...
1065
                $column++;
36✔
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++) {
36✔
1072
            $column = 0;
36✔
1073

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

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

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

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

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

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

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

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

1109
        static::write($table);
36✔
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);
206✔
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();
41✔
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