diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..18ecd19 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,xml}] +indent_size = 2[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..372d125 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +# Set default behavior +* text=auto + +# Enforce LF endings for scripts and configs (Crucial for CLI tools running on Linux/Docker) +*.php text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.xml text eol=lf +*.sh text eol=lf + +# ── EXPORT IGNORE (Keeps production installations tiny) ─────── +/tests/ export-ignore +/.github/ export-ignore +/.idea/ export-ignore +/.vscode/ export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/rector.php export-ignore +/.php-cs-fixer.php export-ignore +/infection.json5 export-ignore +/.gitignore export-ignore +/.editorconfig export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index c6597e4..c36c1be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,53 @@ -# CakePHP 3 +# ── COMPOSER ────────────────────────────────────────────────── +/vendor/ +#(Uncomment if you want to ignore lock file for the library) +composer.lock -/vendor/* -/config/app.php -/tmp/cache/models/* -!/tmp/cache/models/empty -/tmp/cache/persistent/* -!/tmp/cache/persistent/empty -/tmp/cache/views/* -!/tmp/cache/views/empty -/tmp/sessions/* -!/tmp/sessions/empty -/tmp/tests/* -!/tmp/tests/empty +# ── TESTING & COVERAGE ──────────────────────────────────────── +/.phpunit.cache/ +.phpunit.result.cache +/coverage/ +/coverage-html/ +coverage.xml +clover.xml +phpunit.xml +!phpunit.xml.dist +infection.log -/logs/* -!/logs/empty +# ── STATIC ANALYSIS & REFACTORING CACHES ────────────────────── +.php-cs-fixer.cache +/.phpstan.cache/ +/.rector/ +phpstan-results.json -# CakePHP 2 +# ── PROFILING & DEBUGGING (Xdebug) ──────────────────────────── +cachegrind.out.* +xdebug.log +error_log +php_errors.log -/app/tmp/* -/app/Config/core.php -/app/Config/database.php -/vendors/* +# ── BUILD ARTIFACTS (If you compile the CLI) ────────────────── +/build/ +/dist/ +*.phar + +# ── ENVIRONMENT & DOCKER ────────────────────────────────────── +.env +.env.*.local +docker-compose.override.yml +docker-compose.local.yml + +# ── OS & IDEs ───────────────────────────────────────────────── +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +Thumbs.db +ehthumbs.db +/.idea/ +/.vscode/ +*.swp +*.swo +*~ \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..c141715 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,29 @@ +setRiskyAllowed(true) + ->setRules([ + '@auto' => true, + '@auto:risky' => true + ]) + // 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config + ->setFinder( + (new Finder()) + // 💡 root folder to check + ->in(__DIR__) + // 💡 additional files, eg bin entry file + // ->append([__DIR__.'/bin-entry-file']) + // 💡 folders to exclude, if any + // ->exclude([/* ... */]) + // 💡 path patterns to exclude, if any + // ->notPath([/* ... */]) + // 💡 extra configs + // ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode + // ->ignoreVCS(true) // true by default + ) +; diff --git a/composer.json b/composer.json index 54ee7bc..4f1e0a6 100644 --- a/composer.json +++ b/composer.json @@ -3,21 +3,78 @@ "description": "Interactive CLI component runtime for PHP microservice and hexagonal architectures.", "type": "library", "license": "MIT", + "keywords":[ + "cli", + "hexagonal-architecture", + "microservices", + "io", + "terminal" + ], + "authors":[ + { + "name": "Alfacode Team", + "email": "shamavurasheed@gmail.com", + "role": "Developer" + } + ], "autoload": { "psr-4": { "AlfacodeTeam\\PhpIoCli\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "AlfacodeTeam\\PhpIoCli\\Tests\\": "tests/" + } + }, "require": { "php": "^8.2", "psr/log": "^3.0" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3.50", + "infection/infection": "^0.29", + "mockery/mockery": "^1.6", "phpstan/phpstan": "^1.10", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^10.5 || ^11.0", + "rector/rector": "^1.0", "symfony/console": "^6.0 || ^7.0" }, "minimum-stability": "dev", "prefer-stable": true, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true, + "phpstan/extension-installer": true + } + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-html coverage", + "format": "php-cs-fixer fix --allow-risky=yes", + "lint": "php-cs-fixer fix --dry-run --allow-risky=yes", + "analyse": "phpstan analyse --memory-limit=256M", + "refactor": "rector process", + "mutation": "infection --threads=max", + "check":[ + "@lint", + "@analyse", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run unit tests", + "test:coverage": "Run unit tests with HTML coverage report", + "format": "Automatically format PHP code using PHP-CS-Fixer", + "lint": "Check coding standards without modifying files", + "analyse": "Run static analysis with PHPStan", + "refactor": "Run Rector to automatically upgrade code and apply design patterns", + "mutation": "Run mutation testing with Infection to verify test suite quality", + "check": "Run all checks (linting, static analysis, and tests)" + }, "extra": { "_comment": "── php-io-cli: command auto-discovery ──────────────────────────────────────", "_comment2": "Applications that depend on this library should add their own 'extra.php-io-cli'", @@ -25,7 +82,7 @@ "_example": { "extra": { "php-io-cli": { - "commands": [ + "commands":[ "App\\Commands\\ServeCommand", "App\\Commands\\MakeModelCommand", "App\\Commands\\MigrateCommand" diff --git a/examples/01-inputs.php b/examples/01-inputs.php index a618bb1..1e795a2 100644 --- a/examples/01-inputs.php +++ b/examples/01-inputs.php @@ -1,5 +1,6 @@ #!/usr/bin/env php placeholder('e.g. Alice') ->default('World') - ->validate(function (string $value): ?string { - return mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.'; - }) + ->validate(fn(string $value): ?string => mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.') ->run(); Colors::line(" → Name: {$name}", Colors::GREEN); @@ -52,7 +51,7 @@ ->showStrength() ->run(); -Colors::line(" → Password length: " . mb_strlen((string)$secret) . " chars", Colors::GREEN); +Colors::line(" → Password length: " . mb_strlen((string) $secret) . " chars", Colors::GREEN); // ── 4. Confirm ──────────────────────────────────────────────────── diff --git a/examples/02-display.php b/examples/02-display.php index 4bc0f9e..b04bd5f 100644 --- a/examples/02-display.php +++ b/examples/02-display.php @@ -1,5 +1,6 @@ #!/usr/bin/env php headers(['Service', 'Status', 'Latency', 'Requests']) ->rows([ - ['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms', '15,204'], + ['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms', '15,204'], ['auth-service', Colors::wrap('degraded', Colors::YELLOW), '340 ms', '3,891'], - ['payment-worker', Colors::wrap('down', Colors::RED), '—', '0'], - ['cache-service', Colors::wrap('healthy', Colors::GREEN), '2 ms', '52,001'], + ['payment-worker', Colors::wrap('down', Colors::RED), '—', '0'], + ['cache-service', Colors::wrap('healthy', Colors::GREEN), '2 ms', '52,001'], ]) ->align([3 => 'right']) ->render(); diff --git a/examples/03-application.php b/examples/03-application.php index 542e525..ea2e58d 100644 --- a/examples/03-application.php +++ b/examples/03-application.php @@ -1,5 +1,6 @@ #!/usr/bin/env php description = 'Deploy the application to an environment'; $this->addArgument('environment', 'Target environment', required: true); - $this->addOption('tag', 't', 'Git tag to deploy', acceptsValue: true, default: 'latest'); + $this->addOption('tag', 't', 'Git tag to deploy', acceptsValue: true, default: 'latest'); $this->addOption('dry-run', 'd', 'Simulate without side-effects'); - $this->addOption('force', 'f', 'Skip confirmation prompt'); + $this->addOption('force', 'f', 'Skip confirmation prompt'); } protected function handle(): int @@ -107,7 +108,7 @@ protected function configure(): void $this->description = 'Run pending database migrations'; $this->addOption('rollback', 'r', 'Rollback the last batch'); - $this->addOption('steps', 's', 'Number of steps to roll back', acceptsValue: true, default: '1'); + $this->addOption('steps', 's', 'Number of steps to roll back', acceptsValue: true, default: '1'); } protected function handle(): int @@ -190,7 +191,7 @@ protected function handle(): int $this->table() ->headers(['File', 'Status']) ->rows(array_map( - fn (string $f) => ["src/{$name}/{$f}.php", Colors::wrap('created', Colors::GREEN)], + fn(string $f) => ["src/{$name}/{$f}.php", Colors::wrap('created', Colors::GREEN)], $features )) ->render(); diff --git a/examples/04-shell.php b/examples/04-shell.php index 723dcf1..c32ed76 100644 --- a/examples/04-shell.php +++ b/examples/04-shell.php @@ -1,5 +1,6 @@ #!/usr/bin/env php + + + + tests + + + + + src + + + \ No newline at end of file diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..e0578d1 --- /dev/null +++ b/rector.php @@ -0,0 +1,15 @@ +withPaths([ + __DIR__ . '/examples', + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + // uncomment to reach your current PHP version + // ->withPhpSets() + ->withTypeCoverageLevel(0); diff --git a/src/AbstractCommand.php b/src/AbstractCommand.php index 8dc9e59..6eb99fa 100644 --- a/src/AbstractCommand.php +++ b/src/AbstractCommand.php @@ -1,4 +1,5 @@ io = $io; - $this->rawTokens = $tokens; $this->parseTokens($tokens); - // Validate required arguments + // FIX 2: The old condition was: + // !isset($this->arguments[$argName]) || $this->arguments[$argName] === null + // isset() already returns false for null-valued keys, so after !isset() is false + // (i.e. the key IS set and IS NOT null), the === null comparison is always false. + // PHPStan level 8 correctly flags it. Fix: use only !isset(), which covers both + // "key absent" and "key set to null". foreach ($this->argumentDefs as $argName => $def) { - if ($def['required'] && (!isset($this->arguments[$argName]) || $this->arguments[$argName] === null)) { + if ($def['required'] && !isset($this->arguments[$argName])) { $this->error("Missing required argument: <{$argName}>"); return self::INVALID; } @@ -75,6 +83,11 @@ final public function execute(array $tokens, IOInterface $io): int try { return $this->handle(); } catch (Throwable $e) { + // If catch-exceptions mode is enabled (via marker), rethrow + if ($this->rethrowExceptions) { + throw $e; + } + $this->io->error("Command Error: " . $e->getMessage()); if ($io->isDebug()) { $this->io->write(Colors::muted($e->getTraceAsString())); @@ -83,6 +96,17 @@ final public function execute(array $tokens, IOInterface $io): int } } + /** + * Set whether exceptions should be rethrown instead of caught. + * Called by CLIApplication when catchExceptions(false). + * + * @internal For use by CLIApplication only. + */ + final public function setRethrowExceptions(bool $rethrow): void + { + $this->rethrowExceptions = $rethrow; + } + /* ========================================================= Registration ========================================================= */ @@ -112,10 +136,12 @@ protected function addOption(string $long, string $short = '', string $descripti private function parseTokens(array $tokens): void { // Set Defaults - foreach ($this->argumentDefs as $name => $def) + foreach ($this->argumentDefs as $name => $def) { $this->arguments[$name] = $def['default']; - foreach ($this->optionDefs as $key => $def) + } + foreach ($this->optionDefs as $key => $def) { $this->options[$key] = $def['default']; + } $positional = []; $count = count($tokens); @@ -131,7 +157,6 @@ private function parseTokens(array $tokens): void $this->options[$key] = $value; } else { $this->options[$bare] = true; - // Check if next token is a value if (($this->optionDefs[$bare]['acceptsValue'] ?? false) && isset($tokens[$i + 1]) && !str_starts_with($tokens[$i + 1], '-')) { $this->options[$bare] = $tokens[++$i]; } @@ -162,8 +187,9 @@ private function parseTokens(array $tokens): void $argNames = array_keys($this->argumentDefs); foreach ($positional as $idx => $value) { - if (isset($argNames[$idx])) + if (isset($argNames[$idx])) { $this->arguments[$argNames[$idx]] = $value; + } } } @@ -175,18 +201,17 @@ protected function option(string $name, mixed $default = null): mixed { return $this->options[ltrim($name, '-')] ?? $default; } - // protected function hasOption(string $name): bool { return (bool)$this->option($name); } protected function argument(string $name, mixed $default = null): mixed { return $this->arguments[$name] ?? $default; } + protected function hasOption(string $name): bool { return (bool) ($this->options[ltrim($name, '-')] ?? false); } - protected function info(string $message): void { $this->io->write(Colors::info($message)); @@ -220,9 +245,10 @@ protected function section(string $title): void $this->io->write(Colors::wrap($title, [Colors::BOLD, Colors::CYAN])); $this->io->write(Colors::muted(str_repeat('─', mb_strlen(Colors::strip($title))))); } + /* ========================================================= - Alert Components (Restored) - ========================================================= */ + Alert Components + ========================================================= */ protected function alertSuccess(string $title, string|array $body = []): void { @@ -244,7 +270,6 @@ protected function alertInfo(string $title, string|array $body = []): void Alert::info($title, $body); } - /* ========================================================= Component Factory Methods ========================================================= */ @@ -277,7 +302,6 @@ protected function spinner(string $label, string $style = 'dots'): SpinnerCompon return new SpinnerComponent($label, $style); } - /* ========================================================= Help Generation ========================================================= */ @@ -315,4 +339,8 @@ final public function getDescription(): string { return $this->description; } -} \ No newline at end of file + final public function isHidden(): bool + { + return $this->hidden; + } +} diff --git a/src/AbstractPrompt.php b/src/AbstractPrompt.php index 6e23e3a..20d8fb8 100644 --- a/src/AbstractPrompt.php +++ b/src/AbstractPrompt.php @@ -1,4 +1,5 @@ write($messages, $newline, $verbosity); } - public function writeErrorRaw($messages, bool $newline = true, int $verbosity = self::NORMAL): void + public function writeErrorRaw(string|array $messages, bool $newline = true, int $verbosity = self::NORMAL): void { $this->writeError($messages, $newline, $verbosity); } @@ -30,28 +32,44 @@ public function writeErrorRaw($messages, bool $newline = true, int $verbosity = ========================================================= */ public function emergency(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::EMERGENCY, $message, $context); } + { + $this->log(LogLevel::EMERGENCY, $message, $context); + } public function alert(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::ALERT, $message, $context); } + { + $this->log(LogLevel::ALERT, $message, $context); + } public function critical(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::CRITICAL, $message, $context); } + { + $this->log(LogLevel::CRITICAL, $message, $context); + } public function error(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::ERROR, $message, $context); } + { + $this->log(LogLevel::ERROR, $message, $context); + } public function warning(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::WARNING, $message, $context); } + { + $this->log(LogLevel::WARNING, $message, $context); + } public function notice(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::NOTICE, $message, $context); } + { + $this->log(LogLevel::NOTICE, $message, $context); + } public function info(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::INFO, $message, $context); } + { + $this->log(LogLevel::INFO, $message, $context); + } public function debug(string|Stringable $message, array $context = []): void - { $this->log(LogLevel::DEBUG, $message, $context); } + { + $this->log(LogLevel::DEBUG, $message, $context); + } /** * Core logging logic with ANSI theming and safe JSON context serialisation. @@ -64,8 +82,7 @@ public function log($level, $message, array $context = []): void $output = (string) $message; if ($context !== []) { - // Silencer is now properly imported — this call will resolve correctly. - $json = Silencer::call(static fn () => json_encode( + $json = Silencer::call(static fn() => json_encode( $context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE )); @@ -105,4 +122,4 @@ public function log($level, $message, array $context = []): void $this->write($formatted, true, $targetVerbosity); } -} \ No newline at end of file +} diff --git a/src/BufferIO.php b/src/BufferIO.php index bfbe21c..e90c62d 100644 --- a/src/BufferIO.php +++ b/src/BufferIO.php @@ -1,4 +1,5 @@ -input instanceof StreamableInputInterface) { throw new \RuntimeException('Setting the user inputs requires at least the version 3.2 of the symfony/console component.'); } @@ -95,11 +96,11 @@ private function createStream(array $inputs) } foreach ($inputs as $input) { - fwrite($stream, $input.PHP_EOL); + fwrite($stream, $input . PHP_EOL); } rewind($stream); return $stream; } -} \ No newline at end of file +} diff --git a/src/CLIApplication.php b/src/CLIApplication.php index ea8079e..2b2aa43 100644 --- a/src/CLIApplication.php +++ b/src/CLIApplication.php @@ -1,4 +1,5 @@ commands; if (!$includeHidden) { - $cmds = array_filter($cmds, fn ($c) => !$c->isHidden()); + $cmds = array_filter($cmds, fn($c) => !$c->isHidden()); } ksort($cmds); @@ -256,7 +257,9 @@ private function locateComposerJson(): string } $parent = dirname($dir); - if ($parent === $dir) break; // filesystem root + if ($parent === $dir) { + break; + } // filesystem root $dir = $parent; } @@ -276,7 +279,7 @@ private function locateComposerJson(): string */ public function run(?array $argv = null): int { - $argv = $argv ?? array_slice($_SERVER['argv'] ?? [], 1); + $argv ??= array_slice($_SERVER['argv'] ?? [], 1); $token = $argv[0] ?? ''; $rest = array_slice($argv, 1); @@ -320,12 +323,14 @@ private function dispatch(string $token, array $rest): int { // --help / -h attached to a known command if ( - $token !== '' && - $this->has($token) && - (in_array('--help', $rest, true) || in_array('-h', $rest, true)) + $token !== '' + && $this->has($token) + && (in_array('--help', $rest, true) || in_array('-h', $rest, true)) ) { - $this->commands[$token]->execute([], $this->io()); - $this->commands[$token]->printHelp(); + $cmd = $this->commands[$token]; + $cmd->setRethrowExceptions(!$this->catchExceptions); + $cmd->execute([], $this->io()); + $cmd->printHelp(); return AbstractCommand::SUCCESS; } @@ -346,7 +351,9 @@ private function dispatch(string $token, array $rest): int // Exact match if ($this->has($token)) { - return $this->commands[$token]->execute($rest, $this->io()); + $cmd = $this->commands[$token]; + $cmd->setRethrowExceptions(!$this->catchExceptions); + return $cmd->execute($rest, $this->io()); } // ── Not found — fuzzy suggestions ───────────────────── @@ -360,7 +367,9 @@ private function dispatch(string $token, array $rest): int $this->io()->write(Colors::muted(' Did you mean one of these?') . PHP_EOL); $pick = (new CustomSelect('Run instead?', $suggestions))->run(); if (is_string($pick) && $this->has($pick)) { - return $this->commands[$pick]->execute($rest, $this->io()); + $cmd = $this->commands[$pick]; + $cmd->setRethrowExceptions(!$this->catchExceptions); + return $cmd->execute($rest, $this->io()); } } else { $this->io()->writeError(''); @@ -398,6 +407,7 @@ private function cmdHelp(string $commandName): int } $cmd = $this->commands[$commandName]; + $cmd->setRethrowExceptions(!$this->catchExceptions); $cmd->execute([], $this->io()); $cmd->printHelp(); return AbstractCommand::SUCCESS; @@ -432,7 +442,7 @@ private function cmdList(): int } // Align descriptions by padding command names to same width - $maxLen = max(array_map(fn ($n) => mb_strlen($n), array_keys($cmds))); + $maxLen = max(array_map(fn($n) => mb_strlen($n), array_keys($cmds))); foreach ($cmds as $name => $cmd) { $this->io()->write(sprintf( @@ -494,4 +504,4 @@ private function isTty(): bool { return function_exists('posix_isatty') && @posix_isatty(STDIN); } -} \ No newline at end of file +} diff --git a/src/Components/Alert.php b/src/Components/Alert.php index f9408b9..98221aa 100644 --- a/src/Components/Alert.php +++ b/src/Components/Alert.php @@ -1,4 +1,5 @@ input->fallback(function ($s, $key) { + $this->input->fallback(function ($s, $key): void { if (Key::isPrintable($key)) { - $cur = (int)$s->cursor; - $val = (string)$s->value; - + $cur = (int) $s->cursor; + $val = (string) $s->value; + $s->value = mb_substr($val, 0, $cur) . $key . mb_substr($val, $cur); $s->cursor = $cur + 1; $s->focused = 0; // Reset focus on type @@ -57,45 +58,47 @@ protected function setup(): void }); // 2. Navigation & Deletion - $this->input->bind('BACKSPACE', function ($s) { - $cur = (int)$s->cursor; - if ($cur === 0) return; - - $val = (string)$s->value; + $this->input->bind('BACKSPACE', function ($s): void { + $cur = (int) $s->cursor; + if ($cur === 0) { + return; + } + + $val = (string) $s->value; $s->value = mb_substr($val, 0, $cur - 1) . mb_substr($val, $cur); $s->cursor = $cur - 1; $s->focused = 0; }); - $this->input->bind('LEFT', fn($s) => $s->decrement('cursor')); - $this->input->bind('RIGHT', fn($s) => $s->increment('cursor', mb_strlen((string)$s->value))); + $this->input->bind('LEFT', fn($s) => $s->decrement('cursor')); + $this->input->bind('RIGHT', fn($s) => $s->increment('cursor', mb_strlen((string) $s->value))); - $this->input->bind('UP', function ($s) { + $this->input->bind('UP', function ($s): void { $s->decrement('focused'); }); - $this->input->bind('DOWN', function ($s) { + $this->input->bind('DOWN', function ($s): void { $count = count($this->getFiltered()); $s->increment('focused', $count > 0 ? $count - 1 : 0); }); // 3. Selection Logic - $this->input->bind('TAB', function ($s) { + $this->input->bind('TAB', function ($s): void { $filtered = $this->getFiltered(); if (!empty($filtered)) { - $selection = $filtered[(int)$s->focused] ?? $filtered[0]; + $selection = $filtered[(int) $s->focused] ?? $filtered[0]; $s->value = $selection; $s->cursor = mb_strlen($selection); } }); - $this->input->bind('ENTER', function ($s) { + $this->input->bind('ENTER', function ($s): void { $filtered = $this->getFiltered(); - $val = (string)$s->value; + $val = (string) $s->value; // If a suggestion is highlighted and different from current text, "fill" it first if (!empty($filtered) && $val !== '') { - $highlighted = $filtered[(int)$s->focused] ?? null; + $highlighted = $filtered[(int) $s->focused] ?? null; if ($highlighted && $highlighted !== $val) { $s->value = $highlighted; $s->cursor = mb_strlen($highlighted); @@ -114,21 +117,21 @@ public function render(): void Terminal::moveCursorUp($this->lastLines); } - $val = (string)$this->state->value; - $cur = (int)$this->state->cursor; - $focused = (int)$this->state->focused; + $val = (string) $this->state->value; + $cur = (int) $this->state->cursor; + $focused = (int) $this->state->focused; $filtered = $this->getFiltered(); $lines = []; // HEADER $lines[] = Colors::wrap('? ', Colors::CYAN) . Colors::wrap($this->question, Colors::BOLD); - if (!(bool)$this->state->done) { + if (!(bool) $this->state->done) { // INPUT LINE WITH VIRTUAL CARET $before = mb_substr($val, 0, $cur); $at = mb_substr($val, $cur, 1); $after = mb_substr($val, $cur + 1); - + // Caret styling (Reverse Video) $caretChar = ($at === '') ? ' ' : $at; $caret = Colors::wrap($caretChar, ["\033[7m", Colors::YELLOW]); @@ -161,7 +164,7 @@ public function render(): void private function renderDropdown(array $filtered, int $focused): array { $visible = array_slice($filtered, 0, $this->maxSuggestions); - + // Calculate dynamic width based on content $width = $this->minDropdownWidth; foreach ($visible as $item) { @@ -175,10 +178,10 @@ private function renderDropdown(array $filtered, int $focused): array foreach ($visible as $i => $item) { $active = $i === $focused; - + $icon = $active ? Colors::wrap('› ', Colors::GREEN) : ' '; $text = $active ? Colors::wrap($item, [Colors::YELLOW, Colors::BOLD]) : Colors::muted($item); - + // Padded line construction $contentLen = mb_strlen(Colors::strip($item)); $padding = str_repeat(' ', max(0, $width - $contentLen - 4)); @@ -203,6 +206,6 @@ public function resolve(): mixed private function getFiltered(): array { - return Fuzzy::filter($this->suggestions, (string)$this->state->value); + return Fuzzy::filter($this->suggestions, (string) $this->state->value); } -} \ No newline at end of file +} diff --git a/src/Components/Component.php b/src/Components/Component.php index 80715a5..7b8880e 100644 --- a/src/Components/Component.php +++ b/src/Components/Component.php @@ -1,4 +1,5 @@ run(); */ final class Confirm extends Component @@ -34,17 +35,19 @@ protected function setup(): void public function render(): void { - if ($this->lastLines > 0) Terminal::moveCursorUp($this->lastLines); - + if ($this->lastLines > 0) { + Terminal::moveCursorUp($this->lastLines); + } + $active = $this->state->confirmed; - + $yes = $active ? Colors::wrap(' Yes ', [Colors::BG_CYAN, Colors::BLACK]) : Colors::wrap(' Yes ', Colors::GRAY); $no = !$active ? Colors::wrap(' No ', [Colors::BG_CYAN, Colors::BLACK]) : Colors::wrap(' No ', Colors::GRAY); $lines = [ Colors::wrap('? ', Colors::CYAN) . Colors::wrap($this->question, Colors::BOLD), " {$yes} {$no}", - Colors::muted(' ← → to toggle • Enter to confirm') + Colors::muted(' ← → to toggle • Enter to confirm'), ]; foreach ($lines as $line) { @@ -56,6 +59,6 @@ public function render(): void public function resolve(): bool { - return (bool)$this->state->confirmed; + return (bool) $this->state->confirmed; } -} \ No newline at end of file +} diff --git a/src/Components/DatePicker.php b/src/Components/DatePicker.php index 073bad0..b3998a1 100644 --- a/src/Components/DatePicker.php +++ b/src/Components/DatePicker.php @@ -1,4 +1,5 @@ state->batch([ - 'year' => (int)$now->format('Y'), - 'month' => (int)$now->format('n'), - 'day' => (int)$now->format('j'), + 'year' => (int) $now->format('Y'), + 'month' => (int) $now->format('n'), + 'day' => (int) $now->format('j'), 'done' => false, ]); // 1. Precise Day/Week Navigation - $this->input->bind('LEFT', fn($s) => $this->shiftDate($s, '-1 day')); + $this->input->bind('LEFT', fn($s) => $this->shiftDate($s, '-1 day')); $this->input->bind('RIGHT', fn($s) => $this->shiftDate($s, '+1 day')); - $this->input->bind('UP', fn($s) => $this->shiftDate($s, '-7 days')); - $this->input->bind('DOWN', fn($s) => $this->shiftDate($s, '+7 days')); + $this->input->bind('UP', fn($s) => $this->shiftDate($s, '-7 days')); + $this->input->bind('DOWN', fn($s) => $this->shiftDate($s, '+7 days')); // 2. Month Navigation (Mapped to [ and ] or PageUp/Down if your Terminal detects them) - $this->input->bind(['[', 'PAGE_UP'], fn($s) => $this->shiftMonth($s, -1)); + $this->input->bind(['[', 'PAGE_UP'], fn($s) => $this->shiftMonth($s, -1)); $this->input->bind([']', 'PAGE_DOWN'], fn($s) => $this->shiftMonth($s, 1)); // 3. Shortcuts - $this->input->bind('t', function($s) { + $this->input->bind('t', function ($s): void { $now = new DateTimeImmutable(); $s->batch([ - 'year' => (int)$now->format('Y'), - 'month' => (int)$now->format('n'), - 'day' => (int)$now->format('j'), + 'year' => (int) $now->format('Y'), + 'month' => (int) $now->format('n'), + 'day' => (int) $now->format('j'), ]); }); - $this->input->bind('ENTER', function ($s) { + $this->input->bind('ENTER', function ($s): void { $s->done = true; $this->stop(); }); @@ -65,9 +66,9 @@ private function shiftDate(mixed $s, string $modify): void { $dt = $this->getSelectedDate()->modify($modify); $s->batch([ - 'year' => (int)$dt->format('Y'), - 'month' => (int)$dt->format('n'), - 'day' => (int)$dt->format('j'), + 'year' => (int) $dt->format('Y'), + 'month' => (int) $dt->format('n'), + 'day' => (int) $dt->format('j'), ]); } @@ -76,25 +77,25 @@ private function shiftMonth(mixed $s, int $delta): void $dt = $this->getSelectedDate(); // Modify month first $newDt = $dt->modify(($delta > 0 ? '+' : '') . $delta . ' months'); - + // Handle PHP "overflow" (Jan 31 + 1 month = March 3). Clamp to last day of month. - if ((int)$newDt->format('n') !== ($dt->format('n') + $delta + 12) % 12 ?: 12) { - $newDt = $newDt->modify('last day of last month'); + if ((int) $newDt->format('n') !== ($dt->format('n') + $delta + 12) % 12 ?: 12) { + $newDt = $newDt->modify('last day of last month'); } $s->batch([ - 'year' => (int)$newDt->format('Y'), - 'month' => (int)$newDt->format('n'), - 'day' => (int)$newDt->format('j'), + 'year' => (int) $newDt->format('Y'), + 'month' => (int) $newDt->format('n'), + 'day' => (int) $newDt->format('j'), ]); } private function getSelectedDate(): DateTimeImmutable { return new DateTimeImmutable(sprintf( - '%04d-%02d-%02d', - $this->state->year, - $this->state->month, + '%04d-%02d-%02d', + $this->state->year, + $this->state->month, $this->state->day )); } @@ -112,10 +113,10 @@ public function render(): void Terminal::moveCursorUp($this->lastLines); } - $year = (int)$this->state->year; - $month = (int)$this->state->month; - $day = (int)$this->state->day; - $done = (bool)$this->state->done; + $year = (int) $this->state->year; + $month = (int) $this->state->month; + $day = (int) $this->state->day; + $done = (bool) $this->state->done; $lines = []; $lines[] = Colors::wrap('? ', Colors::CYAN) . Colors::wrap($this->question, Colors::BOLD); @@ -123,8 +124,8 @@ public function render(): void if (!$done) { $currentDt = $this->getSelectedDate(); $firstOfMonth = $currentDt->modify('first day of this month'); - $daysInMonth = (int)$currentDt->format('t'); - $startColumn = (int)$firstOfMonth->format('N') - 1; // 0 (Mon) to 6 (Sun) + $daysInMonth = (int) $currentDt->format('t'); + $startColumn = (int) $firstOfMonth->format('N') - 1; // 0 (Mon) to 6 (Sun) $lines[] = ''; $lines[] = ' ' . Colors::wrap($currentDt->format('F Y'), [Colors::BOLD, Colors::CYAN]); @@ -136,9 +137,9 @@ public function render(): void for ($d = 1; $d <= $daysInMonth; $d++) { $isToday = $this->isToday($year, $month, $d); $isSelected = ($d === $day); - - $label = str_pad((string)$d, 2, ' ', STR_PAD_LEFT); - + + $label = str_pad((string) $d, 2, ' ', STR_PAD_LEFT); + if ($isSelected) { // Reverse video for the "Cursor" $cell = Colors::wrap(" {$label}", ["\033[7m", Colors::CYAN, Colors::BOLD]); diff --git a/src/Components/MultiSelect.php b/src/Components/MultiSelect.php index 198e62f..5cf3051 100644 --- a/src/Components/MultiSelect.php +++ b/src/Components/MultiSelect.php @@ -1,4 +1,5 @@ run(); */ final class MultiSelect extends Component @@ -27,11 +28,11 @@ protected function setup(): void $this->input->bind('UP', fn($s) => $s->decrement('index')); $this->input->bind('DOWN', fn($s) => $s->increment('index', count($this->choices) - 1)); - $this->input->bind(' ', function($s) { + $this->input->bind(' ', function ($s): void { $val = $this->choices[$s->index]; $s->toggle('selected', $val); }); - $this->input->bind('ENTER', function($s) { + $this->input->bind('ENTER', function ($s): void { $s->done = true; $this->stop(); }); @@ -39,15 +40,17 @@ protected function setup(): void public function render(): void { - if ($this->lastLines > 0) Terminal::moveCursorUp($this->lastLines); - + if ($this->lastLines > 0) { + Terminal::moveCursorUp($this->lastLines); + } + $lines = [Colors::wrap('? ', Colors::CYAN) . Colors::wrap($this->question, Colors::BOLD)]; if (!$this->state->done) { foreach ($this->choices as $i => $item) { $active = $i === $this->state->index; - $checked = in_array($item, (array)$this->state->selected, true); - + $checked = in_array($item, (array) $this->state->selected, true); + $pointer = $active ? Colors::wrap('› ', Colors::CYAN) : ' '; $box = $checked ? Colors::wrap('⬢ ', Colors::GREEN) : Colors::wrap('⬡ ', Colors::GRAY); $label = $active ? Colors::wrap($item, Colors::YELLOW) : Colors::wrap($item, Colors::GRAY); @@ -56,7 +59,7 @@ public function render(): void } $lines[] = Colors::muted(' ↑↓ move • Space toggle • Enter submit'); } else { - $lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap(implode(', ', (array)$this->state->selected), Colors::GREEN); + $lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap(implode(', ', (array) $this->state->selected), Colors::GREEN); } foreach ($lines as $line) { @@ -66,8 +69,8 @@ public function render(): void $this->lastLines = count($lines); } - public function resolve(): array + public function resolve(): array { - return (array)$this->state->selected; + return (array) $this->state->selected; } -} \ No newline at end of file +} diff --git a/src/Components/NumberInput.php b/src/Components/NumberInput.php index a874bea..e70198e 100644 --- a/src/Components/NumberInput.php +++ b/src/Components/NumberInput.php @@ -1,4 +1,5 @@ min = $v; return $this; } - public function max(float $v): self { $this->max = $v; return $this; } - public function step(float $v): self { $this->step = $v; return $this; } - public function default(float $v): self{ $this->default = $v; return $this; } - public function integer(): self { $this->intOnly = true; return $this; } + public function min(float $v): self + { + $this->min = $v; + return $this; + } + public function max(float $v): self + { + $this->max = $v; + return $this; + } + public function step(float $v): self + { + $this->step = $v; + return $this; + } + public function default(float $v): self + { + $this->default = $v; + return $this; + } + public function integer(): self + { + $this->intOnly = true; + return $this; + } /* ========================================================= LIFECYCLE @@ -42,45 +63,51 @@ public function integer(): self { $this->intOnly = true; return $this; } protected function setup(): void { $this->state->batch([ - 'raw' => $this->default !== null ? (string)$this->default : '', + 'raw' => $this->default !== null ? (string) $this->default : '', 'error' => null, 'done' => false, ]); // Typing digits, minus, dot - $this->input->fallback(function ($s, $key) { - if (!Key::isPrintable($key)) return; + $this->input->fallback(function ($s, $key): void { + if (!Key::isPrintable($key)) { + return; + } $allowed = $this->intOnly ? '0123456789-' : '0123456789.-'; if (mb_strpos($allowed, $key) !== false) { - $s->raw = (string)$s->raw . $key; + $s->raw = (string) $s->raw . $key; $s->error = null; } }); - $this->input->bind('BACKSPACE', function ($s) { - $s->raw = mb_substr((string)$s->raw, 0, -1); + $this->input->bind('BACKSPACE', function ($s): void { + $s->raw = mb_substr((string) $s->raw, 0, -1); $s->error = null; }); // Arrow stepping - $this->input->bind('UP', function ($s) { - $current = (float)((string)$s->raw ?: '0'); + $this->input->bind('UP', function ($s): void { + $current = (float) ((string) $s->raw ?: '0'); $new = $current + $this->step; - if ($this->max !== null) $new = min($new, $this->max); + if ($this->max !== null) { + $new = min($new, $this->max); + } $s->raw = $this->format($new); }); - $this->input->bind('DOWN', function ($s) { - $current = (float)((string)$s->raw ?: '0'); + $this->input->bind('DOWN', function ($s): void { + $current = (float) ((string) $s->raw ?: '0'); $new = $current - $this->step; - if ($this->min !== null) $new = max($new, $this->min); + if ($this->min !== null) { + $new = max($new, $this->min); + } $s->raw = $this->format($new); }); - $this->input->bind('ENTER', function ($s) { - $raw = trim((string)$s->raw); + $this->input->bind('ENTER', function ($s): void { + $raw = trim((string) $s->raw); if ($raw === '' && $this->default !== null) { - $raw = (string)$this->default; + $raw = (string) $this->default; } if ($raw === '') { @@ -93,7 +120,7 @@ protected function setup(): void return; } - $val = (float)$raw; + $val = (float) $raw; if ($this->min !== null && $val < $this->min) { $s->error = "Minimum value is {$this->min}."; @@ -113,7 +140,7 @@ protected function setup(): void private function format(float $v): string { - return $this->intOnly ? (string)(int)$v : rtrim(rtrim(number_format($v, 10, '.', ''), '0'), '.'); + return $this->intOnly ? (string) (int) $v : rtrim(rtrim(number_format($v, 10, '.', ''), '0'), '.'); } /* ========================================================= @@ -128,9 +155,9 @@ public function render(): void Terminal::hideCursor(); - $raw = (string)$this->state->raw; + $raw = (string) $this->state->raw; $error = $this->state->error; - $done = (bool)$this->state->done; + $done = (bool) $this->state->done; $lines = []; $mark = $done ? Colors::success('') : Colors::wrap('? ', Colors::CYAN); @@ -178,7 +205,7 @@ public function destroy(): void public function resolve(): mixed { - $v = (float)$this->state->raw; - return $this->intOnly ? (int)$v : $v; + $v = (float) $this->state->raw; + return $this->intOnly ? (int) $v : $v; } -} \ No newline at end of file +} diff --git a/src/Components/Password.php b/src/Components/Password.php index f826722..68b9ee2 100644 --- a/src/Components/Password.php +++ b/src/Components/Password.php @@ -1,4 +1,5 @@ input->fallback(function ($state, $key) { + $this->input->fallback(function ($state, $key): void { if (Key::isPrintable($key)) { $state->value .= $key; } }); - $this->input->bind('BACKSPACE', function ($state) { + $this->input->bind('BACKSPACE', function ($state): void { $state->value = mb_substr((string) $state->value, 0, -1); }); // TAB toggles visibility - $this->input->bind('TAB', function ($state) { + $this->input->bind('TAB', function ($state): void { $state->visible = !(bool) $state->visible; }); - $this->input->bind('ENTER', function ($state) { + $this->input->bind('ENTER', function ($state): void { $state->done = true; $this->stop(); }); @@ -97,7 +98,7 @@ public function render(): void if ($this->strengthMeter && $len > 0) { $lines[] = ' ' . $this->buildStrengthBar($value); } else { - $lines[] = ''; + $lines[] = ''; } // Help Hint @@ -140,19 +141,29 @@ private function buildStrengthBar(string $password): string { $score = 0; $len = mb_strlen($password); - - if ($len >= 8) $score++; - if ($len >= 12) $score++; - if (preg_match('/[A-Z]/', $password)) $score++; - if (preg_match('/[0-9]/', $password)) $score++; - if (preg_match('/[^A-Za-z0-9]/', $password)) $score++; + + if ($len >= 8) { + $score++; + } + if ($len >= 12) { + $score++; + } + if (preg_match('/[A-Z]/', $password)) { + $score++; + } + if (preg_match('/[0-9]/', $password)) { + $score++; + } + if (preg_match('/[^A-Za-z0-9]/', $password)) { + $score++; + } $labels = ['Very weak', 'Weak', 'Fair', 'Good', 'Strong']; $colors = [Colors::RED, Colors::RED, Colors::YELLOW, Colors::GREEN, Colors::GREEN]; // Ensure we don't index out of bounds $index = max(0, min($score - 1, 4)); - + $filled = str_repeat('━', $score); $empty = str_repeat('━', 5 - $score); $label = $labels[$index]; @@ -163,4 +174,4 @@ private function buildStrengthBar(string $password): string . ' ' . Colors::wrap($label, $color); } -} \ No newline at end of file +} diff --git a/src/Components/ProgressBar.php b/src/Components/ProgressBar.php index 7afb32f..d1dc0bc 100644 --- a/src/Components/ProgressBar.php +++ b/src/Components/ProgressBar.php @@ -1,4 +1,5 @@ width = $w; return $this; } + public function width(int $w): self + { + $this->width = $w; + return $this; + } /* ========================================================= CONTROL @@ -66,7 +71,9 @@ public function advance(int $step = 1, string $status = ''): void if ($this->total > 0) { $this->current = min($this->current + $step, $this->total); } - if ($status !== '') $this->status = $status; + if ($status !== '') { + $this->status = $status; + } $this->draw(); } @@ -74,11 +81,13 @@ public function finish(string $message = ''): void { $this->finished = true; $this->spinner->stop(); - - if ($this->total > 0) $this->current = $this->total; + + if ($this->total > 0) { + $this->current = $this->total; + } $this->draw($message); - + Terminal::showCursor(); echo PHP_EOL; } @@ -112,15 +121,15 @@ private function draw(string $finishMessage = ''): void if ($this->total > 0) { // DETERMINATE MODE (Bar + Spinner + Status) $pct = $this->current / $this->total; - $pctStr = str_pad((int)($pct * 100) . '%', 4, ' ', STR_PAD_LEFT); + $pctStr = str_pad((int) ($pct * 100) . '%', 4, ' ', STR_PAD_LEFT); - $lines[] = "{$frame} " . Colors::wrap($this->label, Colors::BOLD) + $lines[] = "{$frame} " . Colors::wrap($this->label, Colors::BOLD) . Colors::muted(sprintf(' (%d/%d)', $this->current, $this->total)); - + $lines[] = ' ' . $this->buildBar($pct) . ' ' . Colors::wrap($pctStr, Colors::YELLOW); } else { // INDETERMINATE MODE (Label + Spinner + Status) - $lines[] = "{$frame} " . Colors::wrap($this->label, Colors::BOLD) + $lines[] = "{$frame} " . Colors::wrap($this->label, Colors::BOLD) . Colors::muted(sprintf(' %.1fs', $elapsed)); } @@ -155,4 +164,4 @@ private function buildBar(float $pct): string return Colors::wrap(str_repeat($this->fillChar, $filledSize), $color) . Colors::muted(str_repeat($this->emptyChar, $emptySize)); } -} \ No newline at end of file +} diff --git a/src/Components/Select.php b/src/Components/Select.php index c11bfae..e8f0e7f 100644 --- a/src/Components/Select.php +++ b/src/Components/Select.php @@ -1,4 +1,5 @@ run(); */ @@ -40,23 +41,23 @@ protected function setup(): void ]); // Navigation - $this->input->bind('UP', function ($state) { + $this->input->bind('UP', function ($state): void { $state->decrement('index'); }); - $this->input->bind('DOWN', function ($state) { + $this->input->bind('DOWN', function ($state): void { $count = count($this->getFiltered()); $state->increment('index', $count > 0 ? $count - 1 : 0); }); // Search logic - $this->input->bind('BACKSPACE', function ($state) { - $state->search = mb_substr((string)$state->search, 0, -1); + $this->input->bind('BACKSPACE', function ($state): void { + $state->search = mb_substr((string) $state->search, 0, -1); $state->index = 0; }); // Selection - $this->input->bind('ENTER', function ($state) { + $this->input->bind('ENTER', function ($state): void { $filtered = $this->getFiltered(); if (empty($filtered)) { return; @@ -67,7 +68,7 @@ protected function setup(): void }); // Typing fallback - $this->input->fallback(function ($state, $key) { + $this->input->fallback(function ($state, $key): void { if (Key::isPrintable($key)) { $state->search .= $key; $state->index = 0; // Reset index on new search @@ -99,10 +100,10 @@ public function render(): void if (!$done) { // Search Bar Line $searchLabel = Colors::wrap('› ', Colors::GRAY); - $searchText = $search !== '' - ? Colors::wrap($search, Colors::YELLOW) . Colors::wrap('▊', Colors::CYAN) + $searchText = $search !== '' + ? Colors::wrap($search, Colors::YELLOW) . Colors::wrap('▊', Colors::CYAN) : Colors::wrap('Type to filter...', Colors::DIM); - + $lines[] = " {$searchLabel}{$searchText}"; $lines[] = ""; // Spacer @@ -114,7 +115,7 @@ public function render(): void $windowSize = 8; $total = count($filtered); $start = (int) max(0, min($this->state->index - floor($windowSize / 2), $total - $windowSize)); - + foreach (array_slice($filtered, $start, $windowSize) as $i => $item) { $realIndex = $start + $i; $active = $realIndex === $this->state->index; @@ -125,19 +126,19 @@ public function render(): void $lines[] = Colors::wrap(" {$item}", Colors::GRAY); } } - + // Scroll indicators for large lists if ($total > $windowSize) { $lines[] = Colors::muted(sprintf(" (Showing %d of %d)", $windowSize, $total)); } } - + $lines[] = ""; $lines[] = Colors::muted(" ↑↓ navigate • ENTER select • Type to filter"); } else { // Collapse UI on finish - $lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap((string)$this->state->result, Colors::GREEN); + $lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap((string) $this->state->result, Colors::GREEN); } // 3. Clear and print @@ -166,6 +167,6 @@ public function resolve(): mixed private function getFiltered(): array { - return Fuzzy::filter($this->choices, (string)$this->state->search); + return Fuzzy::filter($this->choices, (string) $this->state->search); } -} \ No newline at end of file +} diff --git a/src/Components/SpinnerComponent.php b/src/Components/SpinnerComponent.php index a234c17..e74030c 100644 --- a/src/Components/SpinnerComponent.php +++ b/src/Components/SpinnerComponent.php @@ -1,4 +1,5 @@ running) return; + if (!$this->running) { + return; + } $this->draw($subLabel); } @@ -105,4 +108,4 @@ private function draw(string $subLabel = ''): void $this->lastLines = count($lines); } -} \ No newline at end of file +} diff --git a/src/Components/Table.php b/src/Components/Table.php index 858151e..b99a5e7 100644 --- a/src/Components/Table.php +++ b/src/Components/Table.php @@ -1,4 +1,5 @@ headers = $headers; return $this; } - public function rows(array $rows): self { $this->rows = $rows; return $this; } - public function style(string $style): self { $this->style = $style; return $this; } - public function striped(bool $enable = true): self { $this->striped = $enable; return $this; } + public function headers(array $headers): self + { + $this->headers = $headers; + return $this; + } + public function rows(array $rows): self + { + $this->rows = $rows; + return $this; + } + public function style(string $style): self + { + $this->style = $style; + return $this; + } + public function striped(bool $enable = true): self + { + $this->striped = $enable; + return $this; + } /** @param array $alignments ['left', 'center', 'right'] */ - public function align(array $alignments): self { $this->alignments = $alignments; return $this; } + public function align(array $alignments): self + { + $this->alignments = $alignments; + return $this; + } public function render(): void { @@ -89,7 +113,7 @@ private function getColumnCount(): int } /** - * Fixes the "ANSI Headache": + * Fixes the "ANSI Headache": * Uses Colors::strip() to calculate the VISUAL width of the content. */ private function computeWidths(int $count): array @@ -100,7 +124,7 @@ private function computeWidths(int $count): array foreach ($allData as $row) { foreach ($row as $i => $cell) { // We strip ANSI before measuring length - $visualLength = mb_strlen(Colors::strip((string)$cell)); + $visualLength = mb_strlen(Colors::strip((string) $cell)); $widths[$i] = max($widths[$i], $visualLength); } } @@ -111,9 +135,9 @@ private function drawRow(array $cells, array $widths, string $sep, bool $isHeade { $parts = []; foreach ($widths as $i => $width) { - $rawContent = (string)($cells[$i] ?? ''); + $rawContent = (string) ($cells[$i] ?? ''); $align = $this->alignments[$i] ?? 'left'; - + $padded = $this->applyPadding($rawContent, $width, $align); // Styling logic @@ -139,7 +163,7 @@ private function applyPadding(string $text, int $targetWidth, string $align): st return match ($align) { 'right' => str_repeat(' ', $diff) . $text, - 'center' => str_repeat(' ', (int)floor($diff / 2)) . $text . str_repeat(' ', (int)ceil($diff / 2)), + 'center' => str_repeat(' ', (int) floor($diff / 2)) . $text . str_repeat(' ', (int) ceil($diff / 2)), default => $text . str_repeat(' ', $diff), }; } @@ -162,4 +186,4 @@ private function getBorders(): array default => ['╔','╗','╚','╝','═','║','╦','╩','╠','╣','╬'], }; } -} \ No newline at end of file +} diff --git a/src/Components/TextInput.php b/src/Components/TextInput.php index 8d5162d..b308165 100644 --- a/src/Components/TextInput.php +++ b/src/Components/TextInput.php @@ -1,10 +1,12 @@ state->batch([ - 'value' => '', - 'cursor' => 0, - 'done' => false, - 'error' => null, + 'value' => '', + 'cursor' => 0, + 'done' => false, + 'error' => null, ]); // Key: Typing - $this->input->fallback(function ($state, $key) { + $this->input->fallback(function (State|string $state, string $key): void { if (Key::isPrintable($key)) { - $cur = (int) $state->cursor; + $cur = (int) $state->cursor; $value = (string) $state->value; - $state->value = mb_substr($value, 0, $cur) . $key . mb_substr($value, $cur); + $state->value = mb_substr($value, 0, $cur) . $key . mb_substr($value, $cur); $state->cursor = $cur + 1; - $state->error = null; + $state->error = null; } }); // Key: Navigation - $this->input->bind('LEFT', fn($s) => $s->decrement('cursor')); - $this->input->bind('RIGHT', fn($s) => $s->increment('cursor', mb_strlen((string) $s->value))); - $this->input->bind('HOME', fn($s) => $s->cursor = 0); - $this->input->bind('END', fn($s) => $s->cursor = mb_strlen((string) $s->value)); + $this->input->bind('LEFT', fn(State|string $s) => $s->decrement('cursor')); + $this->input->bind('RIGHT', fn(State|string $s) => $s->increment('cursor', mb_strlen((string) $s->value))); + $this->input->bind('HOME', function (State|string $s): void { + $s->cursor = 0; + }); + $this->input->bind('END', function (State|string $s): void { + $s->cursor = mb_strlen((string) $s->value); + }); // Key: Deletion - $this->input->bind('BACKSPACE', function ($state) { + $this->input->bind('BACKSPACE', function (State|string $state): void { $cur = (int) $state->cursor; - if ($cur === 0) return; - $state->value = mb_substr((string)$state->value, 0, $cur - 1) . mb_substr((string)$state->value, $cur); + if ($cur === 0) { + return; + } + $state->value = mb_substr((string) $state->value, 0, $cur - 1) . mb_substr((string) $state->value, $cur); $state->cursor = $cur - 1; - $state->error = null; + $state->error = null; }); - $this->input->bind('DELETE', function ($state) { - $cur = (int) $state->cursor; + $this->input->bind('DELETE', function (State|string $state): void { + $cur = (int) $state->cursor; $value = (string) $state->value; - if ($cur >= mb_strlen($value)) return; + if ($cur >= mb_strlen($value)) { + return; + } $state->value = mb_substr($value, 0, $cur) . mb_substr($value, $cur + 1); }); // Key: Submission - $this->input->bind('ENTER', function ($state) { + $this->input->bind('ENTER', function (State|string $state): void { $value = (string) $state->value; if ($value === '' && $this->defaultValue !== '') { @@ -126,10 +136,10 @@ public function render(): void Terminal::hideCursor(); - $value = (string) $this->state->value; + $value = (string) $this->state->value; $cursor = (int) $this->state->cursor; - $error = $this->state->error; - $done = (bool) $this->state->done; + $error = $this->state->error; + $done = (bool) $this->state->done; $lines = []; @@ -140,13 +150,13 @@ public function render(): void if (!$done) { // Line 2: Input Area $before = mb_substr($value, 0, $cursor); - $at = mb_substr($value, $cursor, 1); - $after = mb_substr($value, $cursor + 1); - $char = ($at !== '') ? $at : ' '; + $at = mb_substr($value, $cursor, 1); + $after = mb_substr($value, $cursor + 1); + $char = ($at !== '') ? $at : ' '; // Block Cursor using Inverse Video ANSI $cursorAnsi = Colors::wrap($char, [Colors::BOLD, "\033[7m"]); - + $displayText = Colors::wrap($before, Colors::YELLOW) . $cursorAnsi . Colors::wrap($after, Colors::YELLOW); // Handle Placeholder @@ -194,4 +204,4 @@ public function resolve(): mixed $value = (string) $this->state->value; return ($value !== '') ? $value : $this->defaultValue; } -} \ No newline at end of file +} diff --git a/src/ConsoleIO.php b/src/ConsoleIO.php index 62f1290..2de7d6a 100644 --- a/src/ConsoleIO.php +++ b/src/ConsoleIO.php @@ -1,4 +1,5 @@ doWrite($messages, $newline, false, $verbosity); } - public function writeError($messages, bool $newline = true, int $verbosity = self::NORMAL): void + public function writeError(mixed $messages, bool $newline = true, int $verbosity = self::NORMAL): void { $this->doWrite($messages, $newline, true, $verbosity); } - public function writeRaw($messages, bool $newline = true, int $verbosity = self::NORMAL): void + public function writeRaw(mixed $messages, bool $newline = true, int $verbosity = self::NORMAL): void { $this->doWrite($messages, $newline, false, $verbosity, raw: true); } - public function writeErrorRaw($messages, bool $newline = true, int $verbosity = self::NORMAL): void + public function writeErrorRaw(mixed $messages, bool $newline = true, int $verbosity = self::NORMAL): void { $this->doWrite($messages, $newline, true, $verbosity, raw: true); } @@ -279,7 +280,7 @@ private function doWrite( $mem = round(memory_get_usage() / 1024 / 1024, 1); $time = round(microtime(true) - $this->startTime, 2); $prefix = Colors::muted("[{$mem}MiB/{$time}s] "); - $messages = array_map(fn ($m) => $prefix . $m, $messages); + $messages = array_map(fn($m) => $prefix . $m, $messages); } $target = $stderr ? $this->getErrorOutput() : $this->output; @@ -297,12 +298,12 @@ private function doWrite( OVERWRITE ========================================================= */ - public function overwrite($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void + public function overwrite(mixed $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void { $this->doOverwrite($messages, $newline, $size, false, $verbosity); } - public function overwriteError($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void + public function overwriteError(mixed $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void { $this->doOverwrite($messages, $newline, $size, true, $verbosity); } @@ -341,9 +342,24 @@ public function getErrorOutput(): OutputInterface : $this->output; } - public function isInteractive(): bool { return $this->input->isInteractive(); } - public function isVerbose(): bool { return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE; } - public function isVeryVerbose(): bool { return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE; } - public function isDebug(): bool { return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG; } - public function isDecorated(): bool { return $this->output->isDecorated(); } -} \ No newline at end of file + public function isInteractive(): bool + { + return $this->input->isInteractive(); + } + public function isVerbose(): bool + { + return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE; + } + public function isVeryVerbose(): bool + { + return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE; + } + public function isDebug(): bool + { + return $this->output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG; + } + public function isDecorated(): bool + { + return $this->output->isDecorated(); + } +} diff --git a/src/Depends/Colors.php b/src/Depends/Colors.php index 952da41..b0f630f 100644 --- a/src/Depends/Colors.php +++ b/src/Depends/Colors.php @@ -1,4 +1,5 @@ $item) { $score = self::score($query, (string) $item, $index); - + // Only include items that meet a minimum relevancy threshold if ($score > $minScore) { $scored[$index] = $score; @@ -27,7 +28,7 @@ public static function filter(array $items, string $query, int $minScore = 0): a arsort($scored, SORT_NUMERIC); return array_map( - fn ($i) => $items[$i], + fn($i) => $items[$i], array_keys($scored) ); } @@ -40,15 +41,19 @@ public static function score(string $query, string $value, int $tieBreaker = 0): $query = mb_strtolower(trim($query)); $value = mb_strtolower(trim($value)); - if ($query === '') return 0; - if ($query === $value) return 10000; + if ($query === '') { + return 0; + } + if ($query === $value) { + return 10000; + } $score = 0; // 1. Prefix Match if (str_starts_with($value, $query)) { $score = 9000 - mb_strlen($value); - } + } // 2. Substring Match elseif (str_contains($value, $query)) { $score = 8000 - mb_strlen($value); @@ -57,20 +62,16 @@ public static function score(string $query, string $value, int $tieBreaker = 0): elseif (self::isAbbreviation($query, $value)) { $score = 7000 - mb_strlen($value); } + // No other matches + else { + return -1 - $tieBreaker; + } - // 4. Token-based match (multi-word) + // 4. Token-based match bonus (multi-word) $queryTokens = explode(' ', $query); $valueTokens = explode(' ', $value); $score += self::tokenScore($queryTokens, $valueTokens); - // 5. Levenshtein (Only if strings are within PHP's 255 char limit) - if (mb_strlen($query) < 255 && mb_strlen($value) < 255) { - $distance = levenshtein($query, $value); - // If distance is large relative to length, it's a poor match - $levScore = max(0, 2000 - ($distance * 20)); - $score += $levScore; - } - // Final score minus tiebreaker for stable sorting return $score - $tieBreaker; } @@ -82,15 +83,19 @@ private static function isAbbreviation(string $query, string $value): bool { $qLen = mb_strlen($query); $vLen = mb_strlen($value); - - if ($qLen > $vLen) return false; + + if ($qLen > $vLen) { + return false; + } $qIdx = 0; for ($vIdx = 0; $vIdx < $vLen; $vIdx++) { if ($value[$vIdx] === $query[$qIdx]) { $qIdx++; } - if ($qIdx === $qLen) return true; + if ($qIdx === $qLen) { + return true; + } } return false; @@ -100,12 +105,17 @@ private static function tokenScore(array $queryTokens, array $valueTokens): int { $score = 0; foreach ($queryTokens as $q) { - if ($q === '') continue; + if ($q === '') { + continue; + } foreach ($valueTokens as $v) { - if ($q === $v) $score += 500; - elseif (str_starts_with($v, $q)) $score += 200; + if ($q === $v) { + $score += 500; + } elseif (str_starts_with($v, $q)) { + $score += 200; + } } } return $score; } -} \ No newline at end of file +} diff --git a/src/Depends/Input.php b/src/Depends/Input.php index ae4ffb2..a586c01 100644 --- a/src/Depends/Input.php +++ b/src/Depends/Input.php @@ -1,4 +1,5 @@ $keys * @param Closure(State, string): (void|bool) $handler */ @@ -30,7 +31,7 @@ public function bind(string|array $keys, Closure $handler): self /** * Define what happens if no specific binding matches the key. - * + * * @param Closure(State, string): void $handler */ public function fallback(Closure $handler): self @@ -56,7 +57,7 @@ public function unbind(string|array $keys): self public function handle(string $key, State $state): void { // 1. Normalization: Map common hex/escape codes to readable strings - $normalizedKey = Key::normalize($key); + $normalizedKey = Key::normalize($key); if (isset($this->bindings[$normalizedKey])) { foreach ($this->bindings[$normalizedKey] as $handler) { @@ -76,4 +77,4 @@ public function handle(string $key, State $state): void ($this->fallback)($state, $key); } } -} \ No newline at end of file +} diff --git a/src/Depends/Key.php b/src/Depends/Key.php index 08ed066..9a0fd46 100644 --- a/src/Depends/Key.php +++ b/src/Depends/Key.php @@ -1,4 +1,5 @@ = 32; + // Control characters are < 32, or 127 (DEL) + // Printable ASCII is 32-126 + if (mb_strlen($key) !== 1) { + return false; + } + + $ord = ord($key); + return $ord >= 32 && $ord < 127; } -} \ No newline at end of file +} diff --git a/src/Depends/RenderContext.php b/src/Depends/RenderContext.php index 0fe2bc3..7ff6133 100644 --- a/src/Depends/RenderContext.php +++ b/src/Depends/RenderContext.php @@ -1,4 +1,5 @@ meta[$key] ?? $default; } -} \ No newline at end of file +} diff --git a/src/Depends/Renderer.php b/src/Depends/Renderer.php index 9abe54f..f2ceb8c 100644 --- a/src/Depends/Renderer.php +++ b/src/Depends/Renderer.php @@ -1,4 +1,5 @@ 0) ? Colors::wrap(' ↑ more items', Colors::GRAY) : ' '; - foreach (array_slice($filtered, $start, $windowSize) as $i => $label) { + foreach (array_values(array_slice($filtered, $start, $windowSize)) as $i => $label) { $realIndex = $start + $i; $isActive = $realIndex === $index; $isSelected = in_array($label, (array) ($state->selected ?? []), true); @@ -174,4 +175,4 @@ public function __destruct() { echo "\033[?25h"; } -} \ No newline at end of file +} diff --git a/src/Depends/Shell.php b/src/Depends/Shell.php index 8ad9b07..39805ff 100644 --- a/src/Depends/Shell.php +++ b/src/Depends/Shell.php @@ -1,4 +1,5 @@ ok() ? trim($result->output()) : null; } -} \ No newline at end of file +} diff --git a/src/Depends/ShellResult.php b/src/Depends/ShellResult.php index be07c0d..4e34b8c 100644 --- a/src/Depends/ShellResult.php +++ b/src/Depends/ShellResult.php @@ -1,4 +1,5 @@ exitCode === 0; } - public function failed(): bool { return $this->exitCode !== 0; } + public function ok(): bool + { + return $this->exitCode === 0; + } + public function failed(): bool + { + return $this->exitCode !== 0; + } /** All stdout lines joined with newlines. */ public function output(): string @@ -40,7 +47,7 @@ public function meaningfulErrors(): array { return array_values(array_filter( $this->stderr, - fn (string $l) => trim($l) !== '' + fn(string $l) => trim($l) !== '' )); } -} \ No newline at end of file +} diff --git a/src/Depends/Spinner.php b/src/Depends/Spinner.php index e398fcd..45a8293 100644 --- a/src/Depends/Spinner.php +++ b/src/Depends/Spinner.php @@ -1,4 +1,5 @@ currentFrame; } -} \ No newline at end of file +} diff --git a/src/Depends/SpinnerFrames.php b/src/Depends/SpinnerFrames.php index eb0abce..03729fb 100644 --- a/src/Depends/SpinnerFrames.php +++ b/src/Depends/SpinnerFrames.php @@ -1,4 +1,5 @@ set($key, array_values($current)); } + /** + * Filter items by search query (case-insensitive substring match). + * Returns all items if no search query is set. + * + * @return array + */ + public function filtered(): array + { + $items = (array) $this->get('items', []); + $search = mb_strtolower((string) $this->get('search', '')); + + if ($search === '') { + return $items; + } + + return array_filter( + $items, + fn($item) => mb_stripos((string) $item, $search) !== false + ); + } + /* --- Reactivity --- */ /** @@ -121,4 +143,4 @@ private function notify(string $key, mixed $new, mixed $old): void $cb($new, $old, $this); } } -} \ No newline at end of file +} diff --git a/src/Depends/Terminal.php b/src/Depends/Terminal.php index 848767d..7428f58 100644 --- a/src/Depends/Terminal.php +++ b/src/Depends/Terminal.php @@ -1,4 +1,5 @@ listeners[$event] = array_values(array_filter( $this->listeners[$event], - fn ($l) => $l !== $listener + fn($l) => $l !== $listener )); return $this; @@ -79,4 +80,4 @@ public function dispatchUntil(string $event, mixed $payload = null): mixed return null; } -} \ No newline at end of file +} diff --git a/src/ILifecycle.php b/src/ILifecycle.php index e160c2b..3b3b0b8 100644 --- a/src/ILifecycle.php +++ b/src/ILifecycle.php @@ -1,4 +1,5 @@ : string|int|bool) */ public function select( - string $question, - array $choices, - mixed $default, - bool|int $attempts = false, - string $errorMessage = 'Value "%s" is invalid', + string $question, + array $choices, + mixed $default, + bool|int $attempts = false, + string $errorMessage = 'Value "%s" is invalid', bool $multiselect = false ): int|string|array|bool; -} \ No newline at end of file +} diff --git a/src/IPromptComponent.php b/src/IPromptComponent.php index 39c176e..9c60406 100644 --- a/src/IPromptComponent.php +++ b/src/IPromptComponent.php @@ -1,4 +1,5 @@ execute([], $io); // populate io - ob_start(); + // execute() wires $this->io inside the command; printHelp() uses the + // same io, so all output lands in BufferIO — not in PHP's output buffer. + $cmd->execute(['placeholder'], $io); $cmd->printHelp(); - $help = ob_get_clean(); - $this->assertStringContainsString('echo', (string)$help); + // FIX: was ob_start()/ob_get_clean() which captured PHP stdout (always + // empty here). Read from the BufferIO stream instead. + $help = $io->getOutput(); + + $this->assertStringContainsString('echo', $help); } } diff --git a/tests/Integration/BufferIOTest.php b/tests/Integration/BufferIOTest.php index 3d1d2e0..d20dd7c 100644 --- a/tests/Integration/BufferIOTest.php +++ b/tests/Integration/BufferIOTest.php @@ -1,4 +1,5 @@ name = 'boom'; } - protected function handle(): int { throw new \RuntimeException('Boom!'); } + protected function configure(): void + { + $this->name = 'boom'; + } + protected function handle(): int + { + throw new \RuntimeException('Boom!'); + } }; $app = (new CLIApplication()) diff --git a/tests/Unit/ColorsTest.php b/tests/Unit/ColorsTest.php index bdc0134..9838305 100644 --- a/tests/Unit/ColorsTest.php +++ b/tests/Unit/ColorsTest.php @@ -1,4 +1,5 @@ hooks->on('event', function () use (&$log): void { $log[] = 'A'; }); - $this->hooks->on('event', function () use (&$log): void { $log[] = 'B'; }); - $this->hooks->on('event', function () use (&$log): void { $log[] = 'C'; }); + $this->hooks->on('event', function () use (&$log): void { + $log[] = 'A'; + }); + $this->hooks->on('event', function () use (&$log): void { + $log[] = 'B'; + }); + $this->hooks->on('event', function () use (&$log): void { + $log[] = 'C'; + }); $this->hooks->dispatch('event'); @@ -110,8 +117,12 @@ public function test_off_without_listener_removes_all(): void { $count = 0; - $this->hooks->on('event', function () use (&$count): void { $count++; }); - $this->hooks->on('event', function () use (&$count): void { $count++; }); + $this->hooks->on('event', function () use (&$count): void { + $count++; + }); + $this->hooks->on('event', function () use (&$count): void { + $count++; + }); $this->hooks->off('event'); $this->hooks->dispatch('event'); diff --git a/tests/Unit/InputTest.php b/tests/Unit/InputTest.php index 412b1b7..b9c0cec 100644 --- a/tests/Unit/InputTest.php +++ b/tests/Unit/InputTest.php @@ -1,4 +1,5 @@ ["\e[H", 'HOME'], 'end' => ["\e[F", 'END'], 'enter (newline)' => ["\n", 'ENTER'], - 'enter (carriage)'=> ["\r", 'ENTER'], + 'enter (carriage)' => ["\r", 'ENTER'], 'tab' => ["\t", 'TAB'], 'esc' => ["\e", 'ESC'], 'backspace (del)' => ["\x7f", 'BACKSPACE'], diff --git a/tests/Unit/NullIOTest.php b/tests/Unit/NullIOTest.php index 4f41c66..a9890c9 100644 --- a/tests/Unit/NullIOTest.php +++ b/tests/Unit/NullIOTest.php @@ -1,4 +1,5 @@ 0]); $calls = []; - $state->watch('x', function () use (&$calls): void { $calls[] = 'first'; }); - $state->watch('x', function () use (&$calls): void { $calls[] = 'second'; }); + $state->watch('x', function () use (&$calls): void { + $calls[] = 'first'; + }); + $state->watch('x', function () use (&$calls): void { + $calls[] = 'second'; + }); $state->x = 99; diff --git a/tests/Unit/TableTest.php b/tests/Unit/TableTest.php index 26d2271..12a55cd 100644 --- a/tests/Unit/TableTest.php +++ b/tests/Unit/TableTest.php @@ -1,4 +1,5 @@