From ca6877bc78c135287c59f6da439a3d8677670308 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sun, 3 May 2026 18:32:03 +0300 Subject: [PATCH 1/8] refactor: Clean up unused imports and improve test output handling in AbstractCommand --- src/AbstractCommand.php | 25 +++++++++++------------ tests/Integration/AbstractCommandTest.php | 14 ++++++++----- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/AbstractCommand.php b/src/AbstractCommand.php index 8dc9e59..97422ff 100644 --- a/src/AbstractCommand.php +++ b/src/AbstractCommand.php @@ -4,20 +4,13 @@ namespace AlfacodeTeam\PhpIoCli; use AlfacodeTeam\PhpIoCli\Components\Alert; -use AlfacodeTeam\PhpIoCli\Components\Autocomplete; use AlfacodeTeam\PhpIoCli\Components\Confirm; -use AlfacodeTeam\PhpIoCli\Components\DatePicker; -use AlfacodeTeam\PhpIoCli\Components\MultiSelect; -use AlfacodeTeam\PhpIoCli\Components\NumberInput; -use AlfacodeTeam\PhpIoCli\Components\Password; use AlfacodeTeam\PhpIoCli\Components\ProgressBar; use AlfacodeTeam\PhpIoCli\Components\Select; use AlfacodeTeam\PhpIoCli\Components\SpinnerComponent; use AlfacodeTeam\PhpIoCli\Components\Table; use AlfacodeTeam\PhpIoCli\Components\TextInput; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use AlfacodeTeam\PhpIoCli\Depends\Terminal; -use DateTimeImmutable; use LogicException; use Throwable; @@ -175,18 +168,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)); @@ -214,15 +206,17 @@ protected function newLine(int $count = 1): void $this->io->write(''); } } + protected function section(string $title): void { $this->newLine(); $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 +238,6 @@ protected function alertInfo(string $title, string|array $body = []): void Alert::info($title, $body); } - /* ========================================================= Component Factory Methods ========================================================= */ @@ -277,7 +270,6 @@ protected function spinner(string $label, string $style = 'dots'): SpinnerCompon return new SpinnerComponent($label, $style); } - /* ========================================================= Help Generation ========================================================= */ @@ -311,8 +303,15 @@ final public function getName(): string { return $this->name; } + final public function getDescription(): string { return $this->description; } + + // FIX: was missing — called by CLIApplication::all() to filter hidden commands + final public function isHidden(): bool + { + return $this->hidden; + } } \ No newline at end of file diff --git a/tests/Integration/AbstractCommandTest.php b/tests/Integration/AbstractCommandTest.php index e680b76..43c40be 100644 --- a/tests/Integration/AbstractCommandTest.php +++ b/tests/Integration/AbstractCommandTest.php @@ -235,12 +235,16 @@ public function test_print_help_does_not_throw(): void { $io = new BufferIO(); $cmd = new EchoCommand(); - $cmd->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); } -} +} \ No newline at end of file From 72e7a22a8f9517eb148bb0b2136179ef2ba9cdbf Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sun, 3 May 2026 19:30:21 +0300 Subject: [PATCH 2/8] Refactor ConsoleIO and related classes for improved type safety and code clarity - Updated write, writeError, writeRaw, and writeErrorRaw methods in ConsoleIO to use mixed type for messages. - Enhanced readability by adding new lines and consistent formatting in ConsoleIO, Colors, Fuzzy, Input, Key, RenderContext, Renderer, Shell, ShellResult, Spinner, SpinnerFrames, State, Terminal, Hooks, IOInterface, IPromptComponent, IRenderer, and NullIO. - Introduced a new filtered method in State for case-insensitive substring matching. - Added .editorconfig and .gitattributes for consistent coding standards and line endings. - Created phpunit.xml.dist and rector.php for testing and code quality tools. - Updated tests for consistency and clarity in various test files. --- .editorconfig | 13 ++++ .gitattributes | 22 ++++++ .gitignore | 68 +++++++++++++------ .php-cs-fixer.dist.php | 29 ++++++++ composer.json | 59 ++++++++++++++++- examples/01-inputs.php | 7 +- examples/02-display.php | 7 +- examples/03-application.php | 9 +-- examples/04-shell.php | 5 +- phpunit.xml.dist | 17 +++++ rector.php | 15 +++++ src/AbstractCommand.php | 55 +++++++++++---- src/AbstractPrompt.php | 3 +- src/BaseIO.php | 47 ++++++++----- src/BufferIO.php | 15 +++-- src/CLIApplication.php | 34 ++++++---- src/Components/Alert.php | 17 ++--- src/Components/Autocomplete.php | 59 +++++++++-------- src/Components/Component.php | 3 +- src/Components/Confirm.php | 17 +++-- src/Components/DatePicker.php | 67 ++++++++++--------- src/Components/MultiSelect.php | 25 ++++--- src/Components/NumberInput.php | 81 +++++++++++++++-------- src/Components/Password.php | 37 +++++++---- src/Components/ProgressBar.php | 29 +++++--- src/Components/Select.php | 33 ++++----- src/Components/SpinnerComponent.php | 7 +- src/Components/Table.php | 50 ++++++++++---- src/Components/TextInput.php | 66 ++++++++++-------- src/ConsoleIO.php | 42 ++++++++---- src/Depends/Colors.php | 14 +++- src/Depends/Fuzzy.php | 52 +++++++++------ src/Depends/Input.php | 9 +-- src/Depends/Key.php | 13 +++- src/Depends/RenderContext.php | 3 +- src/Depends/Renderer.php | 5 +- src/Depends/Shell.php | 3 +- src/Depends/ShellResult.php | 15 +++-- src/Depends/Spinner.php | 3 +- src/Depends/SpinnerFrames.php | 3 +- src/Depends/State.php | 24 ++++++- src/Depends/Terminal.php | 13 ++-- src/Hooks.php | 7 +- src/ILifecycle.php | 5 +- src/IOInterface.php | 21 +++--- src/IPromptComponent.php | 5 +- src/IRenderer.php | 3 +- src/NullIO.php | 47 +++++++------ src/Silencer.php | 4 +- tests/Integration/AbstractCommandTest.php | 3 +- tests/Integration/BufferIOTest.php | 1 + tests/Integration/CLIApplicationTest.php | 11 ++- tests/Unit/ColorsTest.php | 1 + tests/Unit/FuzzyTest.php | 1 + tests/Unit/HooksTest.php | 21 ++++-- tests/Unit/InputTest.php | 1 + tests/Unit/KeyTest.php | 3 +- tests/Unit/NullIOTest.php | 1 + tests/Unit/RenderContextTest.php | 1 + tests/Unit/ShellResultTest.php | 1 + tests/Unit/StateTest.php | 9 ++- tests/Unit/TableTest.php | 1 + 62 files changed, 852 insertions(+), 390 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .php-cs-fixer.dist.php create mode 100644 phpunit.xml.dist create mode 100644 rector.php 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 97422ff..6eb99fa 100644 --- a/src/AbstractCommand.php +++ b/src/AbstractCommand.php @@ -1,16 +1,24 @@ 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; } @@ -68,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())); @@ -76,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 ========================================================= */ @@ -105,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); @@ -124,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]; } @@ -155,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; + } } } @@ -206,7 +239,6 @@ protected function newLine(int $count = 1): void $this->io->write(''); } } - protected function section(string $title): void { $this->newLine(); @@ -303,15 +335,12 @@ final public function getName(): string { return $this->name; } - final public function getDescription(): string { return $this->description; } - - // FIX: was missing — called by CLIApplication::all() to filter hidden commands final public function isHidden(): bool { return $this->hidden; } -} \ No newline at end of file +} 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 @@ assertStringContainsString('echo', $help); } -} \ No newline at end of file +} 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 @@ Date: Mon, 4 May 2026 06:29:43 +0300 Subject: [PATCH 3/8] Add integration and unit tests for BufferIO, Shell, Alert, Renderer, Spinner, and their components - Introduced BufferIOUserInputsTest to validate user input handling in commands. - Added ShellTest to ensure shell command execution and output capture works correctly. - Created AlertTest to verify alert rendering for success, error, warning, and info messages. - Implemented RendererTest to check rendering logic and output for various states. - Developed SpinnerFramesTest and SpinnerTest to validate spinner frame functionality and behavior. --- .github/CODEOWNERS | 35 ++ .github/dependabot.yml | 43 +++ Makefile | 81 +++++ SECURITY.md | 92 +++++ architecture.md | 263 ++++++++++++++ composer.json | 71 ++-- examples/demo.php | 340 +++++++++++++++++++ php-cs-fixer.php | 130 +++++++ phpunit.xml | 64 ++-- tests/Integration/AbstractCommandTest.php | 5 +- tests/Integration/BufferIOTest.php | 5 +- tests/Integration/BufferIOUserInputsTest.php | 260 ++++++++++++++ tests/Integration/CLIApplicationTest.php | 5 +- tests/Integration/ShellTest.php | 211 ++++++++++++ tests/Unit/AlertTest.php | 227 +++++++++++++ tests/Unit/ColorsTest.php | 5 +- tests/Unit/FuzzyTest.php | 5 +- tests/Unit/HooksTest.php | 5 +- tests/Unit/InputTest.php | 5 +- tests/Unit/KeyTest.php | 18 +- tests/Unit/NullIOTest.php | 5 +- tests/Unit/RenderContextTest.php | 5 +- tests/Unit/RendererTest.php | 315 +++++++++++++++++ tests/Unit/ShellResultTest.php | 5 +- tests/Unit/SpinnerFramesTest.php | 121 +++++++ tests/Unit/SpinnerTest.php | 156 +++++++++ tests/Unit/StateTest.php | 5 +- tests/Unit/TableTest.php | 10 +- 28 files changed, 2381 insertions(+), 111 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 architecture.md create mode 100644 examples/demo.php create mode 100644 php-cs-fixer.php create mode 100644 tests/Integration/BufferIOUserInputsTest.php create mode 100644 tests/Integration/ShellTest.php create mode 100644 tests/Unit/AlertTest.php create mode 100644 tests/Unit/RendererTest.php create mode 100644 tests/Unit/SpinnerFramesTest.php create mode 100644 tests/Unit/SpinnerTest.php diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..7e631a4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,35 @@ +# CODEOWNERS +# +# Each line is a pattern followed by one or more GitHub usernames / teams. +# The last matching pattern takes precedence. +# Docs: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# ── Default owner (catches everything not matched below) ────────── +* @alfacode-team + +# ── Core source ─────────────────────────────────────────────────── +/src/ @alfacode-team +/src/Components/ @alfacode-team +/src/Depends/ @alfacode-team + +# ── Tests ───────────────────────────────────────────────────────── +/tests/ @alfacode-team + +# ── Examples / docs ─────────────────────────────────────────────── +/examples/ @alfacode-team +/docs/ @alfacode-team +README.md @alfacode-team +CHANGELOG.md @alfacode-team + +# ── CI / release infrastructure ─────────────────────────────────── +/.github/ @alfacode-team +/Makefile @alfacode-team +phpunit.xml.dist @alfacode-team +phpstan.neon @alfacode-team +.php-cs-fixer.php @alfacode-team +rector.php @alfacode-team + +# ── Security-sensitive files ────────────────────────────────────── +SECURITY.md @alfacode-team +composer.json @alfacode-team +composer.lock @alfacode-team diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d79607a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +version: 2 + +updates: + # ── Composer (PHP) ──────────────────────────────────────────────── + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "php" + commit-message: + prefix: "chore(deps)" + # Keep dev-only bumps out of the release changelog noise + groups: + dev-dependencies: + dependency-type: "development" + update-types: + - "minor" + - "patch" + ignore: + # symfony/console is a dev dep; only auto-update patch/minor + - dependency-name: "symfony/console" + update-types: ["version-update:semver-major"] + + # ── GitHub Actions ──────────────────────────────────────────────── + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "chore(ci)" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8055590 --- /dev/null +++ b/Makefile @@ -0,0 +1,81 @@ +.PHONY: help install test test-unit test-integration coverage stan cs-check cs-fix \ + refactor mutation demo clean check check-full + +# ── Colours ─────────────────────────────────────────────────────────────────── +BOLD := \033[1m +CYAN := \033[36m +GREEN := \033[32m +RESET := \033[0m + +# ── Default target: print help ──────────────────────────────────────────────── +help: + @printf "\n$(BOLD)php-io-cli — Development Makefile$(RESET)\n\n" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make install" "Install Composer dependencies" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make test" "Run full test suite (Unit + Integration)" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make test-unit" "Run Unit tests only" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make test-integration" "Run Integration tests only" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make coverage" "Generate HTML coverage report" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make stan" "PHPStan static analysis (level 8)" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make cs-check" "Check code style (dry-run)" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make cs-fix" "Apply code-style fixes" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make refactor" "Run Rector automated upgrades" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make mutation" "Run Infection mutation testing" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make demo" "Launch the interactive component demo" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make check" "cs-check + stan + test (CI gate)" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make check-full" "check + coverage + mutation" + @printf "$(CYAN)%-20s$(RESET) %s\n" "make clean" "Remove build artifacts and caches" + @echo "" + +# ── Dependencies ────────────────────────────────────────────────────────────── +install: + composer install --no-interaction --prefer-dist + +# ── Testing ─────────────────────────────────────────────────────────────────── +test: + vendor/bin/phpunit + +test-unit: + vendor/bin/phpunit --testsuite Unit --no-coverage + +test-integration: + vendor/bin/phpunit --testsuite Integration --no-coverage + +coverage: + vendor/bin/phpunit --coverage-html build/coverage/html --coverage-clover build/coverage/clover.xml + @printf "\n$(GREEN)✔ Coverage report written to build/coverage/html/$(RESET)\n" + +# ── Static analysis ─────────────────────────────────────────────────────────── +stan: + vendor/bin/phpstan analyse --memory-limit=256M + +# ── Code style ──────────────────────────────────────────────────────────────── +cs-check: + vendor/bin/php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php + +cs-fix: + vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php + @printf "\n$(GREEN)✔ Code style fixes applied.$(RESET)\n" + +# ── Refactoring ─────────────────────────────────────────────────────────────── +refactor: + vendor/bin/rector process + +# ── Mutation testing ────────────────────────────────────────────────────────── +mutation: + vendor/bin/infection --threads=max --min-msi=60 --min-covered-msi=80 + +# ── Demo ───────────────────────────────────────────────────────────────────── +demo: + php examples/demo.php + +# ── Composite gates ────────────────────────────────────────────────────────── +check: cs-check stan test + +check-full: cs-check stan coverage mutation + @printf "\n$(BOLD)$(GREEN)✔ Full quality gate passed.$(RESET)\n" + +# ── Clean ───────────────────────────────────────────────────────────────────── +clean: + rm -rf build/ .phpunit.cache .php-cs-fixer.cache .phpstan.cache \ + infection.log .rector/ coverage/ coverage-html/ coverage.xml clover.xml + @printf "$(GREEN)✔ Build artifacts removed.$(RESET)\n" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d83de4d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,92 @@ +# Security Policy + +## Supported Versions + +Only the latest stable release receives security patches. + +| Version | Supported | +|---------|-----------| +| 1.x (latest) | ✅ Yes | +| < 1.0 | ❌ No | + +--- + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue for security vulnerabilities.** + +We ask that you follow responsible disclosure practices and report security issues privately so we can prepare a fix before public disclosure. + +### How to report + +Send an email to **shamavurasheed@gmail.com** with: + +- **Subject line:** `[SECURITY] php-io-cli — ` +- A clear description of the vulnerability +- Steps to reproduce (proof-of-concept code is welcome) +- The potential impact in your assessment +- The version(s) affected + +We use PGP-encrypted email if you prefer — ask for our public key in a separate (non-sensitive) message first. + +### What to expect + +| Timeline | Action | +|----------|--------| +| **Within 48 hours** | We acknowledge receipt of your report | +| **Within 7 days** | We assess severity and confirm whether we can reproduce | +| **Within 30 days** | We aim to release a patch (complex issues may take longer) | +| **After the patch is released** | We publicly credit the reporter (unless you prefer anonymity) | + +If we cannot reproduce the issue or determine it to be out of scope, we will explain why. + +--- + +## Scope + +### In scope + +- Code execution vulnerabilities in the library itself +- Unintended information disclosure via `Shell::run()`, `ConsoleIO`, or `BufferIO` +- Escape-sequence injection that could hijack a host terminal session +- Dependency vulnerabilities that affect `php-io-cli` users when installed as a library + +### Out of scope + +- Vulnerabilities in downstream applications that happen to use this library +- Issues that require physical access to the machine running the CLI +- Social engineering attacks +- Bugs without a security impact (please open a regular issue instead) + +--- + +## Security considerations for users + +### Shell::run() + +`Shell::run()` executes arbitrary shell commands via `proc_open`. **Never pass unsanitised user input as the `$command` argument.** Always construct commands from trusted, fixed strings, and validate any user-supplied values before interpolating them. + +```php +// ❌ Unsafe — user controls $branch +Shell::run("git checkout {$branch}"); + +// ✅ Safe — validate before use +if (!preg_match('/^[a-zA-Z0-9._\-\/]+$/', $branch)) { + throw new \InvalidArgumentException('Invalid branch name'); +} +Shell::run('git checkout ' . escapeshellarg($branch)); +``` + +### Terminal raw mode + +`Terminal::enableRaw()` disables canonical input processing and echo. The library registers a shutdown function and signal handlers to restore the terminal on exit. If your application forks or spawns child processes while a component is running, ensure child processes do not inherit the raw-mode state of the parent. + +### BufferIO in production + +`BufferIO` is designed for testing. Do not use it in production environments, as it writes everything to an in-memory `php://memory` stream and may buffer sensitive data (passwords, tokens) in process memory longer than necessary. + +--- + +## Acknowledgements + +We are grateful to the security researchers and community members who help keep this project safe. Confirmed reporters will be listed here (with permission) after the relevant patch is released. diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..fe5cdd4 --- /dev/null +++ b/architecture.md @@ -0,0 +1,263 @@ +# Architecture + +This document describes the internal structure of `php-io-cli` and how its layers relate to one another. + +--- + +## High-level layer map + +```mermaid +graph TD + APP["CLIApplication\n(entry point / dispatcher)"] + CMD["AbstractCommand\n(your commands extend this)"] + IO["IOInterface\n(unified I/O contract)"] + CIO["ConsoleIO\n(real TTY — delegates to components)"] + BIO["BufferIO\n(in-memory — for testing)"] + NIO["NullIO\n(silent — for daemons / CI)"] + PROMPT["AbstractPrompt\n(reactive event loop)"] + COMP["Components\n(TextInput · Select · Table · …)"] + DEP["Depends\n(State · Input · Terminal · Colors · Shell · …)"] + + APP --> CMD + CMD --> IO + IO --> CIO + IO --> BIO + IO --> NIO + CIO --> PROMPT + PROMPT --> COMP + COMP --> DEP +``` + +--- + +## Component lifecycle + +Every interactive component (TextInput, Select, Confirm, …) extends `Component → AbstractPrompt` and runs through this lifecycle inside `run()`: + +```mermaid +sequenceDiagram + participant Caller + participant AbstractPrompt + participant Component + participant Terminal + participant Input + participant State + + Caller->>AbstractPrompt: run() + AbstractPrompt->>Terminal: enableRaw() + AbstractPrompt->>Component: mount() → setup() + Note over Component: wire State + Input bindings + + loop Until stop() is called + AbstractPrompt->>Component: render() + Component-->>Terminal: echo ANSI output + AbstractPrompt->>Terminal: readKey() + Terminal-->>AbstractPrompt: raw key bytes + AbstractPrompt->>Component: update(normalizedKey) + Component->>Input: handle(key, state) + Input->>State: mutate values + State-->>Component: watcher callbacks fire + Component->>AbstractPrompt: [optionally] stop() + end + + AbstractPrompt->>Component: resolve() + Component-->>Caller: typed return value + AbstractPrompt->>Component: destroy() + AbstractPrompt->>Terminal: disableRaw() +``` + +--- + +## Class diagram — core types + +```mermaid +classDiagram + direction TB + + class IOInterface { + <> + +ask() + +askConfirmation() + +select() + +write() + +writeError() + } + + class BaseIO { + <> + +log() + +emergency() warning() info() … + } + + class ConsoleIO { + -InputInterface input + -OutputInterface output + +enableDebugging() + } + + class BufferIO { + +getOutput() string + +setUserInputs(inputs) + } + + class NullIO + + IOInterface <|.. BaseIO + BaseIO <|-- ConsoleIO + BaseIO <|-- NullIO + ConsoleIO <|-- BufferIO + + class ILifecycle { + <> + +mount() + +render() + +update(key) + +destroy() + } + + class IPromptComponent { + <> + +run() mixed + } + + class AbstractPrompt { + #running bool + #context RenderContext + #stop() + #dispatch(event) + } + + class Component { + #state State + #input Input + #renderer Renderer + #setup()* + #resolve()* + } + + ILifecycle <|.. AbstractPrompt + IPromptComponent <|.. AbstractPrompt + AbstractPrompt <|-- Component + + Component <|-- TextInput + Component <|-- Password + Component <|-- NumberInput + Component <|-- Confirm + Component <|-- Select + Component <|-- MultiSelect + Component <|-- Autocomplete + Component <|-- DatePicker +``` + +--- + +## Reactive state flow + +`State` is the single source of truth for every component. Bindings in `Input` mutate it; `watch()` callbacks fire synchronously after each mutation and may trigger re-renders. + +```mermaid +flowchart LR + KEY["Terminal::readKey()"] + NORM["Key::normalize()"] + INPUT["Input::handle()"] + STATE["State\n(reactive store)"] + WATCH["watch() callbacks"] + CTX["RenderContext\n.markDirty()"] + RENDER["Component::render()"] + + KEY --> NORM --> INPUT --> STATE + STATE --> WATCH --> CTX --> RENDER +``` + +--- + +## Shell streaming model + +`Shell::run()` avoids the classic pipe-deadlock problem by using `stream_select()` to drain stdout and stderr concurrently. + +```mermaid +sequenceDiagram + participant Shell + participant proc_open + participant stdout pipe + participant stderr pipe + participant tick callback + + Shell->>proc_open: open(command, pipes) + loop Until feof(stdout) && feof(stderr) + Shell->>stream_select: wait ≤50 ms + stream_select-->>Shell: ready pipes + Shell->>stdout pipe: fread(4096) + Shell->>stderr pipe: fread(4096) + Shell->>Shell: drain complete lines from buffers + Shell->>tick callback: tick(lastLine, isStderr) + end + Shell->>proc_open: proc_close() + Shell-->>Caller: ShellResult(exitCode, stdout[], stderr[]) +``` + +--- + +## IO fallback strategy + +`ConsoleIO` detects the terminal type and delegates accordingly: + +```mermaid +flowchart TD + CALL["ConsoleIO::ask() / select() / confirm()"] + TTY{{"posix_isatty(STDIN) ?"}} + REACTIVE["Reactive Component\n(raw mode, ANSI animation)"] + SYMFONY["Symfony QuestionHelper\n(plain text, pipe-safe)"] + + CALL --> TTY + TTY -- yes --> REACTIVE + TTY -- no --> SYMFONY +``` + +--- + +## Directory structure + +``` +src/ +├── AbstractCommand.php # Base for all commands +├── AbstractPrompt.php # Reactive event loop engine +├── CLIApplication.php # Dispatcher + built-in commands +├── Components/ # Interactive + display components +│ ├── Component.php # Base: wires State, Input, Renderer +│ ├── TextInput.php +│ ├── Password.php +│ ├── NumberInput.php +│ ├── Confirm.php +│ ├── Select.php +│ ├── MultiSelect.php +│ ├── Autocomplete.php +│ ├── DatePicker.php +│ ├── Table.php +│ ├── Alert.php +│ ├── ProgressBar.php +│ └── SpinnerComponent.php +├── Depends/ # Low-level primitives +│ ├── State.php # Reactive key-value store +│ ├── Input.php # Key binding dispatcher +│ ├── Terminal.php # Raw mode, escape sequences +│ ├── Colors.php # ANSI color / strip helper +│ ├── Renderer.php # Scroll windowing, cursor mgmt +│ ├── RenderContext.php # Per-frame metadata +│ ├── Shell.php # proc_open streaming wrapper +│ ├── ShellResult.php # Immutable result value object +│ ├── Fuzzy.php # Fuzzy search + scoring +│ ├── Key.php # Key constants + normalizer +│ ├── Spinner.php # Frame-based spinner engine +│ └── SpinnerFrames.php # Built-in frame sets +├── BaseIO.php # PSR-3 bridge +├── ConsoleIO.php # Real terminal IO +├── BufferIO.php # In-memory IO (testing) +├── NullIO.php # Silent IO (daemons) +├── Hooks.php # Pub/sub event bus +├── IOInterface.php # Unified I/O contract +├── ILifecycle.php # Component lifecycle contract +├── IPromptComponent.php # run() contract +├── IRenderer.php # Renderer contract +└── Silencer.php # PHP error suppression utility +``` diff --git a/composer.json b/composer.json index 4f1e0a6..9815c83 100644 --- a/composer.json +++ b/composer.json @@ -3,14 +3,14 @@ "description": "Interactive CLI component runtime for PHP microservice and hexagonal architectures.", "type": "library", "license": "MIT", - "keywords":[ + "keywords": [ "cli", "hexagonal-architecture", "microservices", "io", "terminal" ], - "authors":[ + "authors": [ { "name": "Alfacode Team", "email": "shamavurasheed@gmail.com", @@ -52,37 +52,60 @@ } }, "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": "phpunit", + "test:unit": "phpunit --testsuite Unit --no-coverage", + "test:integration": "phpunit --testsuite Integration --no-coverage", + "test:coverage": "phpunit --coverage-html build/coverage/html --coverage-clover build/coverage/clover.xml", + "test:coverage:text":"phpunit --coverage-text", + + "phpstan": "phpstan analyse --memory-limit=256M", + "stan": "@phpstan", + + "cs-check": "php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php", + "cs-fix": "php-cs-fixer fix --config=.php-cs-fixer.php", + + "refactor": "rector process", + "mutation": "infection --threads=max --min-msi=60 --min-covered-msi=80", + + "check": [ + "@cs-check", + "@phpstan", "@test" - ] + ], + + "check:full": [ + "@cs-check", + "@phpstan", + "@test:coverage", + "@mutation" + ], + + "demo": "php examples/demo.php" }, "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)" + "test": "Run the full test suite (Unit + Integration)", + "test:unit": "Run Unit tests only (fast, no I/O, no TTY)", + "test:integration": "Run Integration tests only", + "test:coverage": "Run tests and generate HTML + Clover coverage reports", + "test:coverage:text":"Run tests and print coverage summary to stdout", + "phpstan": "Run PHPStan static analysis at level 8", + "stan": "Alias for phpstan", + "cs-check": "Check code style without modifying files (dry-run)", + "cs-fix": "Apply PHP CS Fixer rules to src/, tests/, examples/", + "refactor": "Run Rector to apply automated code upgrades", + "mutation": "Run mutation testing with Infection (min MSI 60 %, min covered 80 %)", + "check": "Run cs-check + phpstan + tests (standard CI gate)", + "check:full": "Run check + coverage + mutation (full quality gate)", + "demo": "Launch the interactive component demo (requires a real TTY)" }, "extra": { - "_comment": "── php-io-cli: command auto-discovery ──────────────────────────────────────", + "_comment": "── php-io-cli: command auto-discovery ──────────────────────────────────────", "_comment2": "Applications that depend on this library should add their own 'extra.php-io-cli'", "_comment3": "block in THEIR composer.json (not this file). Example:", "_example": { "extra": { "php-io-cli": { - "commands":[ + "commands": [ "App\\Commands\\ServeCommand", "App\\Commands\\MakeModelCommand", "App\\Commands\\MigrateCommand" @@ -91,4 +114,4 @@ } } } -} \ No newline at end of file +} diff --git a/examples/demo.php b/examples/demo.php new file mode 100644 index 0000000..89a1475 --- /dev/null +++ b/examples/demo.php @@ -0,0 +1,340 @@ +#!/usr/bin/env php +placeholder('e.g. Alice') + ->default('World') + ->validate(fn(string $v): ?string => mb_strlen($v) >= 2 ? null : 'Name must be ≥ 2 characters') + ->run(); + + result('Name', $name); + pauseForUser(); +} + +function demoNumberInput(): void +{ + banner('NumberInput'); + Colors::line(' Numeric entry with ↑↓ stepping, min/max clamping, range hint.', Colors::GRAY); + echo PHP_EOL; + + $port = (new NumberInput('Server port')) + ->min(1) + ->max(65535) + ->default(8080) + ->step(100) + ->integer() + ->run(); + + result('Port', $port); + pauseForUser(); +} + +function demoPassword(): void +{ + banner('Password'); + Colors::line(' Masked input. TAB to toggle visibility. Live strength meter.', Colors::GRAY); + echo PHP_EOL; + + $secret = (new Password('Enter a password'))->showStrength()->run(); + + result('Length', mb_strlen((string) $secret) . ' chars'); + pauseForUser(); +} + +function demoConfirm(): void +{ + banner('Confirm'); + Colors::line(' Boolean toggle. ← → to switch. y/n shortcuts.', Colors::GRAY); + echo PHP_EOL; + + $ok = (new Confirm('Do you want to continue?', true))->run(); + + result('Answer', $ok ? 'Yes' : 'No'); + pauseForUser(); +} + +function demoSelect(): void +{ + banner('Select'); + Colors::line(' Single-selection list with fuzzy search and scroll windowing.', Colors::GRAY); + echo PHP_EOL; + + $env = (new Select('Deployment environment', [ + 'production', 'staging', 'development', 'local', + ]))->run(); + + result('Environment', (string) $env); + pauseForUser(); +} + +function demoMultiSelect(): void +{ + banner('MultiSelect'); + Colors::line(' Checkbox list. SPACE to toggle, ENTER to confirm.', Colors::GRAY); + echo PHP_EOL; + + $features = (new MultiSelect('Enable features', [ + 'Authentication', 'API Gateway', 'Queue Worker', + 'Scheduler', 'WebSockets', 'Rate Limiting', + ]))->run(); + + result('Features', $features); + pauseForUser(); +} + +function demoAutocomplete(): void +{ + banner('Autocomplete'); + Colors::line(' Text + live fuzzy dropdown. TAB to fill, ↑↓ to navigate.', Colors::GRAY); + echo PHP_EOL; + + $framework = (new Autocomplete('PHP framework', [ + 'Laravel', 'Symfony', 'Slim', 'Laminas', 'CodeIgniter', + 'Yii', 'CakePHP', 'Phalcon', 'Lumen', 'Hyperf', + ]))->maxSuggestions(6)->run(); + + result('Framework', (string) $framework); + pauseForUser(); +} + +function demoDatePicker(): void +{ + banner('DatePicker'); + Colors::line(' Calendar grid. ←→ day, ↑↓ week, [ ] month, t = today.', Colors::GRAY); + echo PHP_EOL; + + $date = (new DatePicker('Select a date'))->run(); + + result('Date', $date->format('Y-m-d')); + pauseForUser(); +} + +function demoTable(): void +{ + banner('Table'); + Colors::line(' Unicode box-drawing table. ANSI-safe column alignment.', Colors::GRAY); + echo PHP_EOL; + + $styles = ['box', 'bold', 'compact', 'minimal']; + + foreach ($styles as $style) { + Colors::line(" Style: {$style}", Colors::YELLOW); + Table::make() + ->headers(['Service', 'Status', 'Latency']) + ->rows([ + ['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms'], + ['auth-service', Colors::wrap('degraded', Colors::YELLOW), '340 ms'], + ['payment-worker', Colors::wrap('down', Colors::RED), '—'], + ]) + ->style($style) + ->render(); + } + + pauseForUser(); +} + +function demoAlert(): void +{ + banner('Alert'); + Colors::line(' Bordered notification boxes in four severity levels.', Colors::GRAY); + echo PHP_EOL; + + Alert::success('Deployment complete!', ['Version: 2.4.1', 'Region: eu-west-1']); + Alert::error('Build failed', ['Exit code: 1', 'Check /var/log/build.log']); + Alert::warning('API quota at 80%', ['Resets in 4 hours']); + Alert::info('Maintenance window tonight 02:00–04:00 UTC'); + + pauseForUser(); +} + +function demoProgressBar(): void +{ + banner('ProgressBar'); + Colors::line(' Determinate (fill + ETA) and indeterminate (bounce) modes.', Colors::GRAY); + echo PHP_EOL; + + Colors::line(' Determinate (30 steps):', Colors::BOLD); + $bar = new ProgressBar('Processing records', 30); + $bar->start(); + for ($i = 0; $i < 30; $i++) { + usleep(40_000); + $bar->advance(1, "Record #{$i}"); + } + $bar->finish('All 30 records processed'); + + echo PHP_EOL; + Colors::line(' Indeterminate (bounce, 2 s):', Colors::BOLD); + $ind = new ProgressBar('Waiting for lock'); + $ind->start(); + for ($i = 0; $i < 40; $i++) { + usleep(50_000); + $ind->tick('Attempt ' . ($i + 1)); + } + $ind->finish('Lock acquired'); + + pauseForUser(); +} + +function demoSpinner(): void +{ + banner('SpinnerComponent'); + Colors::line(' Non-blocking animated spinner. Six built-in frame styles.', Colors::GRAY); + echo PHP_EOL; + + $styles = ['dots', 'line', 'bars', 'pulse', 'arc', 'bounce']; + + foreach ($styles as $style) { + $spin = new SpinnerComponent("Style: {$style}", $style); + $spin->start(); + for ($i = 0; $i < 18; $i++) { + usleep(80_000); + $spin->tick('Running…'); + } + $spin->stop("Finished: {$style}"); + } + + pauseForUser(); +} + +function demoShell(): void +{ + banner('Shell Integration'); + Colors::line(' Shell::run with SpinnerComponent — live output, no deadlocks.', Colors::GRAY); + echo PHP_EOL; + + $spin = new SpinnerComponent('Checking environment', 'arc'); + $spin->start(); + + $result = Shell::run( + 'php -r " + echo \"PHP : \" . PHP_VERSION . PHP_EOL; + echo \"OS : \" . PHP_OS_FAMILY . PHP_EOL; + echo \"SAPI : \" . php_sapi_name() . PHP_EOL; + "', + tick: fn(string $line) => $spin->tick($line), + ); + + if ($result->ok()) { + $spin->stop('Environment checked'); + foreach ($result->stdout as $line) { + Colors::line(" {$line}", Colors::GREEN); + } + } else { + $spin->fail('Command failed'); + Alert::error('Shell error', $result->meaningfulErrors()); + } + + pauseForUser(); +} + +// ── Main menu loop ──────────────────────────────────────────────────────────── + +$menu = [ + '1. TextInput' => 'demoTextInput', + '2. NumberInput' => 'demoNumberInput', + '3. Password' => 'demoPassword', + '4. Confirm' => 'demoConfirm', + '5. Select' => 'demoSelect', + '6. MultiSelect' => 'demoMultiSelect', + '7. Autocomplete' => 'demoAutocomplete', + '8. DatePicker' => 'demoDatePicker', + '9. Table' => 'demoTable', + '10. Alert' => 'demoAlert', + '11. ProgressBar' => 'demoProgressBar', + '12. SpinnerComponent' => 'demoSpinner', + '13. Shell Integration' => 'demoShell', + '─────────────────' => null, + 'Exit' => null, +]; + +$choices = array_keys($menu); + +while (true) { + echo PHP_EOL; + Colors::line(' ██████╗ ██╗ ██╗██████╗ ██╗ ██████╗ ██████╗██╗ ██╗', Colors::CYAN); + Colors::line(' ██╔══██╗██║ ██║██╔══██╗ ██║██╔═══██╗ ██╔════╝██║ ██║', Colors::CYAN); + Colors::line(' ██████╔╝███████║██████╔╝█████╗██║██║ ██║ ██║ ██║ ██║', Colors::CYAN); + Colors::line(' ██╔═══╝ ██╔══██║██╔═══╝ ╚════╝██║██║ ██║ ██║ ██║ ██║', Colors::CYAN); + Colors::line(' ██║ ██║ ██║██║ ██║╚██████╔╝ ╚██████╗███████╗██║', Colors::CYAN); + Colors::line(' ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝', Colors::CYAN); + echo PHP_EOL; + Colors::line(' Interactive component demo — pick a component to explore', Colors::GRAY); + echo PHP_EOL; + + $pick = (new Select('Which component?', $choices))->run(); + + if ($pick === 'Exit' || $pick === '─────────────────') { + break; + } + + $fn = $menu[(string) $pick] ?? null; + if ($fn !== null && function_exists($fn)) { + $fn(); + } +} + +echo PHP_EOL; +Colors::line(' Thanks for exploring php-io-cli! 🚀', [Colors::BOLD, Colors::GREEN]); +Colors::line(' https://github.com/alfacode-team/php-io-cli', Colors::GRAY); +echo PHP_EOL; diff --git a/php-cs-fixer.php b/php-cs-fixer.php new file mode 100644 index 0000000..12c7504 --- /dev/null +++ b/php-cs-fixer.php @@ -0,0 +1,130 @@ +in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples']) + ->name('*.php') + ->notPath('vendor'); + +return (new Config()) + ->setRiskyAllowed(true) + ->setUsingCache(true) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache') + ->setRules([ + // ── Rulesets ────────────────────────────────────────────── + '@PER-CS' => true, + '@PER-CS:risky' => true, + '@PHP82Migration' => true, + '@PHP82Migration:risky' => true, + + // ── Strict types ────────────────────────────────────────── + 'declare_strict_types' => true, + 'strict_param' => true, + 'strict_comparison' => true, + + // ── Imports ─────────────────────────────────────────────── + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'fully_qualified_strict_types' => true, + 'global_namespace_import' => [ + 'import_classes' => false, + 'import_constants' => false, + 'import_functions' => false, + ], + + // ── Arrays ──────────────────────────────────────────────── + 'array_syntax' => ['syntax' => 'short'], + 'trailing_comma_in_multiline' => [ + 'elements' => ['arrays', 'arguments', 'parameters', 'match'], + ], + 'no_multiline_whitespace_around_double_arrow' => true, + 'normalize_index_brace' => true, + + // ── Classes ─────────────────────────────────────────────── + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', + 'case', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public', + 'method_protected', + 'method_private', + ], + ], + 'no_blank_lines_after_class_opening' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'const' => 'one', + 'method' => 'one', + 'property' => 'one', + 'trait_import' => 'none', + 'case' => 'none', + ], + ], + 'final_class' => false, // we have deliberate non-final classes + 'self_accessor' => true, + 'self_static_accessor' => true, + + // ── Functions & Closures ────────────────────────────────── + 'use_arrow_functions' => true, + 'static_lambda' => true, + 'no_useless_return' => true, + + // ── Strings ─────────────────────────────────────────────── + 'single_quote' => ['strings_containing_single_quote_chars' => false], + 'explicit_string_variable' => true, + 'heredoc_to_nowdoc' => true, + + // ── Control flow ────────────────────────────────────────── + 'no_superfluous_elseif' => true, + 'no_useless_else' => true, + 'simplified_if_return' => true, + 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], + + // ── Types ───────────────────────────────────────────────── + 'phpdoc_to_return_type' => true, + 'phpdoc_to_property_type' => true, + 'phpdoc_to_param_type' => true, + 'nullable_type_declaration_for_default_null_value' => true, + 'nullable_type_declaration' => ['syntax' => 'union'], + + // ── PHPDoc ──────────────────────────────────────────────── + 'phpdoc_order' => true, + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + 'phpdoc_no_empty_return' => true, + 'phpdoc_scalar' => true, + 'phpdoc_var_without_name' => true, + 'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => false], + + // ── Whitespace / Formatting ─────────────────────────────── + 'concat_space' => ['spacing' => 'one'], + 'binary_operator_spaces' => ['default' => 'single_space'], + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'], + 'operator_linebreak' => ['only_booleans' => true, 'position' => 'beginning'], + + // ── Misc ────────────────────────────────────────────────── + 'mb_str_functions' => true, + 'modernize_strpos' => true, + 'get_class_to_class_keyword' => true, + 'no_alias_functions' => true, + 'random_api_migration' => true, + 'pow_to_exponentiation' => true, + ]) + ->setFinder($finder); diff --git a/phpunit.xml b/phpunit.xml index 00f355f..e1b16e7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,38 +1,30 @@ - - - - - tests/Unit - - - tests/Integration - - - - - - src - - - src/Silencer.php - - - - - - - - - - - - - + + + + tests/Unit + + + tests/Integration + + + + + + + + + + + + + + + + src + + + src/Silencer.php + + diff --git a/tests/Integration/AbstractCommandTest.php b/tests/Integration/AbstractCommandTest.php index ecdee54..550500a 100644 --- a/tests/Integration/AbstractCommandTest.php +++ b/tests/Integration/AbstractCommandTest.php @@ -7,6 +7,7 @@ use AlfacodeTeam\PhpIoCli\AbstractCommand; use AlfacodeTeam\PhpIoCli\BufferIO; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -96,9 +97,7 @@ protected function handle(): int // ── Tests ───────────────────────────────────────────────────────────────────── -/** - * @covers \AlfacodeTeam\PhpIoCli\AbstractCommand - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\AbstractCommand::class)] final class AbstractCommandTest extends TestCase { // --------------------------------------------------------------- diff --git a/tests/Integration/BufferIOTest.php b/tests/Integration/BufferIOTest.php index d20dd7c..f9b6a9b 100644 --- a/tests/Integration/BufferIOTest.php +++ b/tests/Integration/BufferIOTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\BufferIO; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\BufferIO - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\BufferIO::class)] final class BufferIOTest extends TestCase { // --------------------------------------------------------------- diff --git a/tests/Integration/BufferIOUserInputsTest.php b/tests/Integration/BufferIOUserInputsTest.php new file mode 100644 index 0000000..5b3bfd9 --- /dev/null +++ b/tests/Integration/BufferIOUserInputsTest.php @@ -0,0 +1,260 @@ +name = 'confirm-cmd'; + $this->description = 'Asks a yes/no question'; + } + + protected function handle(): int + { + $answer = $this->io()->askConfirmation('Do you want to proceed?', false); + + if ($answer) { + $this->info('Proceeding!'); + } else { + $this->info('Aborted.'); + } + + return self::SUCCESS; + } + + // Expose the IO so we can call it directly in the fixture + private ?\AlfacodeTeam\PhpIoCli\IOInterface $ioRef = null; + + public function setIORef(\AlfacodeTeam\PhpIoCli\IOInterface $io): void + { + $this->ioRef = $io; + } + + private function io(): \AlfacodeTeam\PhpIoCli\IOInterface + { + // AbstractCommand stores IO internally; we replicate via reflection + $ref = new \ReflectionObject($this); + // walk up to AbstractCommand + $parent = $ref->getParentClass(); + if ($parent === false) { + throw new \RuntimeException('No parent'); + } + $prop = $parent->getProperty('io'); + $prop->setAccessible(true); + return $prop->getValue($this); + } +} + +/** + * Command that asks for a selection and reports the choice. + */ +final class SelectCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'select-cmd'; + $this->description = 'Asks the user to pick an environment'; + } + + protected function handle(): int + { + $choice = $this->ioInstance()->select( + 'Pick environment', + ['production', 'staging', 'development'], + 'staging' + ); + + $this->info("Selected: {$choice}"); + + return self::SUCCESS; + } + + private function ioInstance(): \AlfacodeTeam\PhpIoCli\IOInterface + { + $ref = new \ReflectionObject($this); + $parent = $ref->getParentClass(); + if ($parent === false) { + throw new \RuntimeException('No parent'); + } + $prop = $parent->getProperty('io'); + $prop->setAccessible(true); + return $prop->getValue($this); + } +} + +/** + * Command that asks free-text, a confirm, and then echoes both. + */ +final class MultiPromptCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'multi-prompt'; + $this->description = 'Multiple prompts in sequence'; + } + + protected function handle(): int + { + $io = $this->ioInstance(); + $name = $io->ask('What is your name?', 'World'); + $ok = $io->askConfirmation("Hello {$name}, continue?", true); + + if ($ok) { + $this->info("Hello, {$name}!"); + } else { + $this->info('Cancelled.'); + } + + return self::SUCCESS; + } + + private function ioInstance(): \AlfacodeTeam\PhpIoCli\IOInterface + { + $ref = new \ReflectionObject($this); + $parent = $ref->getParentClass(); + if ($parent === false) { + throw new \RuntimeException('No parent'); + } + $prop = $parent->getProperty('io'); + $prop->setAccessible(true); + return $prop->getValue($this); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[CoversClass(\AlfacodeTeam\PhpIoCli\BufferIO::class)] +final class BufferIOUserInputsTest extends TestCase +{ + // --------------------------------------------------------------- + // Confirm prompt — user answers "yes" + // --------------------------------------------------------------- + + public function test_confirm_prompt_with_yes_input(): void + { + $io = new BufferIO(); + $io->setUserInputs(['yes']); + + $cmd = new ConfirmCommand(); + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('Proceeding!', $io->getOutput()); + } + + // --------------------------------------------------------------- + // Confirm prompt — user answers "no" + // --------------------------------------------------------------- + + public function test_confirm_prompt_with_no_input(): void + { + $io = new BufferIO(); + $io->setUserInputs(['no']); + + $cmd = new ConfirmCommand(); + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('Aborted.', $io->getOutput()); + } + + // --------------------------------------------------------------- + // Select prompt — picks the second option + // --------------------------------------------------------------- + + public function test_select_prompt_with_pre_set_choice(): void + { + $io = new BufferIO(); + // Symfony ChoiceQuestion accepts the option value as input + $io->setUserInputs(['staging']); + + $cmd = new SelectCommand(); + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('staging', $io->getOutput()); + } + + // --------------------------------------------------------------- + // Select prompt — picks by index (Symfony also accepts numeric index) + // --------------------------------------------------------------- + + public function test_select_prompt_with_numeric_index(): void + { + $io = new BufferIO(); + $io->setUserInputs(['1']); // index 1 → 'staging' + + $cmd = new SelectCommand(); + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('staging', $io->getOutput()); + } + + // --------------------------------------------------------------- + // Multiple sequential prompts + // --------------------------------------------------------------- + + public function test_multiple_prompts_consume_inputs_in_order(): void + { + $io = new BufferIO(); + $io->setUserInputs(['Alice', 'yes']); + + $cmd = new MultiPromptCommand(); + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('Hello, Alice!', $io->getOutput()); + } + + public function test_multiple_prompts_with_declined_confirmation(): void + { + $io = new BufferIO(); + $io->setUserInputs(['Bob', 'no']); + + $cmd = new MultiPromptCommand(); + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('Cancelled.', $io->getOutput()); + } + + // --------------------------------------------------------------- + // setUserInputs makes io interactive + // --------------------------------------------------------------- + + public function test_set_user_inputs_marks_io_as_interactive(): void + { + $io = new BufferIO(); + $this->assertFalse($io->isInteractive()); + + $io->setUserInputs(['yes']); + $this->assertTrue($io->isInteractive()); + } + + // --------------------------------------------------------------- + // Output capture still works with interactive inputs set + // --------------------------------------------------------------- + + public function test_output_is_still_captured_with_user_inputs(): void + { + $io = new BufferIO(); + $io->setUserInputs(['yes']); + $io->write('Captured line'); + + $this->assertStringContainsString('Captured line', $io->getOutput()); + } +} diff --git a/tests/Integration/CLIApplicationTest.php b/tests/Integration/CLIApplicationTest.php index 29b5124..41a4307 100644 --- a/tests/Integration/CLIApplicationTest.php +++ b/tests/Integration/CLIApplicationTest.php @@ -8,6 +8,7 @@ use AlfacodeTeam\PhpIoCli\BufferIO; use AlfacodeTeam\PhpIoCli\CLIApplication; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -44,9 +45,7 @@ protected function handle(): int // ── Tests ───────────────────────────────────────────────────────────────────── -/** - * @covers \AlfacodeTeam\PhpIoCli\CLIApplication - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\CLIApplication::class)] final class CLIApplicationTest extends TestCase { private function makeApp(): CLIApplication diff --git a/tests/Integration/ShellTest.php b/tests/Integration/ShellTest.php new file mode 100644 index 0000000..88937a8 --- /dev/null +++ b/tests/Integration/ShellTest.php @@ -0,0 +1,211 @@ +assertInstanceOf(ShellResult::class, $result); + } + + public function test_run_ok_is_true_for_successful_command(): void + { + $result = Shell::run('php -r "exit(0);"'); + + $this->assertTrue($result->ok()); + $this->assertFalse($result->failed()); + $this->assertSame(0, $result->exitCode); + } + + public function test_run_captures_stdout(): void + { + $result = Shell::run('php -r "echo \"hello shell\";"'); + + $this->assertTrue($result->ok()); + $this->assertStringContainsString('hello shell', $result->output()); + } + + public function test_run_captures_multiline_stdout(): void + { + $result = Shell::run('php -r "echo \"line1\nline2\nline3\";"'); + + $this->assertTrue($result->ok()); + $this->assertContains('line1', $result->stdout); + $this->assertContains('line2', $result->stdout); + $this->assertContains('line3', $result->stdout); + } + + // --------------------------------------------------------------- + // Shell::run — failure path + // --------------------------------------------------------------- + + public function test_run_failed_is_true_for_non_zero_exit(): void + { + $result = Shell::run('php -r "exit(1);"'); + + $this->assertTrue($result->failed()); + $this->assertFalse($result->ok()); + $this->assertSame(1, $result->exitCode); + } + + public function test_run_captures_stderr(): void + { + // php -r with a deliberate notice/warning goes to stderr + $result = Shell::run('php -r "fwrite(STDERR, \'error output\');"'); + + $this->assertStringContainsString('error output', $result->errors()); + } + + public function test_run_exit_code_matches_process_exit(): void + { + $result = Shell::run('php -r "exit(42);"'); + + $this->assertSame(42, $result->exitCode); + } + + // --------------------------------------------------------------- + // Shell::run — tick callback + // --------------------------------------------------------------- + + public function test_run_tick_callback_is_invoked(): void + { + $ticked = false; + + Shell::run( + 'php -r "echo \"tick test\";"', + tick: function (string $lastLine, bool $isStderr) use (&$ticked): void { + $ticked = true; + } + ); + + $this->assertTrue($ticked, 'tick callback must be called at least once'); + } + + public function test_run_tick_callback_receives_last_line(): void + { + $receivedLines = []; + + Shell::run( + 'php -r "echo \"abc\ndef\";"', + tick: function (string $lastLine) use (&$receivedLines): void { + if ($lastLine !== '') { + $receivedLines[] = $lastLine; + } + } + ); + + // At least one of the lines should have been surfaced in the tick + $this->assertNotEmpty($receivedLines); + } + + // --------------------------------------------------------------- + // Shell::run — environment variables + // --------------------------------------------------------------- + + public function test_run_passes_env_variables_to_child(): void + { + $result = Shell::run( + 'php -r "echo getenv(\'MY_TEST_VAR\');"', + env: ['MY_TEST_VAR' => 'hello-from-env'] + ); + + $this->assertTrue($result->ok()); + $this->assertStringContainsString('hello-from-env', $result->output()); + } + + // --------------------------------------------------------------- + // Shell::run — working directory + // --------------------------------------------------------------- + + public function test_run_respects_cwd(): void + { + $cwd = sys_get_temp_dir(); + $result = Shell::run('php -r "echo getcwd();"', cwd: $cwd); + + $this->assertTrue($result->ok()); + // Resolve symlinks to handle /var → /private/var on macOS + $this->assertSame( + realpath($cwd), + realpath(trim($result->output())) + ); + } + + // --------------------------------------------------------------- + // Shell::capture — success + // --------------------------------------------------------------- + + public function test_capture_returns_trimmed_stdout_on_success(): void + { + $output = Shell::capture('php -r "echo \' trimmed \';"'); + + $this->assertSame('trimmed', $output); + } + + public function test_capture_returns_null_on_failure(): void + { + $output = Shell::capture('php -r "exit(1);"'); + + $this->assertNull($output); + } + + public function test_capture_php_version_contains_version_string(): void + { + $output = Shell::capture('php --version'); + + $this->assertNotNull($output); + $this->assertStringContainsString('PHP', (string) $output); + } + + // --------------------------------------------------------------- + // Shell::run — stdout and stderr arrays are accessible + // --------------------------------------------------------------- + + public function test_run_stdout_property_is_array_of_lines(): void + { + $result = Shell::run('php -r "echo \"a\nb\nc\";"'); + + $this->assertIsArray($result->stdout); + $this->assertGreaterThanOrEqual(1, count($result->stdout)); + } + + public function test_run_stderr_property_is_array(): void + { + $result = Shell::run('php -r "echo \'ok\';"'); + + $this->assertIsArray($result->stderr); + } + + // --------------------------------------------------------------- + // Shell::run — proc_open failure (bad command) + // --------------------------------------------------------------- + + public function test_run_returns_failure_for_completely_invalid_command(): void + { + // A command that cannot be found at all still returns a ShellResult + $result = Shell::run('this-command-definitely-does-not-exist-xyz-12345 2>/dev/null; exit 127'); + + $this->assertInstanceOf(ShellResult::class, $result); + $this->assertTrue($result->failed()); + } +} diff --git a/tests/Unit/AlertTest.php b/tests/Unit/AlertTest.php new file mode 100644 index 0000000..1a81e82 --- /dev/null +++ b/tests/Unit/AlertTest.php @@ -0,0 +1,227 @@ +capture(fn() => Alert::success('Deployment complete!')); + + $this->assertStringContainsString('Deployment complete!', $output); + } + + public function test_success_contains_checkmark_icon(): void + { + $output = $this->capture(fn() => Alert::success('Done')); + + $this->assertStringContainsString('✔', $output); + } + + public function test_success_renders_body_lines(): void + { + $output = $this->capture( + fn() => Alert::success('Deployed!', ['Version: 2.4.1', 'Region: eu-west-1']) + ); + + $this->assertStringContainsString('Version: 2.4.1', $output); + $this->assertStringContainsString('Region: eu-west-1', $output); + } + + public function test_success_renders_unicode_box_borders(): void + { + $output = $this->capture(fn() => Alert::success('OK')); + + // The alert draws a box with at least one of these border chars + $hasBorder = str_contains($output, '┌') || str_contains($output, '─') || str_contains($output, '└'); + $this->assertTrue($hasBorder, 'Expected Unicode box border characters in output'); + } + + // --------------------------------------------------------------- + // error + // --------------------------------------------------------------- + + public function test_error_contains_title(): void + { + $output = $this->capture(fn() => Alert::error('Build failed')); + + $this->assertStringContainsString('Build failed', $output); + } + + public function test_error_contains_x_icon(): void + { + $output = $this->capture(fn() => Alert::error('Build failed')); + + $this->assertStringContainsString('✘', $output); + } + + public function test_error_renders_body(): void + { + $output = $this->capture( + fn() => Alert::error('Build failed', ['Exit code: 1', 'Check logs']) + ); + + $this->assertStringContainsString('Exit code: 1', $output); + $this->assertStringContainsString('Check logs', $output); + } + + // --------------------------------------------------------------- + // warning + // --------------------------------------------------------------- + + public function test_warning_contains_title(): void + { + $output = $this->capture(fn() => Alert::warning('API quota at 80%')); + + $this->assertStringContainsString('API quota at 80%', $output); + } + + public function test_warning_contains_exclamation_icon(): void + { + $output = $this->capture(fn() => Alert::warning('Watch out')); + + $this->assertStringContainsString('!', $output); + } + + public function test_warning_renders_body(): void + { + $output = $this->capture( + fn() => Alert::warning('Low memory', ['Used: 95%', 'Free: 200MB']) + ); + + $this->assertStringContainsString('Used: 95%', $output); + $this->assertStringContainsString('Free: 200MB', $output); + } + + // --------------------------------------------------------------- + // info + // --------------------------------------------------------------- + + public function test_info_contains_title(): void + { + $output = $this->capture(fn() => Alert::info('New version available: 3.0.0')); + + $this->assertStringContainsString('New version available: 3.0.0', $output); + } + + public function test_info_contains_i_icon(): void + { + $output = $this->capture(fn() => Alert::info('Note')); + + $this->assertStringContainsString('i', $output); + } + + public function test_info_renders_body(): void + { + $output = $this->capture( + fn() => Alert::info('Heads up', ['Maintenance tonight 02:00 UTC']) + ); + + $this->assertStringContainsString('Maintenance tonight 02:00 UTC', $output); + } + + // --------------------------------------------------------------- + // String body (not array) + // --------------------------------------------------------------- + + public function test_body_as_string_renders_correctly(): void + { + $output = $this->capture( + fn() => Alert::success('Done', 'Single line body') + ); + + $this->assertStringContainsString('Single line body', $output); + } + + // --------------------------------------------------------------- + // Empty body + // --------------------------------------------------------------- + + public function test_empty_body_renders_without_separator(): void + { + $output = $this->capture(fn() => Alert::success('Title only')); + + $this->assertStringContainsString('Title only', $output); + // No body separator (├) should appear when body is empty + $this->assertStringNotContainsString('├', $output); + } + + // --------------------------------------------------------------- + // block() + // --------------------------------------------------------------- + + public function test_block_contains_uppercased_title(): void + { + $output = $this->capture(fn() => Alert::block('critical error')); + + $this->assertStringContainsString('CRITICAL ERROR', $output); + } + + public function test_block_renders_body_lines(): void + { + $output = $this->capture( + fn() => Alert::block('Fatal', ['Check /var/log/app.log']) + ); + + $this->assertStringContainsString('Check /var/log/app.log', $output); + } + + // --------------------------------------------------------------- + // ANSI-safe width: long body lines don't crash + // --------------------------------------------------------------- + + public function test_long_body_line_renders_without_error(): void + { + $longLine = str_repeat('x', 120); + + $output = $this->capture(fn() => Alert::info('Wide box', [$longLine])); + + $this->assertStringContainsString($longLine, $output); + } + + // --------------------------------------------------------------- + // ANSI codes in body cells don't corrupt borders + // --------------------------------------------------------------- + + public function test_ansi_colored_body_line_is_included(): void + { + $colored = Colors::wrap('healthy', Colors::GREEN); + + $output = $this->capture(fn() => Alert::success('Status', [$colored])); + + $this->assertStringContainsString('healthy', Colors::strip($output)); + } +} diff --git a/tests/Unit/ColorsTest.php b/tests/Unit/ColorsTest.php index 9838305..d02f9f2 100644 --- a/tests/Unit/ColorsTest.php +++ b/tests/Unit/ColorsTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\Depends\Colors; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Colors - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Colors::class)] final class ColorsTest extends TestCase { protected function setUp(): void diff --git a/tests/Unit/FuzzyTest.php b/tests/Unit/FuzzyTest.php index 7b32c28..62bc719 100644 --- a/tests/Unit/FuzzyTest.php +++ b/tests/Unit/FuzzyTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\Depends\Fuzzy; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Fuzzy - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Fuzzy::class)] final class FuzzyTest extends TestCase { // --------------------------------------------------------------- diff --git a/tests/Unit/HooksTest.php b/tests/Unit/HooksTest.php index 447f3e0..8f66fdd 100644 --- a/tests/Unit/HooksTest.php +++ b/tests/Unit/HooksTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\Hooks; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\Hooks - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Hooks::class)] final class HooksTest extends TestCase { private Hooks $hooks; diff --git a/tests/Unit/InputTest.php b/tests/Unit/InputTest.php index b9c0cec..6e52437 100644 --- a/tests/Unit/InputTest.php +++ b/tests/Unit/InputTest.php @@ -7,10 +7,9 @@ use AlfacodeTeam\PhpIoCli\Depends\Input; use AlfacodeTeam\PhpIoCli\Depends\State; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Input - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Input::class)] final class InputTest extends TestCase { // --------------------------------------------------------------- diff --git a/tests/Unit/KeyTest.php b/tests/Unit/KeyTest.php index 4f4d166..9fdc341 100644 --- a/tests/Unit/KeyTest.php +++ b/tests/Unit/KeyTest.php @@ -6,19 +6,17 @@ use AlfacodeTeam\PhpIoCli\Depends\Key; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Key - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Key::class)] final class KeyTest extends TestCase { // --------------------------------------------------------------- // normalize // --------------------------------------------------------------- - /** - * @dataProvider escapeSequenceProvider - */ + #[DataProvider('escapeSequenceProvider')] public function test_normalize_maps_escape_sequences(string $raw, string $expected): void { $this->assertSame($expected, Key::normalize($raw)); @@ -64,9 +62,7 @@ public function test_normalize_returns_unknown_sequence_as_is(): void // isPrintable // --------------------------------------------------------------- - /** - * @dataProvider printableCharProvider - */ + #[DataProvider('printableCharProvider')] public function test_is_printable_returns_true_for_printable_chars(string $char): void { $this->assertTrue(Key::isPrintable($char)); @@ -85,9 +81,7 @@ public static function printableCharProvider(): array ]; } - /** - * @dataProvider nonPrintableCharProvider - */ + #[DataProvider('nonPrintableCharProvider')] public function test_is_printable_returns_false_for_control_chars(string $char): void { $this->assertFalse(Key::isPrintable($char)); diff --git a/tests/Unit/NullIOTest.php b/tests/Unit/NullIOTest.php index a9890c9..ecc835c 100644 --- a/tests/Unit/NullIOTest.php +++ b/tests/Unit/NullIOTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\NullIO; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\NullIO - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\NullIO::class)] final class NullIOTest extends TestCase { private NullIO $io; diff --git a/tests/Unit/RenderContextTest.php b/tests/Unit/RenderContextTest.php index 64afb11..610d251 100644 --- a/tests/Unit/RenderContextTest.php +++ b/tests/Unit/RenderContextTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\Depends\RenderContext; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\RenderContext - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\RenderContext::class)] final class RenderContextTest extends TestCase { public function test_default_dirty_is_true(): void diff --git a/tests/Unit/RendererTest.php b/tests/Unit/RendererTest.php new file mode 100644 index 0000000..5977233 --- /dev/null +++ b/tests/Unit/RendererTest.php @@ -0,0 +1,315 @@ + 'Choose an item', + 'search' => '', + 'index' => 0, + 'loading' => false, + 'items' => ['Alpha', 'Beta', 'Gamma', 'Delta'], + 'selected' => [], + 'multi' => false, + ], $data)); + } + + // --------------------------------------------------------------- + // key() + // --------------------------------------------------------------- + + public function test_key_returns_class_name(): void + { + $renderer = new Renderer(); + + $this->assertSame(Renderer::class, $renderer->key()); + } + + // --------------------------------------------------------------- + // render() — delegates to beforeRender + paint + afterRender + // --------------------------------------------------------------- + + public function test_render_outputs_question(): void + { + $renderer = new Renderer(); + $state = $this->makeState(['question' => 'Pick a region']); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('Pick a region', $output); + } + + public function test_render_outputs_list_items(): void + { + $renderer = new Renderer(); + $state = $this->makeState([ + 'items' => ['PHP', 'Python', 'Go'], + ]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('PHP', $output); + $this->assertStringContainsString('Python', $output); + $this->assertStringContainsString('Go', $output); + } + + // --------------------------------------------------------------- + // renderState() — convenience overload + // --------------------------------------------------------------- + + public function test_render_state_produces_same_structure(): void + { + $renderer = new Renderer(); + $state = $this->makeState(['question' => 'Pick one']); + + $output = $this->capture(fn() => $renderer->renderState($state)); + + $this->assertStringContainsString('Pick one', $output); + } + + // --------------------------------------------------------------- + // Loading state + // --------------------------------------------------------------- + + public function test_loading_state_shows_loading_indicator(): void + { + $renderer = new Renderer(); + $state = $this->makeState(['loading' => true]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('Loading', $output); + } + + public function test_loading_state_hides_item_list(): void + { + $renderer = new Renderer(); + $state = $this->makeState([ + 'loading' => true, + 'items' => ['Alpha', 'Beta'], + ]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + // Items should not appear while loading + $this->assertStringNotContainsString('Alpha', $output); + } + + // --------------------------------------------------------------- + // Empty items — no-match state + // --------------------------------------------------------------- + + public function test_empty_items_shows_no_results_message(): void + { + $renderer = new Renderer(); + $state = $this->makeState(['items' => [], 'loading' => false]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('No results', $output); + } + + // --------------------------------------------------------------- + // Active index highlight + // --------------------------------------------------------------- + + public function test_active_item_receives_highlight_marker(): void + { + $renderer = new Renderer(); + $state = $this->makeState([ + 'items' => ['Alpha', 'Beta', 'Gamma'], + 'index' => 1, + ]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + // The active item pointer '›' should appear alongside 'Beta' + $this->assertStringContainsString('Beta', $output); + $this->assertStringContainsString('›', $output); + } + + // --------------------------------------------------------------- + // Multi-select mode — checkbox rendering + // --------------------------------------------------------------- + + public function test_multi_mode_renders_checkboxes(): void + { + $renderer = new Renderer(); + $state = $this->makeState([ + 'items' => ['Auth', 'API', 'Queue'], + 'multi' => true, + 'selected' => ['Auth'], + ]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + // Filled ⬢ for selected, empty ⬡ for unselected + $this->assertStringContainsString('⬢', $output); + $this->assertStringContainsString('⬡', $output); + } + + public function test_multi_mode_marks_selected_item(): void + { + $renderer = new Renderer(); + $state = $this->makeState([ + 'items' => ['Auth', 'API'], + 'multi' => true, + 'selected' => ['Auth'], + ]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('Auth', $output); + } + + // --------------------------------------------------------------- + // Scroll windowing — "more items" hints + // --------------------------------------------------------------- + + public function test_scroll_indicator_shown_when_items_exceed_window(): void + { + $renderer = new Renderer(); + $items = array_map(fn($i) => "Item-{$i}", range(1, 20)); + $state = $this->makeState(['items' => $items, 'index' => 0]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + // When there are more items than the window size (10), a "more" hint appears + $this->assertStringContainsString('more items', $output); + } + + // --------------------------------------------------------------- + // Search query rendering + // --------------------------------------------------------------- + + public function test_search_query_appears_in_output(): void + { + $renderer = new Renderer(); + $state = $this->makeState(['search' => 'alph']); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('alph', $output); + } + + public function test_search_label_appears_in_output(): void + { + $renderer = new Renderer(); + $state = $this->makeState(); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('Search', $output); + } + + // --------------------------------------------------------------- + // beforeRender / afterRender hooks + // --------------------------------------------------------------- + + public function test_before_render_does_not_throw(): void + { + $renderer = new Renderer(); + $state = $this->makeState(); + $ctx = new RenderContext(); + + // beforeRender emits cursor-hide escape codes — capture and discard + $this->capture(fn() => $renderer->beforeRender($state, $ctx)); + + $this->assertTrue(true); + } + + public function test_after_render_does_not_throw(): void + { + $renderer = new Renderer(); + $state = $this->makeState(); + $ctx = new RenderContext(); + + $this->capture(fn() => $renderer->afterRender($state, $ctx)); + + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Help text footer + // --------------------------------------------------------------- + + public function test_single_select_footer_contains_nav_and_enter(): void + { + $renderer = new Renderer(); + $state = $this->makeState(['multi' => false]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('nav', $output); + $this->assertStringContainsString('enter', $output); + } + + public function test_multi_select_footer_contains_space_toggle(): void + { + $renderer = new Renderer(); + $state = $this->makeState(['multi' => true, 'items' => ['A']]); + $ctx = new RenderContext(); + + $output = $this->capture(fn() => $renderer->render($state, $ctx)); + + $this->assertStringContainsString('space', $output); + } +} diff --git a/tests/Unit/ShellResultTest.php b/tests/Unit/ShellResultTest.php index 68e86d7..f969709 100644 --- a/tests/Unit/ShellResultTest.php +++ b/tests/Unit/ShellResultTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\Depends\ShellResult; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\ShellResult - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\ShellResult::class)] final class ShellResultTest extends TestCase { public function test_ok_returns_true_for_exit_code_zero(): void diff --git a/tests/Unit/SpinnerFramesTest.php b/tests/Unit/SpinnerFramesTest.php new file mode 100644 index 0000000..eb7bbbb --- /dev/null +++ b/tests/Unit/SpinnerFramesTest.php @@ -0,0 +1,121 @@ +assertIsArray($frames); + $this->assertNotEmpty($frames, "Frame set '{$style}' must not be empty"); + } + + public static function namedStyleProvider(): array + { + return [ + 'dots' => ['dots'], + 'line' => ['line'], + 'bars' => ['bars'], + 'pulse' => ['pulse'], + 'arc' => ['arc'], + 'bounce' => ['bounce'], + ]; + } + + // --------------------------------------------------------------- + // Unknown style falls back to dots (default branch) + // --------------------------------------------------------------- + + public function test_get_unknown_style_returns_default_dots(): void + { + $dots = SpinnerFrames::get('dots'); + $unknown = SpinnerFrames::get('nonexistent-style'); + + $this->assertSame($dots, $unknown); + } + + // --------------------------------------------------------------- + // default() is identical to get('dots') + // --------------------------------------------------------------- + + public function test_default_returns_same_as_get_dots(): void + { + $this->assertSame(SpinnerFrames::get('dots'), SpinnerFrames::default()); + } + + // --------------------------------------------------------------- + // Named shortcut methods + // --------------------------------------------------------------- + + public function test_dots_shortcut_matches_get(): void + { + $this->assertSame(SpinnerFrames::get('dots'), SpinnerFrames::dots()); + } + + public function test_bars_shortcut_matches_get(): void + { + $this->assertSame(SpinnerFrames::get('bars'), SpinnerFrames::bars()); + } + + public function test_line_shortcut_matches_get(): void + { + $this->assertSame(SpinnerFrames::get('line'), SpinnerFrames::line()); + } + + public function test_pulse_shortcut_matches_get(): void + { + $this->assertSame(SpinnerFrames::get('pulse'), SpinnerFrames::pulse()); + } + + // --------------------------------------------------------------- + // Frame content sanity checks + // --------------------------------------------------------------- + + public function test_every_frame_is_a_non_empty_string(): void + { + $styles = ['dots', 'line', 'bars', 'pulse', 'arc', 'bounce']; + + foreach ($styles as $style) { + foreach (SpinnerFrames::get($style) as $i => $frame) { + $this->assertIsString($frame, "{$style}[{$i}] must be a string"); + $this->assertNotSame('', $frame, "{$style}[{$i}] must not be an empty string"); + } + } + } + + public function test_line_style_contains_exactly_four_frames(): void + { + // The classic line spinner: - \ | / + $this->assertCount(4, SpinnerFrames::line()); + } + + public function test_dots_style_contains_ten_frames(): void + { + // Braille dots: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ + $this->assertCount(10, SpinnerFrames::dots()); + } + + public function test_bounce_style_has_more_frames_than_dots(): void + { + // Bounce has a wider animation loop + $this->assertGreaterThan( + count(SpinnerFrames::dots()), + count(SpinnerFrames::get('bounce')) + ); + } +} diff --git a/tests/Unit/SpinnerTest.php b/tests/Unit/SpinnerTest.php new file mode 100644 index 0000000..2be8595 --- /dev/null +++ b/tests/Unit/SpinnerTest.php @@ -0,0 +1,156 @@ +assertSame('', $spinner->tick()); + } + + public function test_tick_returns_empty_string_after_stop(): void + { + $spinner = new Spinner(SpinnerFrames::line()); + $spinner->start(); + $spinner->stop(); + + $this->assertSame('', $spinner->tick()); + } + + // --------------------------------------------------------------- + // tick() returns a frame string when running + // --------------------------------------------------------------- + + public function test_tick_returns_non_empty_string_when_running(): void + { + $spinner = new Spinner(SpinnerFrames::dots()); + $spinner->start(); + + $frame = $spinner->tick(); + + $this->assertIsString($frame); + $this->assertNotSame('', $frame); + } + + public function test_tick_returns_value_from_provided_frames(): void + { + $frames = ['A', 'B', 'C']; + $spinner = new Spinner($frames); + $spinner->start(); + + $frame = $spinner->tick(); + + $this->assertContains($frame, $frames); + } + + // --------------------------------------------------------------- + // tick() advances the frame index over time + // --------------------------------------------------------------- + + public function test_tick_advances_frame_after_interval(): void + { + // Use a very short interval so we don't wait long in tests + $frames = ['X', 'Y', 'Z']; + $spinner = new Spinner($frames, interval: 0.001); // 1 ms interval + $spinner->start(); + + $first = $spinner->tick(); + + // Sleep past the interval to force a frame advance + usleep(5_000); // 5 ms + + $second = $spinner->tick(); + + // After enough time, the frame should have advanced + // (X → Y or further) + $this->assertNotSame($first, $second, 'Frame should advance after the interval elapses'); + } + + public function test_frames_wrap_around_cyclically(): void + { + // Two frames, very short interval — tick many times to confirm cycling + $frames = ['F1', 'F2']; + $spinner = new Spinner($frames, interval: 0.0001); + $spinner->start(); + + $seen = []; + for ($i = 0; $i < 40; $i++) { + usleep(500); + $seen[] = $spinner->tick(); + } + + $unique = array_unique($seen); + sort($unique); + + $this->assertSame(['F1', 'F2'], $unique, 'Both frames should appear during cycling'); + } + + // --------------------------------------------------------------- + // Default frames (no constructor arg) use SpinnerFrames::default() + // --------------------------------------------------------------- + + public function test_default_frames_are_used_when_none_provided(): void + { + $spinner = new Spinner(); + $spinner->start(); + + $frame = $spinner->tick(); + + $this->assertContains($frame, SpinnerFrames::default()); + } + + // --------------------------------------------------------------- + // start() / stop() are idempotent + // --------------------------------------------------------------- + + public function test_calling_start_twice_does_not_throw(): void + { + $spinner = new Spinner(SpinnerFrames::line()); + $spinner->start(); + $spinner->start(); // second call — should not throw + + $this->assertNotSame('', $spinner->tick()); + } + + public function test_calling_stop_twice_does_not_throw(): void + { + $spinner = new Spinner(SpinnerFrames::line()); + $spinner->start(); + $spinner->stop(); + $spinner->stop(); // second call — should not throw + + $this->assertSame('', $spinner->tick()); + } + + // --------------------------------------------------------------- + // tick() does not advance below interval + // --------------------------------------------------------------- + + public function test_tick_does_not_advance_before_interval(): void + { + // Long interval — frame should NOT change between two rapid ticks + $frames = ['A', 'B', 'C']; + $spinner = new Spinner($frames, interval: 60.0); // 60-second interval + $spinner->start(); + + $first = $spinner->tick(); + $second = $spinner->tick(); // called immediately — no time has passed + + $this->assertSame($first, $second, 'Frame must not advance before the interval elapses'); + } +} diff --git a/tests/Unit/StateTest.php b/tests/Unit/StateTest.php index 147e7b0..c1bd75f 100644 --- a/tests/Unit/StateTest.php +++ b/tests/Unit/StateTest.php @@ -6,10 +6,9 @@ use AlfacodeTeam\PhpIoCli\Depends\State; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\State - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\State::class)] final class StateTest extends TestCase { // --------------------------------------------------------------- diff --git a/tests/Unit/TableTest.php b/tests/Unit/TableTest.php index 12a55cd..c1756a2 100644 --- a/tests/Unit/TableTest.php +++ b/tests/Unit/TableTest.php @@ -7,10 +7,10 @@ use AlfacodeTeam\PhpIoCli\Components\Table; use AlfacodeTeam\PhpIoCli\Depends\Colors; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; -/** - * @covers \AlfacodeTeam\PhpIoCli\Components\Table - */ +#[CoversClass(\AlfacodeTeam\PhpIoCli\Components\Table::class)] final class TableTest extends TestCase { protected function setUp(): void @@ -70,9 +70,7 @@ public function test_empty_table_returns_empty_string(): void // Styles // --------------------------------------------------------------- - /** - * @dataProvider styleProvider - */ + #[DataProvider('styleProvider')] public function test_table_renders_with_different_styles(string $style): void { $output = Colors::strip( From 59aa06a790b4953b46f46e291504b7b5aca61bc4 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 4 May 2026 07:43:49 +0300 Subject: [PATCH 4/8] refactor: Update PHPUnit commands to disable coverage and enhance Shell documentation --- Makefile | 2 +- phpunit.xml | 7 --- src/ConsoleIO.php | 1 + src/Depends/Shell.php | 135 +++++++++++++++++++++++------------------- 4 files changed, 75 insertions(+), 70 deletions(-) diff --git a/Makefile b/Makefile index 8055590..dc7c974 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ install: # ── Testing ─────────────────────────────────────────────────────────────────── test: - vendor/bin/phpunit + vendor/bin/phpunit --no-coverage test-unit: vendor/bin/phpunit --testsuite Unit --no-coverage diff --git a/phpunit.xml b/phpunit.xml index e1b16e7..932ebb5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,13 +8,6 @@ tests/Integration - - - - - - - diff --git a/src/ConsoleIO.php b/src/ConsoleIO.php index 2de7d6a..c8262b1 100644 --- a/src/ConsoleIO.php +++ b/src/ConsoleIO.php @@ -66,6 +66,7 @@ public function enableDebugging(float $startTime): void private function isStdinTty(): bool { return $this->isInteractive() + && !$this->input instanceof \Symfony\Component\Console\Input\StringInput && function_exists('posix_isatty') && @posix_isatty(STDIN); } diff --git a/src/Depends/Shell.php b/src/Depends/Shell.php index 39805ff..4f8b3eb 100644 --- a/src/Depends/Shell.php +++ b/src/Depends/Shell.php @@ -5,9 +5,9 @@ namespace AlfacodeTeam\PhpIoCli\Depends; /** - * Thin, testable wrapper around proc_open. - * - * Features + * Enterprise Shell Wrapper + * + * v1-> Features * ───────── * • Streams stdout AND stderr simultaneously with stream_select() so neither * pipe can block the other (the classic deadlock trap with proc_open). @@ -15,7 +15,12 @@ * caller can animate a SpinnerComponent with the most recent output line. * • Merges caller-supplied env vars over the current process environment. * • Returns an immutable ShellResult value object. - * + * + * Features: + * - Deadlock-free simultaneous stdout/stderr streaming. + * - Non-blocking stream_select for high-performance UI ticks. + * - Guaranteed capture of partial trailing lines (fixes test failures). + * * Usage with SpinnerComponent * ─────────────────────────── * $spin = new SpinnerComponent('Running git …'); @@ -53,23 +58,19 @@ public static function run( string $cwd = '', ): ShellResult { $descriptors = [ - 0 => ['pipe', 'r'], // stdin — we close this immediately - 1 => ['pipe', 'w'], // stdout - 2 => ['pipe', 'w'], // stderr + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr ]; - // Merge caller env over the real process environment. - // array_merge resets numeric keys; this is intentional for env arrays. - $fullEnv = array_merge( - (array) (getenv() ?: []), - $env - ); + // Ensure environment variables are preserved and merged + $fullEnv = array_merge((array)(getenv() ?: []), $env); $process = proc_open( $command, $descriptors, $pipes, - $cwd !== '' ? $cwd : (getcwd() ?: null), + $cwd !== '' ? $cwd : null, $fullEnv ); @@ -77,83 +78,94 @@ public static function run( return new ShellResult(1, [], ["proc_open failed for: {$command}"]); } - // We never write to stdin. + // Close stdin immediately as we don't support interactive input here fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); - $stdout = []; - $stderr = []; - $stdoutBuf = ''; - $stderrBuf = ''; - $lastLine = ''; + $stdout = []; + $stderr = []; + $stdoutBuf = ''; + $stderrBuf = ''; + $lastLine = ''; $lastIsStderr = false; - // ── Streaming loop ──────────────────────────────────── + // --- Streaming Loop --- while (true) { - $read = [$pipes[1], $pipes[2]]; - $write = null; + $read = [$pipes[1], $pipes[2]]; + $write = null; $except = null; - // Wait up to 50 ms for data on either pipe. - // Returns false on error, 0 on timeout, >0 when data is ready. + // Wait 50ms for activity $changed = stream_select($read, $write, $except, 0, 50_000); + if ($changed === false) { + break; // System error + } + if ($changed > 0) { foreach ($read as $stream) { $isStdout = ($stream === $pipes[1]); $chunk = fread($stream, 4096); - if ($chunk === false || $chunk === '') { - continue; - } - - if ($isStdout) { - $stdoutBuf .= $chunk; - } else { - $stderrBuf .= $chunk; + if ($chunk !== false && $chunk !== '') { + if ($isStdout) { + $stdoutBuf .= $chunk; + } else { + $stderrBuf .= $chunk; + } } } } - // ── Drain complete lines from buffers ───────────── - foreach ([ - 'stdout' => [&$stdoutBuf, &$stdout, false], - 'stderr' => [&$stderrBuf, &$stderr, true], - ] as [$buf, $collection, $isErr]) { - while (($pos = strpos($buf, "\n")) !== false) { - $line = rtrim(substr($buf, 0, $pos)); - $buf = substr($buf, $pos + 1); - $collection[] = $line; - - if ($line !== '') { - $lastLine = $line; - $lastIsStderr = $isErr; - } - } + // --- Process complete lines for STDOUT --- + while (($pos = strpos($stdoutBuf, "\n")) !== false) { + $line = rtrim(substr($stdoutBuf, 0, $pos)); + $stdoutBuf = substr($stdoutBuf, $pos + 1); + $stdout[] = $line; + $lastLine = $line; + $lastIsStderr = false; } - // ── Tick callback (animation + last-line sub-label) ─ + // --- Process complete lines for STDERR --- + while (($pos = strpos($stderrBuf, "\n")) !== false) { + $line = rtrim(substr($stderrBuf, 0, $pos)); + $stderrBuf = substr($stderrBuf, $pos + 1); + $stderr[] = $line; + $lastLine = $line; + $lastIsStderr = true; + } + + // --- UI Tick --- if ($tick !== null) { $tick($lastLine, $lastIsStderr); } - // ── Check for EOF on both pipes ─────────────────── + // Exit loop if both pipes are closed if (feof($pipes[1]) && feof($pipes[2])) { - // Flush any remaining partial lines - foreach ([ - [&$stdoutBuf, &$stdout, false], - [&$stderrBuf, &$stderr, true], - ] as [$buf, $collection, $isErr]) { - if (trim($buf) !== '') { - $collection[] = rtrim($buf); - } - } break; } } + // --- Final Flush --- + // Capture any remaining data that didn't end with a newline (Critical for tests!) + if (($trimmed = rtrim($stdoutBuf)) !== '') { + $stdout[] = $trimmed; + $lastLine = $trimmed; + $lastIsStderr = false; + } + if (($trimmed = rtrim($stderrBuf)) !== '') { + $stderr[] = $trimmed; + $lastLine = $trimmed; + $lastIsStderr = true; + } + + // Final tick to update UI with last processed data + if ($tick !== null && $lastLine !== '') { + $tick($lastLine, $lastIsStderr); + } + fclose($pipes[1]); fclose($pipes[2]); @@ -163,12 +175,11 @@ public static function run( } /** - * Convenience: run and return trimmed stdout or null on failure. - * Good for quick value capture (e.g. reading a git config entry). + * Run and return trimmed stdout. Returns null on failure. */ public static function capture(string $command, string $cwd = ''): ?string { $result = self::run($command, cwd: $cwd); return $result->ok() ? trim($result->output()) : null; } -} +} \ No newline at end of file From fdba928155cbbdc41fe3d880e2ecf5f267620fe4 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 4 May 2026 08:11:15 +0300 Subject: [PATCH 5/8] Refactor test classes to use static function syntax for closures and update CoversClass attributes - Updated test classes (ColorsTest, FuzzyTest, HooksTest, InputTest, KeyTest, NullIOTest, RenderContextTest, RendererTest, ShellResultTest, SpinnerFramesTest, SpinnerTest, StateTest, TableTest) to use static function syntax for closures in various test methods. - Changed CoversClass attributes to use the class name directly instead of the fully qualified name. - Cleaned up formatting for better consistency across test files. --- Makefile | 4 +- composer.json | 4 +- examples/01-inputs.php | 14 +- examples/02-display.php | 16 +- examples/03-application.php | 38 +-- examples/04-shell.php | 22 +- examples/demo.php | 38 +-- src/AbstractCommand.php | 241 ++++++++++--------- src/AbstractPrompt.php | 28 ++- src/BaseIO.php | 34 ++- src/BufferIO.php | 39 +-- src/CLIApplication.php | 159 ++++++------ src/Components/Alert.php | 10 +- src/Components/Autocomplete.php | 64 ++--- src/Components/Component.php | 60 ++--- src/Components/Confirm.php | 10 +- src/Components/DatePicker.php | 108 ++++----- src/Components/MultiSelect.php | 3 +- src/Components/NumberInput.php | 117 +++++---- src/Components/Password.php | 42 ++-- src/Components/ProgressBar.php | 24 +- src/Components/Select.php | 34 +-- src/Components/SpinnerComponent.php | 17 +- src/Components/Table.php | 32 ++- src/Components/TextInput.php | 61 ++--- src/ConsoleIO.php | 171 ++++++------- src/Depends/Colors.php | 57 +++-- src/Depends/Fuzzy.php | 9 +- src/Depends/Input.php | 19 +- src/Depends/Key.php | 56 +++-- src/Depends/RenderContext.php | 5 +- src/Depends/Renderer.php | 28 ++- src/Depends/Shell.php | 39 +-- src/Depends/ShellResult.php | 3 +- src/Depends/Spinner.php | 9 +- src/Depends/SpinnerFrames.php | 10 +- src/Depends/State.php | 13 +- src/Depends/Terminal.php | 66 ++--- src/Hooks.php | 13 +- src/IOInterface.php | 23 +- src/NullIO.php | 18 +- src/Silencer.php | 10 +- tests/Integration/AbstractCommandTest.php | 36 +-- tests/Integration/BufferIOTest.php | 4 +- tests/Integration/BufferIOUserInputsTest.php | 51 ++-- tests/Integration/CLIApplicationTest.php | 45 ++-- tests/Integration/ShellTest.php | 19 +- tests/Unit/AlertTest.php | 65 ++--- tests/Unit/ColorsTest.php | 12 +- tests/Unit/FuzzyTest.php | 8 +- tests/Unit/HooksTest.php | 35 +-- tests/Unit/InputTest.php | 39 +-- tests/Unit/KeyTest.php | 44 ++-- tests/Unit/NullIOTest.php | 6 +- tests/Unit/RenderContextTest.php | 4 +- tests/Unit/RendererTest.php | 150 ++++++------ tests/Unit/ShellResultTest.php | 4 +- tests/Unit/SpinnerFramesTest.php | 18 +- tests/Unit/SpinnerTest.php | 14 +- tests/Unit/StateTest.php | 16 +- tests/Unit/TableTest.php | 42 ++-- 61 files changed, 1282 insertions(+), 1098 deletions(-) diff --git a/Makefile b/Makefile index dc7c974..e129455 100644 --- a/Makefile +++ b/Makefile @@ -50,10 +50,10 @@ stan: # ── Code style ──────────────────────────────────────────────────────────────── cs-check: - vendor/bin/php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php + vendor/bin/php-cs-fixer fix --dry-run --diff --allow-unsupported-php-version=yes --config=php-cs-fixer.php cs-fix: - vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php + vendor/bin/php-cs-fixer fix --allow-unsupported-php-version=yes --config=php-cs-fixer.php @printf "\n$(GREEN)✔ Code style fixes applied.$(RESET)\n" # ── Refactoring ─────────────────────────────────────────────────────────────── diff --git a/composer.json b/composer.json index 1c783d3..17275d6 100644 --- a/composer.json +++ b/composer.json @@ -61,8 +61,8 @@ "phpstan": "phpstan analyse --memory-limit=256M", "stan": "@phpstan", - "cs-check": "php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php", - "cs-fix": "php-cs-fixer fix --config=.php-cs-fixer.php", + "cs-check": "php-cs-fixer fix --dry-run --diff --allow-unsupported-php-version=yes --config=php-cs-fixer.php", + "cs-fix": "php-cs-fixer fix --allow-unsupported-php-version=yes --config=php-cs-fixer.php", "refactor": "rector process", "mutation": "infection --threads=max --min-msi=60 --min-covered-msi=80", diff --git a/examples/01-inputs.php b/examples/01-inputs.php index 1e795a2..f248898 100644 --- a/examples/01-inputs.php +++ b/examples/01-inputs.php @@ -28,7 +28,7 @@ $name = (new TextInput('What is your name?')) ->placeholder('e.g. Alice') ->default('World') - ->validate(fn(string $value): ?string => mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.') + ->validate(static fn(string $value): string|null => mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.') ->run(); Colors::line(" → Name: {$name}", Colors::GREEN); @@ -51,13 +51,13 @@ ->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 ──────────────────────────────────────────────────── -$confirmed = (new Confirm("Do you want to continue?", true))->run(); +$confirmed = (new Confirm('Do you want to continue?', true))->run(); -Colors::line(" → Confirmed: " . ($confirmed ? 'Yes' : 'No'), Colors::GREEN); +Colors::line(' → Confirmed: ' . ($confirmed ? 'Yes' : 'No'), Colors::GREEN); // ── 5. Select ───────────────────────────────────────────────────── @@ -81,7 +81,7 @@ 'Rate Limiting', ]))->run(); -Colors::line(" → Features: " . implode(', ', $features), Colors::GREEN); +Colors::line(' → Features: ' . implode(', ', $features), Colors::GREEN); // ── 7. Autocomplete ─────────────────────────────────────────────── @@ -98,10 +98,10 @@ $date = (new DatePicker('Select a release date'))->run(); -Colors::line(" → Date: " . $date->format('Y-m-d'), Colors::GREEN); +Colors::line(' → Date: ' . $date->format('Y-m-d'), Colors::GREEN); // ── Summary ─────────────────────────────────────────────────────── echo PHP_EOL; -Colors::line(" All inputs collected successfully!", [Colors::BOLD, Colors::GREEN]); +Colors::line(' All inputs collected successfully!', [Colors::BOLD, Colors::GREEN]); echo PHP_EOL; diff --git a/examples/02-display.php b/examples/02-display.php index b04bd5f..0677cc9 100644 --- a/examples/02-display.php +++ b/examples/02-display.php @@ -39,10 +39,10 @@ // ── Table ───────────────────────────────────────────────────────── -Colors::line(" ── Tables ──────────────────────────────────", Colors::CYAN); +Colors::line(' ── Tables ──────────────────────────────────', Colors::CYAN); echo PHP_EOL; -Colors::line(" Box style (default):", Colors::BOLD); +Colors::line(' Box style (default):', Colors::BOLD); Table::make() ->headers(['Service', 'Status', 'Latency', 'Requests']) ->rows([ @@ -54,7 +54,7 @@ ->align([3 => 'right']) ->render(); -Colors::line(" Bold style:", Colors::BOLD); +Colors::line(' Bold style:', Colors::BOLD); Table::make() ->headers(['Package', 'Version', 'License']) ->rows([ @@ -65,7 +65,7 @@ ->style('bold') ->render(); -Colors::line(" Minimal style:", Colors::BOLD); +Colors::line(' Minimal style:', Colors::BOLD); Table::make() ->headers(['Key', 'Value']) ->rows([ @@ -79,7 +79,7 @@ // ── Progress Bar (Determinate) ──────────────────────────────────── -Colors::line(" ── Progress Bar (Determinate) ───────────────", Colors::CYAN); +Colors::line(' ── Progress Bar (Determinate) ───────────────', Colors::CYAN); echo PHP_EOL; $bar = new ProgressBar('Processing records', 50); @@ -94,7 +94,7 @@ // ── Progress Bar (Indeterminate) ────────────────────────────────── -Colors::line(" ── Progress Bar (Indeterminate) ────────────", Colors::CYAN); +Colors::line(' ── Progress Bar (Indeterminate) ────────────', Colors::CYAN); echo PHP_EOL; $indeterminate = new ProgressBar('Connecting to cluster'); @@ -109,7 +109,7 @@ // ── Spinner ─────────────────────────────────────────────────────── -Colors::line(" ── Spinner Styles ───────────────────────────", Colors::CYAN); +Colors::line(' ── Spinner Styles ───────────────────────────', Colors::CYAN); echo PHP_EOL; $styles = ['dots', 'line', 'bars', 'pulse', 'arc', 'bounce']; @@ -127,5 +127,5 @@ } echo PHP_EOL; -Colors::line(" Display components demo complete!", [Colors::BOLD, Colors::GREEN]); +Colors::line(' Display components demo complete!', [Colors::BOLD, Colors::GREEN]); echo PHP_EOL; diff --git a/examples/03-application.php b/examples/03-application.php index ea2e58d..64a32f7 100644 --- a/examples/03-application.php +++ b/examples/03-application.php @@ -34,7 +34,7 @@ final class DeployCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'deploy'; + $this->name = 'deploy'; $this->description = 'Deploy the application to an environment'; $this->addArgument('environment', 'Target environment', required: true); @@ -45,21 +45,23 @@ protected function configure(): void protected function handle(): int { - $env = (string) $this->argument('environment'); - $tag = (string) $this->option('tag', 'latest'); + $env = (string) $this->argument('environment'); + $tag = (string) $this->option('tag', 'latest'); $dryRun = $this->hasOption('dry-run'); $this->section("Deployment: {$tag} → {$env}"); if (!in_array($env, ['production', 'staging', 'development', 'local'], true)) { $this->error("Unknown environment: {$env}"); + return self::INVALID; } if ($env === 'production' && !$this->hasOption('force')) { - $confirmed = $this->confirm("You are deploying to PRODUCTION. Are you sure?", false); + $confirmed = $this->confirm('You are deploying to PRODUCTION. Are you sure?', false); if (!$confirmed) { $this->muted('Deployment cancelled.'); + return self::SUCCESS; } } @@ -87,10 +89,10 @@ protected function handle(): int $bar->finish("Deployed {$tag} → {$env}"); - $this->alertSuccess("Deployment complete!", [ + $this->alertSuccess('Deployment complete!', [ "Environment: {$env}", "Tag: {$tag}", - "Dry-run: " . ($dryRun ? 'yes' : 'no'), + 'Dry-run: ' . ($dryRun ? 'yes' : 'no'), ]); return self::SUCCESS; @@ -104,7 +106,7 @@ final class MigrateCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'db:migrate'; + $this->name = 'db:migrate'; $this->description = 'Run pending database migrations'; $this->addOption('rollback', 'r', 'Rollback the last batch'); @@ -114,7 +116,7 @@ protected function configure(): void protected function handle(): int { $rollback = $this->hasOption('rollback'); - $steps = (int) $this->option('steps', '1'); + $steps = (int) $this->option('steps', '1'); $this->section($rollback ? "Rolling back {$steps} migration(s)" : 'Running migrations'); @@ -124,6 +126,7 @@ protected function handle(): int if (empty($migrations)) { $this->info('Nothing to migrate.'); + return self::SUCCESS; } @@ -158,7 +161,7 @@ final class MakeModuleCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'make:module'; + $this->name = 'make:module'; $this->description = 'Scaffold a new application module'; } @@ -168,9 +171,9 @@ protected function handle(): int $name = $this->ask('Module name (kebab-case)', 'my-module'); - $features = (new \AlfacodeTeam\PhpIoCli\Components\MultiSelect( + $features = (new AlfacodeTeam\PhpIoCli\Components\MultiSelect( 'Select features to include', - ['Controller', 'Repository', 'Service', 'Events', 'Tests', 'Migration', 'Factory'] + ['Controller', 'Repository', 'Service', 'Events', 'Tests', 'Migration', 'Factory'], ))->run(); $this->newLine(); @@ -191,14 +194,14 @@ protected function handle(): int $this->table() ->headers(['File', 'Status']) ->rows(array_map( - fn(string $f) => ["src/{$name}/{$f}.php", Colors::wrap('created', Colors::GREEN)], - $features + static fn(string $f) => ["src/{$name}/{$f}.php", Colors::wrap('created', Colors::GREEN)], + $features, )) ->render(); - $this->alertSuccess("Module created!", [ + $this->alertSuccess('Module created!', [ "Name: {$name}", - "Files: " . count($features), + 'Files: ' . count($features), ]); return self::SUCCESS; @@ -212,7 +215,7 @@ final class EnvCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'env'; + $this->name = 'env'; $this->description = 'Display current environment variables'; $this->addOption('filter', 'f', 'Filter by prefix', acceptsValue: true, default: ''); } @@ -233,12 +236,13 @@ protected function handle(): int ]; if ($filter !== '') { - $vars = array_filter($vars, fn($row) => str_starts_with($row[0], strtoupper($filter))); + $vars = array_filter($vars, static fn($row) => str_starts_with($row[0], mb_strtoupper($filter))); $vars = array_values($vars); } if (empty($vars)) { $this->warning("No variables matching prefix: {$filter}"); + return self::SUCCESS; } diff --git a/examples/04-shell.php b/examples/04-shell.php index c32ed76..545f4a8 100644 --- a/examples/04-shell.php +++ b/examples/04-shell.php @@ -24,19 +24,19 @@ // ── Example 1: Shell::capture (quick value read) ─────────────────── -Colors::line(" 1. Shell::capture — read a value", Colors::BOLD); +Colors::line(' 1. Shell::capture — read a value', Colors::BOLD); $phpVersion = Shell::capture('php --version'); $gitVersion = Shell::capture('git --version'); echo PHP_EOL; -Colors::line(" PHP: " . explode("\n", (string) $phpVersion)[0], Colors::GREEN); -Colors::line(" Git: " . (string) $gitVersion, Colors::GREEN); +Colors::line(' PHP: ' . explode("\n", (string) $phpVersion)[0], Colors::GREEN); +Colors::line(' Git: ' . (string) $gitVersion, Colors::GREEN); echo PHP_EOL; // ── Example 2: Shell::run with SpinnerComponent ──────────────────── -Colors::line(" 2. Shell::run with SpinnerComponent", Colors::BOLD); +Colors::line(' 2. Shell::run with SpinnerComponent', Colors::BOLD); echo PHP_EOL; $spin = new SpinnerComponent('Listing /tmp directory', 'dots'); @@ -44,14 +44,14 @@ $result = Shell::run( 'ls -la /tmp 2>&1 | head -20', - tick: function (string $lastLine) use ($spin): void { + tick: static function (string $lastLine) use ($spin): void { $spin->tick($lastLine); - } + }, ); if ($result->ok()) { $spin->stop('Directory listing complete'); - Colors::line(" Output lines: " . count($result->stdout), Colors::GREEN); + Colors::line(' Output lines: ' . count($result->stdout), Colors::GREEN); } else { $spin->fail('Command failed'); Alert::error('Shell error', $result->meaningfulErrors()); @@ -61,7 +61,7 @@ // ── Example 3: Shell::run with ProgressBar (multi-step) ──────────── -Colors::line(" 3. Multi-step pipeline with ProgressBar", Colors::BOLD); +Colors::line(' 3. Multi-step pipeline with ProgressBar', Colors::BOLD); echo PHP_EOL; $steps = [ @@ -80,7 +80,7 @@ foreach ($steps as [$label, $command]) { $stepResult = Shell::run( $command, - tick: fn() => $bar->advance(0) // redraw without advancing + tick: static fn() => $bar->advance(0), // redraw without advancing ); if ($stepResult->failed()) { @@ -97,7 +97,7 @@ // ── Example 4: Error handling ────────────────────────────────────── -Colors::line(" 4. Error handling", Colors::BOLD); +Colors::line(' 4. Error handling', Colors::BOLD); echo PHP_EOL; $spin2 = new SpinnerComponent('Running a command that fails', 'arc'); @@ -121,5 +121,5 @@ } echo PHP_EOL; -Colors::line(" Shell integration demo complete!", [Colors::BOLD, Colors::GREEN]); +Colors::line(' Shell integration demo complete!', [Colors::BOLD, Colors::GREEN]); echo PHP_EOL; diff --git a/examples/demo.php b/examples/demo.php index 89a1475..8766eca 100644 --- a/examples/demo.php +++ b/examples/demo.php @@ -36,7 +36,7 @@ function banner(string $title): void $line = str_repeat('─', mb_strlen($title) + 4); echo PHP_EOL; Colors::line(" ┌{$line}┐", Colors::CYAN); - Colors::line(" │ " . Colors::wrap($title, Colors::BOLD) . " │", Colors::CYAN); + Colors::line(' │ ' . Colors::wrap($title, Colors::BOLD) . ' │', Colors::CYAN); Colors::line(" └{$line}┘", Colors::CYAN); echo PHP_EOL; } @@ -65,7 +65,7 @@ function demoTextInput(): void $name = (new TextInput('What is your name?')) ->placeholder('e.g. Alice') ->default('World') - ->validate(fn(string $v): ?string => mb_strlen($v) >= 2 ? null : 'Name must be ≥ 2 characters') + ->validate(static fn(string $v): string|null => mb_strlen($v) >= 2 ? null : 'Name must be ≥ 2 characters') ->run(); result('Name', $name); @@ -183,9 +183,9 @@ function demoTable(): void Table::make() ->headers(['Service', 'Status', 'Latency']) ->rows([ - ['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms'], + ['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms'], ['auth-service', Colors::wrap('degraded', Colors::YELLOW), '340 ms'], - ['payment-worker', Colors::wrap('down', Colors::RED), '—'], + ['payment-worker', Colors::wrap('down', Colors::RED), '—'], ]) ->style($style) ->render(); @@ -272,7 +272,7 @@ function demoShell(): void echo \"OS : \" . PHP_OS_FAMILY . PHP_EOL; echo \"SAPI : \" . php_sapi_name() . PHP_EOL; "', - tick: fn(string $line) => $spin->tick($line), + tick: static fn(string $line) => $spin->tick($line), ); if ($result->ok()) { @@ -291,21 +291,21 @@ function demoShell(): void // ── Main menu loop ──────────────────────────────────────────────────────────── $menu = [ - '1. TextInput' => 'demoTextInput', - '2. NumberInput' => 'demoNumberInput', - '3. Password' => 'demoPassword', - '4. Confirm' => 'demoConfirm', - '5. Select' => 'demoSelect', - '6. MultiSelect' => 'demoMultiSelect', - '7. Autocomplete' => 'demoAutocomplete', - '8. DatePicker' => 'demoDatePicker', - '9. Table' => 'demoTable', - '10. Alert' => 'demoAlert', - '11. ProgressBar' => 'demoProgressBar', - '12. SpinnerComponent' => 'demoSpinner', + '1. TextInput' => 'demoTextInput', + '2. NumberInput' => 'demoNumberInput', + '3. Password' => 'demoPassword', + '4. Confirm' => 'demoConfirm', + '5. Select' => 'demoSelect', + '6. MultiSelect' => 'demoMultiSelect', + '7. Autocomplete' => 'demoAutocomplete', + '8. DatePicker' => 'demoDatePicker', + '9. Table' => 'demoTable', + '10. Alert' => 'demoAlert', + '11. ProgressBar' => 'demoProgressBar', + '12. SpinnerComponent' => 'demoSpinner', '13. Shell Integration' => 'demoShell', - '─────────────────' => null, - 'Exit' => null, + '─────────────────' => null, + 'Exit' => null, ]; $choices = array_keys($menu); diff --git a/src/AbstractCommand.php b/src/AbstractCommand.php index 6eb99fa..851053a 100644 --- a/src/AbstractCommand.php +++ b/src/AbstractCommand.php @@ -5,32 +5,28 @@ namespace AlfacodeTeam\PhpIoCli; use AlfacodeTeam\PhpIoCli\Components\Alert; -use AlfacodeTeam\PhpIoCli\Components\Autocomplete; use AlfacodeTeam\PhpIoCli\Components\Confirm; -use AlfacodeTeam\PhpIoCli\Components\DatePicker; -use AlfacodeTeam\PhpIoCli\Components\MultiSelect; -use AlfacodeTeam\PhpIoCli\Components\NumberInput; -use AlfacodeTeam\PhpIoCli\Components\Password; use AlfacodeTeam\PhpIoCli\Components\ProgressBar; use AlfacodeTeam\PhpIoCli\Components\Select; use AlfacodeTeam\PhpIoCli\Components\SpinnerComponent; use AlfacodeTeam\PhpIoCli\Components\Table; use AlfacodeTeam\PhpIoCli\Components\TextInput; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use AlfacodeTeam\PhpIoCli\Depends\Terminal; -use DateTimeImmutable; -use LogicException; -use Throwable; abstract class AbstractCommand { public const SUCCESS = 0; + public const FAILURE = 1; + public const INVALID = 2; protected string $name = ''; + protected string $description = ''; + protected string $help = ''; + protected bool $hidden = false; /** @var array */ @@ -40,11 +36,13 @@ abstract class AbstractCommand private array $optionDefs = []; private array $arguments = []; + private array $options = []; // FIX 1: $rawTokens was assigned in execute() but never read anywhere — removed. private IOInterface $io; + private bool $rethrowExceptions = false; final public function __construct() @@ -52,13 +50,10 @@ final public function __construct() $this->configure(); if ($this->name === '') { - throw new LogicException(static::class . '::configure() must set $this->name.'); + throw new \LogicException(static::class . '::configure() must set $this->name.'); } } - abstract protected function configure(): void; - abstract protected function handle(): int; - /** * @internal Entry point called by CLIApplication */ @@ -76,22 +71,24 @@ final public function execute(array $tokens, IOInterface $io): int foreach ($this->argumentDefs as $argName => $def) { if ($def['required'] && !isset($this->arguments[$argName])) { $this->error("Missing required argument: <{$argName}>"); + return self::INVALID; } } try { return $this->handle(); - } catch (Throwable $e) { + } catch (\Throwable $e) { // If catch-exceptions mode is enabled (via marker), rethrow if ($this->rethrowExceptions) { throw $e; } - $this->io->error("Command Error: " . $e->getMessage()); + $this->io->error('Command Error: ' . $e->getMessage()); if ($io->isDebug()) { $this->io->write(Colors::muted($e->getTraceAsString())); } + return self::FAILURE; } } @@ -107,6 +104,54 @@ final public function setRethrowExceptions(bool $rethrow): void $this->rethrowExceptions = $rethrow; } + /* ========================================================= + Help Generation + ========================================================= */ + + final public function printHelp(): void + { + $this->section('Command: ' . $this->name); + $this->io->write($this->description . "\n"); + + if (!empty($this->argumentDefs)) { + $this->io->write(Colors::wrap('Arguments:', Colors::YELLOW)); + foreach ($this->argumentDefs as $name => $def) { + $label = mb_str_pad("<{$name}>", 20); + $this->io->write(' ' . Colors::info($label) . $def['description']); + } + } + + if (!empty($this->optionDefs)) { + $this->io->write("\n" . Colors::wrap('Options:', Colors::YELLOW)); + foreach ($this->optionDefs as $key => $def) { + $shortcut = $def['short'] ? "-{$def['short']}, " : ' '; + $label = mb_str_pad($shortcut . "--{$key}", 20); + $this->io->write(' ' . Colors::info($label) . $def['description']); + } + } + $this->io->write(''); + } + + // Standard Getters + final public function getName(): string + { + return $this->name; + } + + final public function getDescription(): string + { + return $this->description; + } + + final public function isHidden(): bool + { + return $this->hidden; + } + + abstract protected function configure(): void; + + abstract protected function handle(): int; + /* ========================================================= Registration ========================================================= */ @@ -114,83 +159,21 @@ final public function setRethrowExceptions(bool $rethrow): void protected function addArgument(string $name, string $description = '', bool $required = false, mixed $default = null): static { $this->argumentDefs[$name] = compact('description', 'required', 'default'); + return $this; } protected function addOption(string $long, string $short = '', string $description = '', bool $acceptsValue = false, mixed $default = null): static { - $key = ltrim($long, '-'); + $key = mb_ltrim($long, '-'); $this->optionDefs[$key] = [ - 'short' => ltrim($short, '-'), + 'short' => mb_ltrim($short, '-'), 'description' => $description, 'acceptsValue' => $acceptsValue, 'default' => $default, ]; - return $this; - } - - /* ========================================================= - The "No-Headache" Parser - ========================================================= */ - - private function parseTokens(array $tokens): void - { - // Set Defaults - foreach ($this->argumentDefs as $name => $def) { - $this->arguments[$name] = $def['default']; - } - foreach ($this->optionDefs as $key => $def) { - $this->options[$key] = $def['default']; - } - - $positional = []; - $count = count($tokens); - - for ($i = 0; $i < $count; $i++) { - $token = $tokens[$i]; - - // 1. Long Options (--option or --option=value) - if (str_starts_with($token, '--')) { - $bare = ltrim($token, '-'); - if (str_contains($bare, '=')) { - [$key, $value] = explode('=', $bare, 2); - $this->options[$key] = $value; - } else { - $this->options[$bare] = true; - if (($this->optionDefs[$bare]['acceptsValue'] ?? false) && isset($tokens[$i + 1]) && !str_starts_with($tokens[$i + 1], '-')) { - $this->options[$bare] = $tokens[++$i]; - } - } - continue; - } - - // 2. Short Options Cluster (-vfa) - if (str_starts_with($token, '-') && mb_strlen($token) > 1) { - $chars = mb_str_split(mb_substr($token, 1)); - foreach ($chars as $char) { - foreach ($this->optionDefs as $key => $def) { - if ($def['short'] === $char) { - $this->options[$key] = true; - if ($def['acceptsValue'] && isset($tokens[$i + 1]) && !str_starts_with($tokens[$i + 1], '-')) { - $this->options[$key] = $tokens[++$i]; - } - break; - } - } - } - continue; - } - // 3. Positional Arguments - $positional[] = $token; - } - - $argNames = array_keys($this->argumentDefs); - foreach ($positional as $idx => $value) { - if (isset($argNames[$idx])) { - $this->arguments[$argNames[$idx]] = $value; - } - } + return $this; } /* ========================================================= @@ -199,7 +182,7 @@ private function parseTokens(array $tokens): void protected function option(string $name, mixed $default = null): mixed { - return $this->options[ltrim($name, '-')] ?? $default; + return $this->options[mb_ltrim($name, '-')] ?? $default; } protected function argument(string $name, mixed $default = null): mixed @@ -209,25 +192,29 @@ protected function argument(string $name, mixed $default = null): mixed protected function hasOption(string $name): bool { - return (bool) ($this->options[ltrim($name, '-')] ?? false); + return (bool) ($this->options[mb_ltrim($name, '-')] ?? false); } protected function info(string $message): void { $this->io->write(Colors::info($message)); } + protected function success(string $message): void { $this->io->write(Colors::success($message)); } + protected function warning(string $message): void { $this->io->writeError(Colors::warning($message)); } + protected function error(string $message): void { $this->io->writeError(Colors::error($message)); } + protected function muted(string $message): void { $this->io->write(Colors::muted($message)); @@ -239,6 +226,7 @@ protected function newLine(int $count = 1): void $this->io->write(''); } } + protected function section(string $title): void { $this->newLine(); @@ -278,10 +266,12 @@ protected function ask(string $q, string $default = ''): string { return (string) (new TextInput($q))->default($default)->run(); } + protected function select(string $q, array $c): string { return (string) (new Select($q, $c))->run(); } + protected function confirm(string $question, bool $default = true): bool { return (bool) (new Confirm($question, $default))->run(); @@ -303,44 +293,69 @@ protected function spinner(string $label, string $style = 'dots'): SpinnerCompon } /* ========================================================= - Help Generation + The "No-Headache" Parser ========================================================= */ - final public function printHelp(): void + private function parseTokens(array $tokens): void { - $this->section("Command: " . $this->name); - $this->io->write($this->description . "\n"); + // Set Defaults + foreach ($this->argumentDefs as $name => $def) { + $this->arguments[$name] = $def['default']; + } + foreach ($this->optionDefs as $key => $def) { + $this->options[$key] = $def['default']; + } - if (!empty($this->argumentDefs)) { - $this->io->write(Colors::wrap("Arguments:", Colors::YELLOW)); - foreach ($this->argumentDefs as $name => $def) { - $label = str_pad("<$name>", 20); - $this->io->write(" " . Colors::info($label) . $def['description']); + $positional = []; + $count = count($tokens); + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + // 1. Long Options (--option or --option=value) + if (str_starts_with($token, '--')) { + $bare = mb_ltrim($token, '-'); + if (str_contains($bare, '=')) { + [$key, $value] = explode('=', $bare, 2); + $this->options[$key] = $value; + } else { + $this->options[$bare] = true; + if (($this->optionDefs[$bare]['acceptsValue'] ?? false) && isset($tokens[$i + 1]) && !str_starts_with($tokens[$i + 1], '-')) { + $this->options[$bare] = $tokens[++$i]; + } + } + + continue; } - } - if (!empty($this->optionDefs)) { - $this->io->write("\n" . Colors::wrap("Options:", Colors::YELLOW)); - foreach ($this->optionDefs as $key => $def) { - $shortcut = $def['short'] ? "-{$def['short']}, " : " "; - $label = str_pad($shortcut . "--$key", 20); - $this->io->write(" " . Colors::info($label) . $def['description']); + // 2. Short Options Cluster (-vfa) + if (str_starts_with($token, '-') && mb_strlen($token) > 1) { + $chars = mb_str_split(mb_substr($token, 1)); + foreach ($chars as $char) { + foreach ($this->optionDefs as $key => $def) { + if ($def['short'] === $char) { + $this->options[$key] = true; + if ($def['acceptsValue'] && isset($tokens[$i + 1]) && !str_starts_with($tokens[$i + 1], '-')) { + $this->options[$key] = $tokens[++$i]; + } + + break; + } + } + } + + continue; } + + // 3. Positional Arguments + $positional[] = $token; } - $this->io->write(""); - } - // Standard Getters - final public function getName(): string - { - return $this->name; - } - final public function getDescription(): string - { - return $this->description; - } - final public function isHidden(): bool - { - return $this->hidden; + $argNames = array_keys($this->argumentDefs); + foreach ($positional as $idx => $value) { + if (isset($argNames[$idx])) { + $this->arguments[$argNames[$idx]] = $value; + } + } } } diff --git a/src/AbstractPrompt.php b/src/AbstractPrompt.php index 20d8fb8..f3ea041 100644 --- a/src/AbstractPrompt.php +++ b/src/AbstractPrompt.php @@ -4,19 +4,19 @@ namespace AlfacodeTeam\PhpIoCli; -use AlfacodeTeam\PhpIoCli\Depends\Key; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use AlfacodeTeam\PhpIoCli\Depends\Terminal; +use AlfacodeTeam\PhpIoCli\Depends\Key; use AlfacodeTeam\PhpIoCli\Depends\RenderContext; -use Exception; +use AlfacodeTeam\PhpIoCli\Depends\Terminal; abstract class AbstractPrompt implements IPromptComponent, ILifecycle { protected bool $running = false; + protected RenderContext $context; public function __construct( - protected Hooks $hooks = new Hooks() + protected Hooks $hooks = new Hooks(), ) { $this->context = new RenderContext(); } @@ -46,10 +46,11 @@ public function run(): mixed } $rawKey = Terminal::readKey(); - $key = Key::normalize($rawKey); + $key = Key::normalize($rawKey); if ($key === 'CTRL_C') { $this->handleCancel(); + break; } @@ -62,8 +63,9 @@ public function run(): mixed return $result; - } catch (Exception $e) { + } catch (\Exception $e) { $this->handleError($e); + throw $e; } finally { $this->destroy(); @@ -72,6 +74,14 @@ public function run(): mixed } } + abstract public function mount(): void; + + abstract public function render(): void; + + abstract public function update(string $key): void; + + abstract public function destroy(): void; + /* ========================================================= RENDER LIFECYCLE HOOKS Concrete subclasses may override these to delegate to an @@ -100,7 +110,7 @@ protected function handleCancel(): void echo PHP_EOL . ' ' . Colors::error('Cancelled.') . PHP_EOL; } - protected function handleError(Exception $e): void + protected function handleError(\Exception $e): void { echo PHP_EOL . ' ' . Colors::error('An error occurred.') . PHP_EOL; } @@ -120,8 +130,4 @@ protected function stop(): void ========================================================= */ abstract protected function resolve(): mixed; - abstract public function mount(): void; - abstract public function render(): void; - abstract public function update(string $key): void; - abstract public function destroy(): void; } diff --git a/src/BaseIO.php b/src/BaseIO.php index 85c033c..043d2af 100644 --- a/src/BaseIO.php +++ b/src/BaseIO.php @@ -5,9 +5,7 @@ namespace AlfacodeTeam\PhpIoCli; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use AlfacodeTeam\PhpIoCli\Silencer; use Psr\Log\LogLevel; -use Stringable; /** * Enterprise Base IO. @@ -31,42 +29,42 @@ public function writeErrorRaw(string|array $messages, bool $newline = true, int PSR-3 IMPLEMENTATION ========================================================= */ - public function emergency(string|Stringable $message, array $context = []): void + public function emergency(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); } - public function alert(string|Stringable $message, array $context = []): void + public function alert(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); } - public function critical(string|Stringable $message, array $context = []): void + public function critical(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); } - public function error(string|Stringable $message, array $context = []): void + public function error(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); } - public function warning(string|Stringable $message, array $context = []): void + public function warning(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); } - public function notice(string|Stringable $message, array $context = []): void + public function notice(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); } - public function info(string|Stringable $message, array $context = []): void + public function info(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); } - public function debug(string|Stringable $message, array $context = []): void + public function debug(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); } @@ -75,16 +73,15 @@ public function debug(string|Stringable $message, array $context = []): void * Core logging logic with ANSI theming and safe JSON context serialisation. * * @param mixed|LogLevel::* $level - * @param string|Stringable $message */ - public function log($level, $message, array $context = []): void + public function log($level, string|\Stringable $message, array $context = []): void { $output = (string) $message; if ($context !== []) { $json = Silencer::call(static fn() => json_encode( $context, - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE, )); if ($json) { @@ -96,17 +93,17 @@ public function log($level, $message, array $context = []): void LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, - LogLevel::ERROR => Colors::error($output), + LogLevel::ERROR => Colors::error($output), LogLevel::WARNING => Colors::warning($output), LogLevel::NOTICE, - LogLevel::INFO => Colors::info($output), - default => Colors::muted($output), + LogLevel::INFO => Colors::info($output), + default => Colors::muted($output), }; $targetVerbosity = match ($level) { LogLevel::NOTICE => self::VERBOSE, - LogLevel::DEBUG => self::DEBUG, - default => self::NORMAL, + LogLevel::DEBUG => self::DEBUG, + default => self::NORMAL, }; if (in_array($level, [ @@ -117,6 +114,7 @@ public function log($level, $message, array $context = []): void LogLevel::WARNING, ], true)) { $this->writeError($formatted, true, $targetVerbosity); + return; } diff --git a/src/BufferIO.php b/src/BufferIO.php index e90c62d..53fee8b 100644 --- a/src/BufferIO.php +++ b/src/BufferIO.php @@ -4,13 +4,13 @@ namespace AlfacodeTeam\PhpIoCli; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Output\StreamOutput; +use AlfacodeTeam\PhpIoCli\Depends\Colors; use Symfony\Component\Console\Formatter\OutputFormatterInterface; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\StreamableInputInterface; use Symfony\Component\Console\Input\StringInput; -use Symfony\Component\Console\Helper\HelperSet; -use AlfacodeTeam\PhpIoCli\Depends\Colors; +use Symfony\Component\Console\Output\StreamOutput; /** * Captures CLI output in memory for testing and allows simulated user input. @@ -20,7 +20,7 @@ class BufferIO extends ConsoleIO public function __construct( string $input = '', int $verbosity = StreamOutput::VERBOSITY_NORMAL, - ?OutputFormatterInterface $formatter = null + OutputFormatterInterface|null $formatter = null, ) { $inputInstance = new StringInput($input); $inputInstance->setInteractive(false); @@ -53,20 +53,6 @@ public function getOutput(): string return Colors::strip($this->cleanBackspaces($output)); } - /** - * Handles the cleanup of backspace characters (\x08) - */ - private function cleanBackspaces(string $output): string - { - return (string) preg_replace_callback("{(?<=^|\n|\x08)(.+?)(\x08+)}", static function ($matches): string { - $pre = strip_tags($matches[1]); - if (strlen($pre) === strlen($matches[2])) { - return ''; - } - return rtrim($matches[1]) . "\n"; - }, $output); - } - /** * Simulated interaction for testing prompts. * @@ -83,6 +69,21 @@ public function setUserInputs(array $inputs): void $this->input->setInteractive(true); } + /** + * Handles the cleanup of backspace characters (\x08) + */ + private function cleanBackspaces(string $output): string + { + return (string) preg_replace_callback("{(?<=^|\n|\x08)(.+?)(\x08+)}", static function ($matches): string { + $pre = strip_tags($matches[1]); + if (mb_strlen($pre) === mb_strlen($matches[2])) { + return ''; + } + + return mb_rtrim($matches[1]) . "\n"; + }, $output); + } + /** * @param string[] $inputs * diff --git a/src/CLIApplication.php b/src/CLIApplication.php index 2b2aa43..8d08c0f 100644 --- a/src/CLIApplication.php +++ b/src/CLIApplication.php @@ -4,14 +4,13 @@ namespace AlfacodeTeam\PhpIoCli; -use AlfacodeTeam\PhpIoCli\Depends\Colors; use AlfacodeTeam\PhpIoCli\Components\Alert; use AlfacodeTeam\PhpIoCli\Components\Select as CustomSelect; +use AlfacodeTeam\PhpIoCli\Depends\Colors; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; -use Throwable; /** * CLIApplication — self-bootstrapping application runner. @@ -58,14 +57,15 @@ final class CLIApplication * IO layer — built automatically on first access via io(). * Can be replaced with withIO() for tests or custom environments. */ - private ?IOInterface $io = null; + private IOInterface|null $io = null; private bool $catchExceptions = true; - private bool $debug = false; + + private bool $debug = false; public function __construct( - private string $name = 'CLI Application', - private string $version = '1.0.0' + private string $name = 'CLI Application', + private string $version = '1.0.0', ) {} /* ========================================================= @@ -82,24 +82,8 @@ public function __construct( public function withIO(IOInterface $io): self { $this->io = $io; - return $this; - } - - /** - * Lazily builds and caches a ConsoleIO wired to the real terminal. - * Application code never calls this directly. - */ - private function io(): IOInterface - { - if ($this->io === null) { - $this->io = new ConsoleIO( - new ArgvInput(), - new ConsoleOutput(), - new HelperSet([new QuestionHelper()]) - ); - } - return $this->io; + return $this; } /* ========================================================= @@ -109,6 +93,7 @@ private function io(): IOInterface public function catchExceptions(bool $catch): self { $this->catchExceptions = $catch; + return $this; } @@ -121,6 +106,7 @@ public function add(AbstractCommand ...$commands): self foreach ($commands as $command) { $this->commands[$command->getName()] = $command; } + return $this; } @@ -134,6 +120,7 @@ public function get(string $name): AbstractCommand if (!$this->has($name)) { throw new \InvalidArgumentException("Command not found: {$name}"); } + return $this->commands[$name]; } @@ -143,10 +130,11 @@ public function all(bool $includeHidden = false): array $cmds = $this->commands; if (!$includeHidden) { - $cmds = array_filter($cmds, fn($c) => !$c->isHidden()); + $cmds = array_filter($cmds, static fn($c) => !$c->isHidden()); } ksort($cmds); + return $cmds; } @@ -173,9 +161,10 @@ public function discoverCommands(string $composerJsonPath = ''): self if (!is_file($composerJsonPath)) { if ($this->debug) { $this->io()->writeError(Colors::warning( - "discoverCommands: composer.json not found at: {$composerJsonPath}" + "discoverCommands: composer.json not found at: {$composerJsonPath}", )); } + return $this; } @@ -189,8 +178,9 @@ public function discoverCommands(string $composerJsonPath = ''): self if (!is_array($json)) { $this->io()->writeError(Colors::warning( - "discoverCommands: invalid JSON in {$composerJsonPath}" + "discoverCommands: invalid JSON in {$composerJsonPath}", )); + return $this; } @@ -201,9 +191,10 @@ public function discoverCommands(string $composerJsonPath = ''): self if (!is_string($fqcn) || !class_exists($fqcn)) { if ($this->debug) { $this->io()->writeError(Colors::muted( - " [discover] Skipped '{$fqcn}': class not found. Did you run composer dump-autoload?" + " [discover] Skipped '{$fqcn}': class not found. Did you run composer dump-autoload?", )); } + continue; } @@ -212,9 +203,10 @@ public function discoverCommands(string $composerJsonPath = ''): self if ($ref->isAbstract() || !$ref->isSubclassOf(AbstractCommand::class)) { if ($this->debug) { $this->io()->writeError(Colors::muted( - " [discover] Skipped '{$fqcn}': not a concrete AbstractCommand subclass." + " [discover] Skipped '{$fqcn}': not a concrete AbstractCommand subclass.", )); } + continue; } @@ -222,10 +214,10 @@ public function discoverCommands(string $composerJsonPath = ''): self /** @var AbstractCommand $cmd */ $cmd = $ref->newInstance(); $this->add($cmd); - } catch (Throwable $e) { + } catch (\Throwable $e) { if ($this->debug) { $this->io()->writeError(Colors::muted( - " [discover] Skipped '{$fqcn}': {$e->getMessage()}" + " [discover] Skipped '{$fqcn}': {$e->getMessage()}", )); } } @@ -234,39 +226,6 @@ public function discoverCommands(string $composerJsonPath = ''): self return $this; } - /** - * Walks up the directory tree from this library file to find the - * outermost composer.json (the project root, not the library's own). - */ - private function locateComposerJson(): string - { - $dir = __DIR__; - - for ($i = 0; $i < 8; $i++) { - $candidate = $dir . '/composer.json'; - - if (is_file($candidate)) { - // Keep walking up — we want the project root, not the library itself - $decoded = json_decode((string) file_get_contents($candidate), true); - $libName = $decoded['name'] ?? ''; - - // Stop at the first composer.json that is NOT this library - if ($libName !== 'alfacode-team/php-io-cli') { - return $candidate; - } - } - - $parent = dirname($dir); - if ($parent === $dir) { - break; - } // filesystem root - $dir = $parent; - } - - // Final fallback: working directory - return getcwd() . '/composer.json'; - } - /* ========================================================= Entry point ========================================================= */ @@ -275,13 +234,14 @@ private function locateComposerJson(): string * Parse argv and run the matching command. * * @param string[]|null $argv Omit to use $_SERVER['argv'] automatically. + * * @return int POSIX exit code (0 = success) */ - public function run(?array $argv = null): int + public function run(array|null $argv = null): int { $argv ??= array_slice($_SERVER['argv'] ?? [], 1); $token = $argv[0] ?? ''; - $rest = array_slice($argv, 1); + $rest = array_slice($argv, 1); // ── Global flags ────────────────────────────────────── if (in_array('--no-ansi', $argv, true)) { @@ -300,7 +260,7 @@ public function run(?array $argv = null): int // ── Dispatch ────────────────────────────────────────── try { return $this->dispatch($token, $rest); - } catch (Throwable $e) { + } catch (\Throwable $e) { if (!$this->catchExceptions) { throw $e; } @@ -315,6 +275,56 @@ public function run(?array $argv = null): int } } + /** + * Lazily builds and caches a ConsoleIO wired to the real terminal. + * Application code never calls this directly. + */ + private function io(): IOInterface + { + if ($this->io === null) { + $this->io = new ConsoleIO( + new ArgvInput(), + new ConsoleOutput(), + new HelperSet([new QuestionHelper()]), + ); + } + + return $this->io; + } + + /** + * Walks up the directory tree from this library file to find the + * outermost composer.json (the project root, not the library's own). + */ + private function locateComposerJson(): string + { + $dir = __DIR__; + + for ($i = 0; $i < 8; $i++) { + $candidate = $dir . '/composer.json'; + + if (is_file($candidate)) { + // Keep walking up — we want the project root, not the library itself + $decoded = json_decode((string) file_get_contents($candidate), true); + $libName = $decoded['name'] ?? ''; + + // Stop at the first composer.json that is NOT this library + if ($libName !== 'alfacode-team/php-io-cli') { + return $candidate; + } + } + + $parent = dirname($dir); + if ($parent === $dir) { + break; + } // filesystem root + $dir = $parent; + } + + // Final fallback: working directory + return getcwd() . '/composer.json'; + } + /* ========================================================= Dispatch ========================================================= */ @@ -331,6 +341,7 @@ private function dispatch(string $token, array $rest): int $cmd->setRethrowExceptions(!$this->catchExceptions); $cmd->execute([], $this->io()); $cmd->printHelp(); + return AbstractCommand::SUCCESS; } @@ -353,6 +364,7 @@ private function dispatch(string $token, array $rest): int if ($this->has($token)) { $cmd = $this->commands[$token]; $cmd->setRethrowExceptions(!$this->catchExceptions); + return $cmd->execute($rest, $this->io()); } @@ -369,6 +381,7 @@ private function dispatch(string $token, array $rest): int if (is_string($pick) && $this->has($pick)) { $cmd = $this->commands[$pick]; $cmd->setRethrowExceptions(!$this->catchExceptions); + return $cmd->execute($rest, $this->io()); } } else { @@ -395,8 +408,9 @@ private function cmdVersion(): int $this->io()->write( Colors::wrap($this->name, [Colors::BOLD, Colors::CYAN]) . ' ' - . Colors::wrap("v{$this->version}", Colors::GREEN) + . Colors::wrap("v{$this->version}", Colors::GREEN), ); + return AbstractCommand::SUCCESS; } @@ -410,6 +424,7 @@ private function cmdHelp(string $commandName): int $cmd->setRethrowExceptions(!$this->catchExceptions); $cmd->execute([], $this->io()); $cmd->printHelp(); + return AbstractCommand::SUCCESS; } @@ -421,6 +436,7 @@ private function cmdList(): int if (empty($commands)) { $this->io()->write(Colors::muted(' No commands registered.')); + return AbstractCommand::SUCCESS; } @@ -442,13 +458,13 @@ 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(static fn($n) => mb_strlen($n), array_keys($cmds))); foreach ($cmds as $name => $cmd) { $this->io()->write(sprintf( ' %s %s', - Colors::wrap(str_pad($name, $maxLen), Colors::GREEN), - Colors::muted($cmd->getDescription()) + Colors::wrap(mb_str_pad($name, $maxLen), Colors::GREEN), + Colors::muted($cmd->getDescription()), )); } } @@ -472,7 +488,7 @@ private function printBanner(): void $this->io()->write( Colors::wrap(" {$this->name}", [Colors::BOLD, Colors::CYAN]) . ' ' - . Colors::muted("v{$this->version}") + . Colors::muted("v{$this->version}"), ); $this->io()->write(Colors::muted(" {$separator}")); $this->io()->write(''); @@ -497,6 +513,7 @@ private function suggest(string $input): array } asort($matches); + return array_keys($matches); } diff --git a/src/Components/Alert.php b/src/Components/Alert.php index 98221aa..3b39093 100644 --- a/src/Components/Alert.php +++ b/src/Components/Alert.php @@ -54,12 +54,12 @@ public static function block(string $title, string|array $body = [], string $col echo Colors::wrap(str_repeat(' ', $visualWidth), $color . '48') . PHP_EOL; // 48 = BG // Title Line - $titleLine = " " . strtoupper($title); + $titleLine = ' ' . mb_strtoupper($title); echo Colors::wrap(self::padVisual($titleLine, $visualWidth), [Colors::BOLD, $color . '48', Colors::WHITE]) . PHP_EOL; // Body foreach ($body as $line) { - echo Colors::wrap(self::padVisual(" " . $line, $visualWidth), $color . '48') . PHP_EOL; + echo Colors::wrap(self::padVisual(' ' . $line, $visualWidth), $color . '48') . PHP_EOL; } // Bottom Padding @@ -81,14 +81,14 @@ private static function render(string $title, array $body, string $icon, string echo PHP_EOL . Colors::wrap('┌' . str_repeat('─', $innerWidth) . '┐', $t) . PHP_EOL; // 2. Title Line - $formattedTitle = " " . Colors::wrap($icon, [$color, Colors::BOLD]) . " " . Colors::wrap($title, Colors::BOLD); + $formattedTitle = ' ' . Colors::wrap($icon, [$color, Colors::BOLD]) . ' ' . Colors::wrap($title, Colors::BOLD); echo Colors::wrap('│', $t) . self::padVisual($formattedTitle, $innerWidth) . Colors::wrap('│', $t) . PHP_EOL; // 3. Body Content if (!empty($body)) { echo Colors::wrap('├' . str_repeat('─', $innerWidth) . '┤', $t) . PHP_EOL; foreach ($body as $line) { - echo Colors::wrap('│', $t) . self::padVisual(" " . $line, $innerWidth) . Colors::wrap('│', $t) . PHP_EOL; + echo Colors::wrap('│', $t) . self::padVisual(' ' . $line, $innerWidth) . Colors::wrap('│', $t) . PHP_EOL; } } @@ -102,6 +102,7 @@ private static function calculateMaxWidth(string $title, array $body): int foreach ($body as $line) { $max = max($max, mb_strlen(Colors::strip((string) $line))); } + return $max; } @@ -112,6 +113,7 @@ private static function calculateMaxWidth(string $title, array $body): int private static function padVisual(string $text, int $width): string { $visualLen = mb_strlen(Colors::strip($text)); + return $text . str_repeat(' ', max(0, $width - $visualLen)); } } diff --git a/src/Components/Autocomplete.php b/src/Components/Autocomplete.php index dfda84d..a06893b 100644 --- a/src/Components/Autocomplete.php +++ b/src/Components/Autocomplete.php @@ -20,33 +20,29 @@ final class Autocomplete extends Component { private int $lastLines = 0; + private int $maxSuggestions = 6; + private int $minDropdownWidth = 40; public function __construct( private string $question, - private array $suggestions + private array $suggestions, ) { parent::__construct(); } - public function maxSuggestions(int $n): self - { - $this->maxSuggestions = $n; - return $this; - } - protected function setup(): void { $this->state->batch([ - 'value' => '', - 'cursor' => 0, - 'focused' => 0, - 'done' => false, + 'value' => '', + 'cursor' => 0, + 'focused' => 0, + 'done' => false, ]); // 1. Text Input Logic (Multibyte Safe) - $this->input->fallback(function ($s, $key): void { + $this->input->fallback(static function ($s, $key): void { if (Key::isPrintable($key)) { $cur = (int) $s->cursor; $val = (string) $s->value; @@ -58,7 +54,7 @@ protected function setup(): void }); // 2. Navigation & Deletion - $this->input->bind('BACKSPACE', function ($s): void { + $this->input->bind('BACKSPACE', static function ($s): void { $cur = (int) $s->cursor; if ($cur === 0) { return; @@ -70,10 +66,10 @@ protected function setup(): void $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', static fn($s) => $s->decrement('cursor')); + $this->input->bind('RIGHT', static fn($s) => $s->increment('cursor', mb_strlen((string) $s->value))); - $this->input->bind('UP', function ($s): void { + $this->input->bind('UP', static function ($s): void { $s->decrement('focused'); }); @@ -102,6 +98,7 @@ protected function setup(): void if ($highlighted && $highlighted !== $val) { $s->value = $highlighted; $s->cursor = mb_strlen($highlighted); + return; } } @@ -111,17 +108,24 @@ protected function setup(): void }); } + public function maxSuggestions(int $n): self + { + $this->maxSuggestions = $n; + + return $this; + } + public function render(): void { if ($this->lastLines > 0) { 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 = []; + $lines = []; // HEADER $lines[] = Colors::wrap('? ', Colors::CYAN) . Colors::wrap($this->question, Colors::BOLD); @@ -129,12 +133,12 @@ public function render(): void 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); + $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]); + $caret = Colors::wrap($caretChar, ["\033[7m", Colors::YELLOW]); $lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap($before, Colors::YELLOW) . $caret . Colors::wrap($after, Colors::YELLOW); @@ -152,7 +156,7 @@ public function render(): void } // ATOMIC RENDER - $output = ""; + $output = ''; foreach ($lines as $line) { $output .= "\r\033[2K" . $line . PHP_EOL; } @@ -161,6 +165,11 @@ public function render(): void $this->lastLines = count($lines); } + public function resolve(): mixed + { + return $this->state->value; + } + private function renderDropdown(array $filtered, int $focused): array { $visible = array_slice($filtered, 0, $this->maxSuggestions); @@ -184,7 +193,7 @@ private function renderDropdown(array $filtered, int $focused): array // Padded line construction $contentLen = mb_strlen(Colors::strip($item)); - $padding = str_repeat(' ', max(0, $width - $contentLen - 4)); + $padding = str_repeat(' ', max(0, $width - $contentLen - 4)); $lines[] = Colors::wrap(' │ ', $t) . $icon . $text . $padding . Colors::wrap(' │', $t); } @@ -199,11 +208,6 @@ private function renderDropdown(array $filtered, int $focused): array return $lines; } - public function resolve(): mixed - { - return $this->state->value; - } - private function getFiltered(): array { return Fuzzy::filter($this->suggestions, (string) $this->state->value); diff --git a/src/Components/Component.php b/src/Components/Component.php index 7b8880e..8ac1589 100644 --- a/src/Components/Component.php +++ b/src/Components/Component.php @@ -5,25 +5,36 @@ namespace AlfacodeTeam\PhpIoCli\Components; use AlfacodeTeam\PhpIoCli\AbstractPrompt; -use AlfacodeTeam\PhpIoCli\Hooks; -use AlfacodeTeam\PhpIoCli\Depends\State; use AlfacodeTeam\PhpIoCli\Depends\Input; use AlfacodeTeam\PhpIoCli\Depends\Renderer; +use AlfacodeTeam\PhpIoCli\Depends\State; +use AlfacodeTeam\PhpIoCli\Hooks; abstract class Component extends AbstractPrompt { protected State $state; + protected Input $input; + protected Renderer $renderer; - public function __construct(?Hooks $hooks = null) + public function __construct(Hooks|null $hooks = null) { parent::__construct($hooks ?? new Hooks()); - $this->state = new State(); - $this->input = new Input(); + $this->state = new State(); + $this->input = new Input(); $this->renderer = new Renderer(); } + /* ========================================================= + ABSTRACT HOOKS FOR SUBCLASSES + ========================================================= */ + + /** + * Subclasses wire their State + Input bindings here. + */ + abstract protected function setup(): void; + /* ========================================================= LIFECYCLE ========================================================= */ @@ -37,22 +48,6 @@ final public function mount(): void $this->setup(); } - /** - * Wire IRenderer::beforeRender() into AbstractPrompt's engine loop. - */ - protected function beforeRenderHook(): void - { - $this->renderer->beforeRender($this->state, $this->context); - } - - /** - * Wire IRenderer::afterRender() into AbstractPrompt's engine loop. - */ - protected function afterRenderHook(): void - { - $this->renderer->afterRender($this->state, $this->context); - } - /** * Proxies the key update to the Input dispatcher, then marks the UI dirty. */ @@ -67,16 +62,23 @@ public function destroy(): void echo "\r\033[2K"; } - /* ========================================================= - ABSTRACT HOOKS FOR SUBCLASSES - ========================================================= */ + abstract public function render(): void; + + abstract public function resolve(): mixed; /** - * Subclasses wire their State + Input bindings here. + * Wire IRenderer::beforeRender() into AbstractPrompt's engine loop. */ - abstract protected function setup(): void; - - abstract public function render(): void; + protected function beforeRenderHook(): void + { + $this->renderer->beforeRender($this->state, $this->context); + } - abstract public function resolve(): mixed; + /** + * Wire IRenderer::afterRender() into AbstractPrompt's engine loop. + */ + protected function afterRenderHook(): void + { + $this->renderer->afterRender($this->state, $this->context); + } } diff --git a/src/Components/Confirm.php b/src/Components/Confirm.php index 6386b99..bb57ff1 100644 --- a/src/Components/Confirm.php +++ b/src/Components/Confirm.php @@ -18,7 +18,7 @@ final class Confirm extends Component public function __construct( private string $question, - private bool $default = true + private bool $default = true, ) { parent::__construct(); } @@ -27,9 +27,9 @@ protected function setup(): void { $this->state->confirmed = $this->default; - $this->input->bind(['y', 'Y'], fn($s) => $s->confirmed = true); - $this->input->bind(['n', 'N'], fn($s) => $s->confirmed = false); - $this->input->bind(['LEFT', 'RIGHT'], fn($s) => $s->confirmed = !$s->confirmed); + $this->input->bind(['y', 'Y'], static fn($s) => $s->confirmed = true); + $this->input->bind(['n', 'N'], static fn($s) => $s->confirmed = false); + $this->input->bind(['LEFT', 'RIGHT'], static fn($s) => $s->confirmed = !$s->confirmed); $this->input->bind('ENTER', fn() => $this->stop()); } @@ -42,7 +42,7 @@ public function render(): void $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); + $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), diff --git a/src/Components/DatePicker.php b/src/Components/DatePicker.php index b3998a1..b1debf0 100644 --- a/src/Components/DatePicker.php +++ b/src/Components/DatePicker.php @@ -27,13 +27,13 @@ public function __construct(private string $question) protected function setup(): void { - $now = new DateTimeImmutable(); + $now = new \DateTimeImmutable(); $this->state->batch([ - 'year' => (int) $now->format('Y'), + 'year' => (int) $now->format('Y'), 'month' => (int) $now->format('n'), - 'day' => (int) $now->format('j'), - 'done' => false, + 'day' => (int) $now->format('j'), + 'done' => false, ]); // 1. Precise Day/Week Navigation @@ -47,8 +47,8 @@ protected function setup(): void $this->input->bind([']', 'PAGE_DOWN'], fn($s) => $this->shiftMonth($s, 1)); // 3. Shortcuts - $this->input->bind('t', function ($s): void { - $now = new DateTimeImmutable(); + $this->input->bind('t', static function ($s): void { + $now = new \DateTimeImmutable(); $s->batch([ 'year' => (int) $now->format('Y'), 'month' => (int) $now->format('n'), @@ -62,44 +62,6 @@ protected function setup(): void }); } - 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'), - ]); - } - - 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'); - } - - $s->batch([ - '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, - $this->state->day - )); - } - public function render(): void { // 1. Move up and CLEAR the old lines @@ -113,10 +75,10 @@ public function render(): void Terminal::moveCursorUp($this->lastLines); } - $year = (int) $this->state->year; + $year = (int) $this->state->year; $month = (int) $this->state->month; - $day = (int) $this->state->day; - $done = (bool) $this->state->done; + $day = (int) $this->state->day; + $done = (bool) $this->state->done; $lines = []; $lines[] = Colors::wrap('? ', Colors::CYAN) . Colors::wrap($this->question, Colors::BOLD); @@ -138,7 +100,7 @@ public function render(): void $isToday = $this->isToday($year, $month, $d); $isSelected = ($d === $day); - $label = str_pad((string) $d, 2, ' ', STR_PAD_LEFT); + $label = mb_str_pad((string) $d, 2, ' ', STR_PAD_LEFT); if ($isSelected) { // Reverse video for the "Cursor" @@ -160,7 +122,7 @@ public function render(): void } } - if (trim($currentLine) !== '') { + if (mb_trim($currentLine) !== '') { $lines[] = $currentLine; } @@ -172,7 +134,7 @@ public function render(): void } // Atomic render - $output = ""; + $output = ''; foreach ($lines as $line) { $output .= "\r\033[2K" . $line . PHP_EOL; } @@ -181,13 +143,51 @@ public function render(): void $this->lastLines = count($lines); } - private function isToday(int $y, int $m, int $d): bool + public function resolve(): \DateTimeImmutable { - return date('Y-m-d') === sprintf('%04d-%02d-%02d', $y, $m, $d); + return $this->getSelectedDate(); } - public function resolve(): DateTimeImmutable + private function shiftDate(mixed $s, string $modify): void { - return $this->getSelectedDate(); + $dt = $this->getSelectedDate()->modify($modify); + $s->batch([ + 'year' => (int) $dt->format('Y'), + 'month' => (int) $dt->format('n'), + 'day' => (int) $dt->format('j'), + ]); + } + + 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'); + } + + $s->batch([ + '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, + $this->state->day, + )); + } + + private function isToday(int $y, int $m, int $d): bool + { + return date('Y-m-d') === sprintf('%04d-%02d-%02d', $y, $m, $d); } } diff --git a/src/Components/MultiSelect.php b/src/Components/MultiSelect.php index 5cf3051..d9e4111 100644 --- a/src/Components/MultiSelect.php +++ b/src/Components/MultiSelect.php @@ -5,7 +5,6 @@ namespace AlfacodeTeam\PhpIoCli\Components; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use AlfacodeTeam\PhpIoCli\Depends\Key; use AlfacodeTeam\PhpIoCli\Depends\Terminal; /** @@ -26,7 +25,7 @@ protected function setup(): void { $this->state->batch(['index' => 0, 'selected' => [], 'done' => false]); - $this->input->bind('UP', fn($s) => $s->decrement('index')); + $this->input->bind('UP', static fn($s) => $s->decrement('index')); $this->input->bind('DOWN', fn($s) => $s->increment('index', count($this->choices) - 1)); $this->input->bind(' ', function ($s): void { $val = $this->choices[$s->index]; diff --git a/src/Components/NumberInput.php b/src/Components/NumberInput.php index e70198e..3ae0117 100644 --- a/src/Components/NumberInput.php +++ b/src/Components/NumberInput.php @@ -17,11 +17,16 @@ */ final class NumberInput extends Component { - private ?float $min = null; - private ?float $max = null; - private float $step = 1; - private ?float $default = null; - private bool $intOnly = false; + private float|null $min = null; + + private float|null $max = null; + + private float $step = 1; + + private float|null $default = null; + + private bool $intOnly = false; + private int $lastLines = 0; public function __construct(private string $question) @@ -29,33 +34,6 @@ public function __construct(private string $question) parent::__construct(); } - /* --- Fluent --- */ - 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 ========================================================= */ @@ -63,9 +41,9 @@ public function integer(): self 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, + 'done' => false, ]); // Typing digits, minus, dot @@ -75,48 +53,50 @@ protected function setup(): void } $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): void { - $s->raw = mb_substr((string) $s->raw, 0, -1); + $this->input->bind('BACKSPACE', static function ($s): void { + $s->raw = mb_substr((string) $s->raw, 0, -1); $s->error = null; }); // Arrow stepping $this->input->bind('UP', function ($s): void { $current = (float) ((string) $s->raw ?: '0'); - $new = $current + $this->step; + $new = $current + $this->step; if ($this->max !== null) { $new = min($new, $this->max); } - $s->raw = $this->format($new); + $s->raw = $this->format($new); }); $this->input->bind('DOWN', function ($s): void { $current = (float) ((string) $s->raw ?: '0'); - $new = $current - $this->step; + $new = $current - $this->step; if ($this->min !== null) { $new = max($new, $this->min); } - $s->raw = $this->format($new); + $s->raw = $this->format($new); }); $this->input->bind('ENTER', function ($s): void { - $raw = trim((string) $s->raw); + $raw = mb_trim((string) $s->raw); if ($raw === '' && $this->default !== null) { $raw = (string) $this->default; } if ($raw === '') { $s->error = 'A number is required.'; + return; } if (!is_numeric($raw)) { $s->error = "'{$raw}' is not a valid number."; + return; } @@ -124,23 +104,56 @@ protected function setup(): void if ($this->min !== null && $val < $this->min) { $s->error = "Minimum value is {$this->min}."; + return; } if ($this->max !== null && $val > $this->max) { $s->error = "Maximum value is {$this->max}."; + return; } - $s->raw = $this->format($val); + $s->raw = $this->format($val); $s->done = true; $this->stop(); }); } - private function format(float $v): string + /* --- Fluent --- */ + public function min(float $v): self { - return $this->intOnly ? (string) (int) $v : rtrim(rtrim(number_format($v, 10, '.', ''), '0'), '.'); + $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; } /* ========================================================= @@ -155,16 +168,16 @@ 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); + $mark = $done ? Colors::success('') : Colors::wrap('? ', Colors::CYAN); $lines[] = $mark . Colors::wrap($this->question, Colors::BOLD); if (!$done) { - $cursor = Colors::wrap('▊', Colors::CYAN); + $cursor = Colors::wrap('▊', Colors::CYAN); $display = Colors::wrap($raw, Colors::YELLOW) . $cursor; // Range hint inline @@ -206,6 +219,12 @@ public function destroy(): void public function resolve(): mixed { $v = (float) $this->state->raw; + return $this->intOnly ? (int) $v : $v; } + + private function format(float $v): string + { + return $this->intOnly ? (string) (int) $v : mb_rtrim(mb_rtrim(number_format($v, 10, '.', ''), '0'), '.'); + } } diff --git a/src/Components/Password.php b/src/Components/Password.php index 68b9ee2..ec18fc3 100644 --- a/src/Components/Password.php +++ b/src/Components/Password.php @@ -15,6 +15,7 @@ final class Password extends Component { private bool $strengthMeter = false; + private int $lastLines = 0; public function __construct(private string $question) @@ -22,12 +23,6 @@ public function __construct(private string $question) parent::__construct(); } - public function showStrength(): self - { - $this->strengthMeter = true; - return $this; - } - /* ========================================================= LIFECYCLE ========================================================= */ @@ -35,24 +30,24 @@ public function showStrength(): self protected function setup(): void { $this->state->batch([ - 'value' => '', + 'value' => '', 'visible' => false, - 'done' => false, + 'done' => false, ]); // Capture characters - $this->input->fallback(function ($state, $key): void { + $this->input->fallback(static function ($state, $key): void { if (Key::isPrintable($key)) { $state->value .= $key; } }); - $this->input->bind('BACKSPACE', function ($state): void { + $this->input->bind('BACKSPACE', static function ($state): void { $state->value = mb_substr((string) $state->value, 0, -1); }); // TAB toggles visibility - $this->input->bind('TAB', function ($state): void { + $this->input->bind('TAB', static function ($state): void { $state->visible = !(bool) $state->visible; }); @@ -62,6 +57,13 @@ protected function setup(): void }); } + public function showStrength(): self + { + $this->strengthMeter = true; + + return $this; + } + /* ========================================================= RENDER ========================================================= */ @@ -75,10 +77,10 @@ public function render(): void Terminal::hideCursor(); - $value = (string) $this->state->value; + $value = (string) $this->state->value; $visible = (bool) $this->state->visible; - $done = (bool) $this->state->done; - $len = mb_strlen($value); + $done = (bool) $this->state->done; + $len = mb_strlen($value); $lines = []; @@ -102,11 +104,11 @@ public function render(): void } // Help Hint - $toggle = $visible ? 'hide' : 'show'; + $toggle = $visible ? 'hide' : 'show'; $lines[] = Colors::muted(" TAB {$toggle} password • ENTER submit"); } else { // Collapse UI on finish to keep terminal clean - $lines[] = Colors::success(" Password accepted ($len chars)"); + $lines[] = Colors::success(" Password accepted ({$len} chars)"); } // 2. Output with line clearing @@ -140,7 +142,7 @@ public function resolve(): mixed private function buildStrengthBar(string $password): string { $score = 0; - $len = mb_strlen($password); + $len = mb_strlen($password); if ($len >= 8) { $score++; @@ -165,9 +167,9 @@ private function buildStrengthBar(string $password): string $index = max(0, min($score - 1, 4)); $filled = str_repeat('━', $score); - $empty = str_repeat('━', 5 - $score); - $label = $labels[$index]; - $color = $colors[$index]; + $empty = str_repeat('━', 5 - $score); + $label = $labels[$index]; + $color = $colors[$index]; return Colors::wrap($filled, $color) . Colors::muted($empty) diff --git a/src/Components/ProgressBar.php b/src/Components/ProgressBar.php index d1dc0bc..3f4449e 100644 --- a/src/Components/ProgressBar.php +++ b/src/Components/ProgressBar.php @@ -5,9 +5,9 @@ namespace AlfacodeTeam\PhpIoCli\Components; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use AlfacodeTeam\PhpIoCli\Depends\Terminal; use AlfacodeTeam\PhpIoCli\Depends\Spinner as SpinnerEngine; use AlfacodeTeam\PhpIoCli\Depends\SpinnerFrames; +use AlfacodeTeam\PhpIoCli\Depends\Terminal; /** * Enterprise Integrated Progress Bar @@ -16,20 +16,27 @@ final class ProgressBar { private SpinnerEngine $spinner; + private int $current = 0; + private int $lastLines = 0; + private float $startTime = 0.0; + private string $status = ''; + private bool $finished = false; private int $width = 40; + private string $fillChar = '█'; + private string $emptyChar = '░'; public function __construct( private string $label, private int $total = 0, // 0 = indeterminate - string $spinnerStyle = 'dots' + string $spinnerStyle = 'dots', ) { $this->spinner = new SpinnerEngine(SpinnerFrames::get($spinnerStyle)); } @@ -38,6 +45,7 @@ public function __construct( public function width(int $w): self { $this->width = $w; + return $this; } @@ -61,7 +69,7 @@ public function tick(string $status = ''): void { if ($status !== '') { // Truncate to prevent line wrapping which breaks cursor math - $this->status = mb_strimwidth(trim($status), 0, 60, '...'); + $this->status = mb_strimwidth(mb_trim($status), 0, 60, '...'); } $this->draw(); } @@ -114,14 +122,14 @@ private function draw(string $finishMessage = ''): void if ($this->finished) { // Finished State $msg = $finishMessage ?: "Completed: {$this->label}"; - $lines[] = Colors::success($msg) . Colors::muted(sprintf(" (%.2fs)", $elapsed)); + $lines[] = Colors::success($msg) . Colors::muted(sprintf(' (%.2fs)', $elapsed)); } else { $frame = Colors::wrap($this->spinner->tick() ?: '', Colors::CYAN); 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 = mb_str_pad((int) ($pct * 100) . '%', 4, ' ', STR_PAD_LEFT); $lines[] = "{$frame} " . Colors::wrap($this->label, Colors::BOLD) . Colors::muted(sprintf(' (%d/%d)', $this->current, $this->total)); @@ -140,7 +148,7 @@ private function draw(string $finishMessage = ''): void } // 2. Atomic Render - $output = ""; + $output = ''; foreach ($lines as $line) { $output .= "\r\033[2K" . $line . PHP_EOL; } @@ -152,13 +160,13 @@ private function draw(string $finishMessage = ''): void private function buildBar(float $pct): string { $filledSize = (int) round($this->width * $pct); - $emptySize = $this->width - $filledSize; + $emptySize = $this->width - $filledSize; $color = match (true) { $pct >= 1.0 => Colors::GREEN, $pct >= 0.7 => Colors::CYAN, $pct >= 0.3 => Colors::YELLOW, - default => Colors::RED, + default => Colors::RED, }; return Colors::wrap(str_repeat($this->fillChar, $filledSize), $color) diff --git a/src/Components/Select.php b/src/Components/Select.php index e8f0e7f..e994dc3 100644 --- a/src/Components/Select.php +++ b/src/Components/Select.php @@ -21,7 +21,7 @@ final class Select extends Component public function __construct( private string $question, - private array $choices + private array $choices, ) { parent::__construct(); } @@ -33,15 +33,15 @@ public function __construct( protected function setup(): void { $this->state->batch([ - 'index' => 0, - 'search' => '', + 'index' => 0, + 'search' => '', 'choices' => $this->choices, - 'result' => null, - 'done' => false, + 'result' => null, + 'done' => false, ]); // Navigation - $this->input->bind('UP', function ($state): void { + $this->input->bind('UP', static function ($state): void { $state->decrement('index'); }); @@ -51,7 +51,7 @@ protected function setup(): void }); // Search logic - $this->input->bind('BACKSPACE', function ($state): void { + $this->input->bind('BACKSPACE', static function ($state): void { $state->search = mb_substr((string) $state->search, 0, -1); $state->index = 0; }); @@ -68,7 +68,7 @@ protected function setup(): void }); // Typing fallback - $this->input->fallback(function ($state, $key): void { + $this->input->fallback(static function ($state, $key): void { if (Key::isPrintable($key)) { $state->search .= $key; $state->index = 0; // Reset index on new search @@ -89,10 +89,10 @@ public function render(): void Terminal::hideCursor(); - $done = (bool) $this->state->done; - $search = (string) $this->state->search; + $done = (bool) $this->state->done; + $search = (string) $this->state->search; $filtered = $this->getFiltered(); - $lines = []; + $lines = []; // Question Line $lines[] = Colors::wrap('? ', Colors::CYAN) . Colors::wrap($this->question, Colors::BOLD); @@ -100,16 +100,16 @@ public function render(): void if (!$done) { // Search Bar Line $searchLabel = Colors::wrap('› ', Colors::GRAY); - $searchText = $search !== '' + $searchText = $search !== '' ? Colors::wrap($search, Colors::YELLOW) . Colors::wrap('▊', Colors::CYAN) : Colors::wrap('Type to filter...', Colors::DIM); $lines[] = " {$searchLabel}{$searchText}"; - $lines[] = ""; // Spacer + $lines[] = ''; // Spacer // List Items if (empty($filtered)) { - $lines[] = Colors::wrap(" ✘ No matches found", Colors::RED); + $lines[] = Colors::wrap(' ✘ No matches found', Colors::RED); } else { // Windowing (Enterprise scale: show 8 items max) $windowSize = 8; @@ -129,12 +129,12 @@ public function render(): void // Scroll indicators for large lists if ($total > $windowSize) { - $lines[] = Colors::muted(sprintf(" (Showing %d of %d)", $windowSize, $total)); + $lines[] = Colors::muted(sprintf(' (Showing %d of %d)', $windowSize, $total)); } } - $lines[] = ""; - $lines[] = Colors::muted(" ↑↓ navigate • ENTER select • Type to filter"); + $lines[] = ''; + $lines[] = Colors::muted(' ↑↓ navigate • ENTER select • Type to filter'); } else { // Collapse UI on finish diff --git a/src/Components/SpinnerComponent.php b/src/Components/SpinnerComponent.php index e74030c..7d4fc48 100644 --- a/src/Components/SpinnerComponent.php +++ b/src/Components/SpinnerComponent.php @@ -21,13 +21,16 @@ final class SpinnerComponent { private SpinnerEngine $engine; - private bool $running = false; - private float $startTime = 0.0; - private int $lastLines = 0; + + private bool $running = false; + + private float $startTime = 0.0; + + private int $lastLines = 0; public function __construct( private string $label, - string $style = 'dots' + string $style = 'dots', ) { $this->engine = new SpinnerEngine(SpinnerFrames::get($style)); } @@ -35,7 +38,7 @@ public function __construct( public function start(): void { Terminal::hideCursor(); - $this->running = true; + $this->running = true; $this->startTime = microtime(true); $this->engine->start(); $this->draw(); @@ -89,9 +92,9 @@ private function draw(string $subLabel = ''): void Terminal::moveCursorUp($this->lastLines); } - $frame = $this->engine->tick(); + $frame = $this->engine->tick(); $elapsed = round(microtime(true) - $this->startTime, 1); - $lines = []; + $lines = []; $lines[] = Colors::wrap($frame . ' ', Colors::CYAN) . Colors::wrap($this->label, Colors::BOLD) diff --git a/src/Components/Table.php b/src/Components/Table.php index b99a5e7..ce57ed9 100644 --- a/src/Components/Table.php +++ b/src/Components/Table.php @@ -24,9 +24,13 @@ final class Table { private array $headers = []; - private array $rows = []; - private string $style = 'box'; + + private array $rows = []; + + private string $style = 'box'; + private array $alignments = []; + private bool $striped = true; private function __construct() {} @@ -39,21 +43,28 @@ public static function make(): self 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; } @@ -61,6 +72,7 @@ public function striped(bool $enable = true): self public function align(array $alignments): self { $this->alignments = $alignments; + return $this; } @@ -72,11 +84,11 @@ public function render(): void public function toString(): string { if (empty($this->headers) && empty($this->rows)) { - return ""; + return ''; } $colCount = $this->getColumnCount(); - $widths = $this->computeWidths($colCount); + $widths = $this->computeWidths($colCount); [$tl, $tr, $bl, $br, $hSep, $vSep, $tJoin, $bJoin, $lJoin, $rJoin, $cross] = $this->getBorders(); @@ -109,6 +121,7 @@ private function getColumnCount(): int foreach ($this->rows as $row) { $max = max($max, count($row)); } + return $max; } @@ -128,6 +141,7 @@ private function computeWidths(int $count): array $widths[$i] = max($widths[$i], $visualLength); } } + return $widths; } @@ -153,6 +167,7 @@ private function drawRow(array $cells, array $widths, string $sep, bool $isHeade } $vSep = Colors::muted($sep); + return $vSep . implode($vSep, $parts) . $vSep; } @@ -162,9 +177,9 @@ private function applyPadding(string $text, int $targetWidth, string $align): st $diff = max(0, $targetWidth - $visualLen); return match ($align) { - 'right' => str_repeat(' ', $diff) . $text, + 'right' => str_repeat(' ', $diff) . $text, 'center' => str_repeat(' ', (int) floor($diff / 2)) . $text . str_repeat(' ', (int) ceil($diff / 2)), - default => $text . str_repeat(' ', $diff), + default => $text . str_repeat(' ', $diff), }; } @@ -174,6 +189,7 @@ private function drawSeparator(array $widths, string $l, string $r, string $h, s foreach ($widths as $w) { $segments[] = str_repeat($h, $w + 2); } + return Colors::muted($l . implode($join, $segments) . $r); } @@ -182,8 +198,8 @@ private function getBorders(): array return match ($this->style) { 'compact' => ['┌','┐','└','┘','─','│','┬','┴','├','┤','┼'], 'minimal' => [' ',' ',' ',' ','─',' ','─','─','─','─',' '], - 'bold' => ['┏','┓','┗','┛','━','┃','┳','┻','┣','┫','╋'], - default => ['╔','╗','╚','╝','═','║','╦','╩','╠','╣','╬'], + 'bold' => ['┏','┓','┗','┛','━','┃','┳','┻','┣','┫','╋'], + default => ['╔','╗','╚','╝','═','║','╦','╩','╠','╣','╬'], }; } } diff --git a/src/Components/TextInput.php b/src/Components/TextInput.php index b308165..93b0ca4 100644 --- a/src/Components/TextInput.php +++ b/src/Components/TextInput.php @@ -16,7 +16,9 @@ final class TextInput extends Component { private string $placeholder = ''; + private string $defaultValue = ''; + private int $lastLines = 0; /** @var (callable(string): ?string)|null */ @@ -27,26 +29,6 @@ public function __construct(private string $question) parent::__construct(); } - /* --- Fluent Builders --- */ - - public function placeholder(string $text): self - { - $this->placeholder = $text; - return $this; - } - - public function default(string $value): self - { - $this->defaultValue = $value; - return $this; - } - - public function validate(callable $validator): self - { - $this->validator = $validator; - return $this; - } - /* ========================================================= LIFECYCLE ========================================================= */ @@ -61,7 +43,7 @@ protected function setup(): void ]); // Key: Typing - $this->input->fallback(function (State|string $state, string $key): void { + $this->input->fallback(static function (State|string $state, string $key): void { if (Key::isPrintable($key)) { $cur = (int) $state->cursor; $value = (string) $state->value; @@ -72,17 +54,17 @@ protected function setup(): void }); // Key: Navigation - $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 { + $this->input->bind('LEFT', static fn(State|string $s) => $s->decrement('cursor')); + $this->input->bind('RIGHT', static fn(State|string $s) => $s->increment('cursor', mb_strlen((string) $s->value))); + $this->input->bind('HOME', static function (State|string $s): void { $s->cursor = 0; }); - $this->input->bind('END', function (State|string $s): void { + $this->input->bind('END', static function (State|string $s): void { $s->cursor = mb_strlen((string) $s->value); }); // Key: Deletion - $this->input->bind('BACKSPACE', function (State|string $state): void { + $this->input->bind('BACKSPACE', static function (State|string $state): void { $cur = (int) $state->cursor; if ($cur === 0) { return; @@ -92,7 +74,7 @@ protected function setup(): void $state->error = null; }); - $this->input->bind('DELETE', function (State|string $state): void { + $this->input->bind('DELETE', static function (State|string $state): void { $cur = (int) $state->cursor; $value = (string) $state->value; if ($cur >= mb_strlen($value)) { @@ -114,6 +96,7 @@ protected function setup(): void $err = ($this->validator)($value); if ($err !== null) { $state->error = $err; + return; } } @@ -123,6 +106,29 @@ protected function setup(): void }); } + /* --- Fluent Builders --- */ + + public function placeholder(string $text): self + { + $this->placeholder = $text; + + return $this; + } + + public function default(string $value): self + { + $this->defaultValue = $value; + + return $this; + } + + public function validate(callable $validator): self + { + $this->validator = $validator; + + return $this; + } + /* ========================================================= RENDER ========================================================= */ @@ -202,6 +208,7 @@ public function destroy(): void public function resolve(): mixed { $value = (string) $this->state->value; + return ($value !== '') ? $value : $this->defaultValue; } } diff --git a/src/ConsoleIO.php b/src/ConsoleIO.php index c8262b1..a360eb2 100644 --- a/src/ConsoleIO.php +++ b/src/ConsoleIO.php @@ -4,13 +4,12 @@ namespace AlfacodeTeam\PhpIoCli; -use AlfacodeTeam\PhpIoCli\Depends\Colors; -use AlfacodeTeam\PhpIoCli\Components\Autocomplete; use AlfacodeTeam\PhpIoCli\Components\Confirm; use AlfacodeTeam\PhpIoCli\Components\MultiSelect; use AlfacodeTeam\PhpIoCli\Components\Password; use AlfacodeTeam\PhpIoCli\Components\Select as CustomSelect; use AlfacodeTeam\PhpIoCli\Components\TextInput; +use AlfacodeTeam\PhpIoCli\Depends\Colors; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; @@ -32,22 +31,24 @@ */ class ConsoleIO extends BaseIO { - protected string $lastMessage = ''; + protected string $lastMessage = ''; + protected string $lastMessageErr = ''; - private ?float $startTime = null; + + private float|null $startTime = null; private array $verbosityMap = [ - self::QUIET => OutputInterface::VERBOSITY_QUIET, - self::NORMAL => OutputInterface::VERBOSITY_NORMAL, - self::VERBOSE => OutputInterface::VERBOSITY_VERBOSE, + self::QUIET => OutputInterface::VERBOSITY_QUIET, + self::NORMAL => OutputInterface::VERBOSITY_NORMAL, + self::VERBOSE => OutputInterface::VERBOSITY_VERBOSE, self::VERY_VERBOSE => OutputInterface::VERBOSITY_VERY_VERBOSE, - self::DEBUG => OutputInterface::VERBOSITY_DEBUG, + self::DEBUG => OutputInterface::VERBOSITY_DEBUG, ]; public function __construct( protected InputInterface $input, protected OutputInterface $output, - protected HelperSet $helperSet + protected HelperSet $helperSet, ) {} public function enableDebugging(float $startTime): void @@ -55,22 +56,6 @@ public function enableDebugging(float $startTime): void $this->startTime = $startTime; } - /* ========================================================= - TTY detection - ========================================================= */ - - /** - * Returns true only when STDIN is a real TTY. - * Prevents Terminal::enableRaw() from conflicting with piped/memory streams. - */ - private function isStdinTty(): bool - { - return $this->isInteractive() - && !$this->input instanceof \Symfony\Component\Console\Input\StringInput - && function_exists('posix_isatty') - && @posix_isatty(STDIN); - } - /* ========================================================= INTERACTIVE — ask (plain text) ========================================================= */ @@ -97,7 +82,7 @@ public function ask(string $question, mixed $default = null): mixed return $this->getQuestionHelper()->ask( $this->input, $this->output, - new Question($question, $default) + new Question($question, $default), ); } @@ -121,7 +106,7 @@ public function askConfirmation(string $question, bool $default = true): bool return (bool) $this->getQuestionHelper()->ask( $this->input, $this->output, - new ConfirmationQuestion($question, $default) + new ConfirmationQuestion($question, $default), ); } @@ -139,14 +124,15 @@ public function askConfirmation(string $question, bool $default = true): bool public function askAndValidate( string $question, callable $validator, - ?int $attempts = null, - mixed $default = null + int|null $attempts = null, + mixed $default = null, ): mixed { if ($this->isStdinTty()) { $input = (new TextInput($question)) - ->validate(function (string $value) use ($validator): ?string { + ->validate(static function (string $value) use ($validator): ?string { try { $validator($value); + return null; // null = no error, validation passed } catch (\Throwable $e) { return $e->getMessage(); @@ -181,7 +167,7 @@ public function askAndValidate( * TAB to toggle visibility, live strength meter). * Otherwise: falls back to Symfony Question::setHidden(). */ - public function askAndHideAnswer(string $question): ?string + public function askAndHideAnswer(string $question): string|null { if ($this->isStdinTty()) { return (string) (new Password($question))->showStrength()->run(); @@ -209,15 +195,16 @@ public function askAndHideAnswer(string $question): ?string * Otherwise: falls back to Symfony ChoiceQuestion. * * @param string[] $choices + * * @phpstan-return ($multiselect is true ? list : string|int|bool) */ public function select( string $question, array $choices, mixed $default, - bool|int $attempts = false, + bool|int $attempts = false, string $errorMessage = 'Value "%s" is invalid', - bool $multiselect = false + bool $multiselect = false, ): int|string|array|bool { if ($this->isStdinTty()) { if ($multiselect) { @@ -262,12 +249,74 @@ public function writeErrorRaw(mixed $messages, bool $newline = true, int $verbos $this->doWrite($messages, $newline, true, $verbosity, raw: true); } + /* ========================================================= + OVERWRITE + ========================================================= */ + + public function overwrite(mixed $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void + { + $this->doOverwrite($messages, $newline, $size, false, $verbosity); + } + + public function overwriteError(mixed $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void + { + $this->doOverwrite($messages, $newline, $size, true, $verbosity); + } + + public function getErrorOutput(): OutputInterface + { + return ($this->output instanceof ConsoleOutputInterface) + ? $this->output->getErrorOutput() + : $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(); + } + + /* ========================================================= + TTY detection + ========================================================= */ + + /** + * Returns true only when STDIN is a real TTY. + * Prevents Terminal::enableRaw() from conflicting with piped/memory streams. + */ + private function isStdinTty(): bool + { + return $this->isInteractive() + && !$this->input instanceof \Symfony\Component\Console\Input\StringInput + && function_exists('posix_isatty') + && @posix_isatty(STDIN); + } + private function doWrite( mixed $messages, bool $newline, bool $stderr, int $verbosity, - bool $raw = false + bool $raw = false, ): void { $sfVerbosity = $this->verbosityMap[$verbosity] ?? OutputInterface::VERBOSITY_NORMAL; @@ -278,10 +327,10 @@ private function doWrite( $messages = (array) $messages; if ($this->startTime !== null) { - $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); + $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(static fn($m) => $prefix . $m, $messages); } $target = $stderr ? $this->getErrorOutput() : $this->output; @@ -295,26 +344,12 @@ private function doWrite( } } - /* ========================================================= - OVERWRITE - ========================================================= */ - - 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(mixed $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void - { - $this->doOverwrite($messages, $newline, $size, true, $verbosity); - } - private function doOverwrite( mixed $messages, bool $newline, - ?int $size, + int|null $size, bool $stderr, - int $verbosity + int $verbosity, ): void { $target = $stderr ? $this->getErrorOutput() : $this->output; $target->write("\r\033[K", false, OutputInterface::OUTPUT_RAW); @@ -335,32 +370,4 @@ private function getQuestionHelper(): QuestionHelper return $helper; } - - public function getErrorOutput(): OutputInterface - { - return ($this->output instanceof ConsoleOutputInterface) - ? $this->output->getErrorOutput() - : $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(); - } } diff --git a/src/Depends/Colors.php b/src/Depends/Colors.php index b0f630f..257cc9a 100644 --- a/src/Depends/Colors.php +++ b/src/Depends/Colors.php @@ -9,7 +9,38 @@ */ final class Colors { - private static ?bool $enabled = null; + /* --- Constants --- */ + public const RESET = "\033[0m"; + + public const BOLD = "\033[1m"; + + public const DIM = "\033[2m"; + + public const ITALIC = "\033[3m"; + + public const UNDERLINE = "\033[4m"; + + public const RED = "\033[31m"; + + public const GREEN = "\033[32m"; + + public const YELLOW = "\033[33m"; + + public const BLUE = "\033[34m"; + + public const MAGENTA = "\033[35m"; + + public const CYAN = "\033[36m"; + + public const WHITE = "\033[37m"; + + public const GRAY = "\033[90m"; + + public const BLACK = "\033[30m"; + + public const BG_CYAN = "\033[46m"; + + private static bool|null $enabled = null; /** * Determine if the current environment supports/allows colors. @@ -44,29 +75,12 @@ public static function enable(): void { self::$enabled = true; } + public static function disable(): void { self::$enabled = false; } - /* --- Constants --- */ - public const RESET = "\033[0m"; - public const BOLD = "\033[1m"; - public const DIM = "\033[2m"; - public const ITALIC = "\033[3m"; - public const UNDERLINE = "\033[4m"; - - public const RED = "\033[31m"; - public const GREEN = "\033[32m"; - public const YELLOW = "\033[33m"; - public const BLUE = "\033[34m"; - public const MAGENTA = "\033[35m"; - public const CYAN = "\033[36m"; - public const WHITE = "\033[37m"; - public const GRAY = "\033[90m"; - public const BLACK = "\033[30m"; - public const BG_CYAN = "\033[46m"; - /* --- Core --- */ public static function wrap(string $text, string|array $styles): string @@ -76,6 +90,7 @@ public static function wrap(string $text, string|array $styles): string } $prefix = is_array($styles) ? implode('', $styles) : $styles; + return $prefix . $text . self::RESET; } @@ -85,8 +100,8 @@ public static function wrap(string $text, string|array $styles): string */ public static function hex(string $hex, string $text = ''): string { - $hex = ltrim($hex, '#'); - if (strlen($hex) === 3) { + $hex = mb_ltrim($hex, '#'); + if (mb_strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } diff --git a/src/Depends/Fuzzy.php b/src/Depends/Fuzzy.php index a912980..4fa190b 100644 --- a/src/Depends/Fuzzy.php +++ b/src/Depends/Fuzzy.php @@ -28,8 +28,8 @@ public static function filter(array $items, string $query, int $minScore = 0): a arsort($scored, SORT_NUMERIC); return array_map( - fn($i) => $items[$i], - array_keys($scored) + static fn($i) => $items[$i], + array_keys($scored), ); } @@ -38,8 +38,8 @@ public static function filter(array $items, string $query, int $minScore = 0): a */ public static function score(string $query, string $value, int $tieBreaker = 0): int { - $query = mb_strtolower(trim($query)); - $value = mb_strtolower(trim($value)); + $query = mb_strtolower(mb_trim($query)); + $value = mb_strtolower(mb_trim($value)); if ($query === '') { return 0; @@ -116,6 +116,7 @@ private static function tokenScore(array $queryTokens, array $valueTokens): int } } } + return $score; } } diff --git a/src/Depends/Input.php b/src/Depends/Input.php index a586c01..9048f6f 100644 --- a/src/Depends/Input.php +++ b/src/Depends/Input.php @@ -4,23 +4,21 @@ namespace AlfacodeTeam\PhpIoCli\Depends; -use Closure; - final class Input { - /** @var array> */ + /** @var array> */ private array $bindings = []; - /** @var Closure|null */ - private ?Closure $fallback = null; + /** */ + private \Closure|null $fallback = null; /** * Bind a handler to one or multiple keys. * * @param string|array $keys - * @param Closure(State, string): (void|bool) $handler + * @param \Closure(State, string): (void|bool) $handler */ - public function bind(string|array $keys, Closure $handler): self + public function bind(string|array $keys, \Closure $handler): self { foreach ((array) $keys as $key) { $this->bindings[$key][] = $handler; @@ -32,11 +30,12 @@ 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 + * @param \Closure(State, string): void $handler */ - public function fallback(Closure $handler): self + public function fallback(\Closure $handler): self { $this->fallback = $handler; + return $this; } @@ -48,6 +47,7 @@ public function unbind(string|array $keys): self foreach ((array) $keys as $key) { unset($this->bindings[$key]); } + return $this; } @@ -69,6 +69,7 @@ public function handle(string $key, State $state): void break; } } + return; } diff --git a/src/Depends/Key.php b/src/Depends/Key.php index 9a0fd46..3b51060 100644 --- a/src/Depends/Key.php +++ b/src/Depends/Key.php @@ -10,23 +10,34 @@ final class Key { // Navigation - public const UP = "\e[A"; - public const DOWN = "\e[B"; + public const UP = "\e[A"; + + public const DOWN = "\e[B"; + public const RIGHT = "\e[C"; - public const LEFT = "\e[D"; - public const HOME = "\e[H"; - public const END = "\e[F"; + + public const LEFT = "\e[D"; + + public const HOME = "\e[H"; + + public const END = "\e[F"; // Actions - public const ENTER = "\n"; - public const RETURN = "\r"; - public const TAB = "\t"; - public const ESC = "\e"; + public const ENTER = "\n"; + + public const RETURN = "\r"; + + public const TAB = "\t"; + + public const ESC = "\e"; + public const BACKSPACE = "\x7f"; - public const DELETE = "\e[3~"; + + public const DELETE = "\e[3~"; // Common Ctrl sequences public const CTRL_C = "\x03"; + public const CTRL_D = "\x04"; /** @@ -35,20 +46,20 @@ final class Key public static function normalize(string $key): string { return match ($key) { - self::UP => 'UP', - self::DOWN => 'DOWN', - self::RIGHT => 'RIGHT', - self::LEFT => 'LEFT', - self::HOME => 'HOME', - self::END => 'END', + self::UP => 'UP', + self::DOWN => 'DOWN', + self::RIGHT => 'RIGHT', + self::LEFT => 'LEFT', + self::HOME => 'HOME', + self::END => 'END', self::ENTER, self::RETURN => 'ENTER', - self::TAB => 'TAB', - self::ESC => 'ESC', + self::TAB => 'TAB', + self::ESC => 'ESC', self::BACKSPACE, "\x08" => 'BACKSPACE', - self::DELETE => 'DELETE', - self::CTRL_C => 'CTRL_C', - self::CTRL_D => 'CTRL_D', - default => $key + self::DELETE => 'DELETE', + self::CTRL_C => 'CTRL_C', + self::CTRL_D => 'CTRL_D', + default => $key, }; } @@ -64,6 +75,7 @@ public static function isPrintable(string $key): bool } $ord = ord($key); + return $ord >= 32 && $ord < 127; } } diff --git a/src/Depends/RenderContext.php b/src/Depends/RenderContext.php index 7ff6133..b1c52a3 100644 --- a/src/Depends/RenderContext.php +++ b/src/Depends/RenderContext.php @@ -25,6 +25,7 @@ public function __construct( public function markDirty(): self { $this->dirty = true; + return $this; } @@ -34,6 +35,7 @@ public function markDirty(): self public function clear(): self { $this->dirty = false; + return $this; } @@ -59,7 +61,7 @@ public function refreshDimensions(): self } else { $stty = shell_exec('stty size 2>/dev/null'); if ($stty) { - [$rows, $cols] = explode(' ', trim($stty)); + [$rows, $cols] = explode(' ', mb_trim($stty)); $this->height = (int) $rows; $this->width = (int) $cols; } @@ -74,6 +76,7 @@ public function refreshDimensions(): self public function set(string $key, mixed $value): self { $this->meta[$key] = $value; + return $this; } diff --git a/src/Depends/Renderer.php b/src/Depends/Renderer.php index f2ceb8c..354ace5 100644 --- a/src/Depends/Renderer.php +++ b/src/Depends/Renderer.php @@ -13,7 +13,9 @@ final class Renderer implements IRenderer { private int $lastLines = 0; + private Spinner $spinner; + private bool $cursorHidden = false; public function __construct() @@ -21,6 +23,11 @@ public function __construct() $this->spinner = new Spinner(); } + public function __destruct() + { + echo "\033[?25h"; + } + /* ========================================================= IRenderer — lifecycle hooks ========================================================= */ @@ -75,7 +82,7 @@ public function renderState(State $state): void public function key(): string { - return static::class; + return self::class; } /* ========================================================= @@ -99,6 +106,7 @@ private function paint(State $state): void $this->spinner->start(); $lines[] = Colors::wrap(' ' . $this->spinner->tick() . ' Loading...', Colors::CYAN); $this->display($lines); + return; } @@ -110,6 +118,7 @@ private function paint(State $state): void if (empty($filtered)) { $lines[] = Colors::wrap(' ✘ No results found.', Colors::RED); $this->display($lines); + return; } @@ -118,19 +127,19 @@ private function paint(State $state): void ===================================================== */ $windowSize = 10; $totalItems = count($filtered); - $index = (int) ($state->index ?? 0); + $index = (int) ($state->index ?? 0); $start = (int) max(0, min($index - (int) floor($windowSize / 2), $totalItems - $windowSize)); - $end = (int) min($totalItems, $start + $windowSize); + $end = (int) min($totalItems, $start + $windowSize); $lines[] = ($start > 0) ? Colors::wrap(' ↑ more items', Colors::GRAY) : ' '; foreach (array_values(array_slice($filtered, $start, $windowSize)) as $i => $label) { - $realIndex = $start + $i; - $isActive = $realIndex === $index; + $realIndex = $start + $i; + $isActive = $realIndex === $index; $isSelected = in_array($label, (array) ($state->selected ?? []), true); - $pointer = $isActive ? Colors::wrap('›', Colors::GREEN) : ' '; + $pointer = $isActive ? Colors::wrap('›', Colors::GREEN) : ' '; $checkbox = ''; if ($state->multi ?? false) { @@ -152,7 +161,7 @@ private function paint(State $state): void // FOOTER $lines[] = ''; - $help = ($state->multi ?? false) + $help = ($state->multi ?? false) ? '↑↓ nav • space toggle • enter confirm' : '↑↓ nav • enter confirm'; $lines[] = Colors::wrap($help, Colors::GRAY); @@ -170,9 +179,4 @@ private function display(array $lines): void echo $output; $this->lastLines = count($lines); } - - public function __destruct() - { - echo "\033[?25h"; - } } diff --git a/src/Depends/Shell.php b/src/Depends/Shell.php index 4f8b3eb..54ab99c 100644 --- a/src/Depends/Shell.php +++ b/src/Depends/Shell.php @@ -6,7 +6,7 @@ /** * Enterprise Shell Wrapper - * + * * v1-> Features * ───────── * • Streams stdout AND stderr simultaneously with stream_select() so neither @@ -15,12 +15,12 @@ * caller can animate a SpinnerComponent with the most recent output line. * • Merges caller-supplied env vars over the current process environment. * • Returns an immutable ShellResult value object. - * + * * Features: * - Deadlock-free simultaneous stdout/stderr streaming. * - Non-blocking stream_select for high-performance UI ticks. * - Guaranteed capture of partial trailing lines (fixes test failures). - * + * * Usage with SpinnerComponent * ─────────────────────────── * $spin = new SpinnerComponent('Running git …'); @@ -53,9 +53,9 @@ private function __construct() {} */ public static function run( string $command, - ?callable $tick = null, - array $env = [], - string $cwd = '', + callable|null $tick = null, + array $env = [], + string $cwd = '', ): ShellResult { $descriptors = [ 0 => ['pipe', 'r'], // stdin @@ -64,14 +64,14 @@ public static function run( ]; // Ensure environment variables are preserved and merged - $fullEnv = array_merge((array)(getenv() ?: []), $env); + $fullEnv = array_merge((array) (getenv() ?: []), $env); $process = proc_open( $command, $descriptors, $pipes, $cwd !== '' ? $cwd : null, - $fullEnv + $fullEnv, ); if (!is_resource($process)) { @@ -120,18 +120,18 @@ public static function run( } // --- Process complete lines for STDOUT --- - while (($pos = strpos($stdoutBuf, "\n")) !== false) { - $line = rtrim(substr($stdoutBuf, 0, $pos)); - $stdoutBuf = substr($stdoutBuf, $pos + 1); + while (($pos = mb_strpos($stdoutBuf, "\n")) !== false) { + $line = mb_rtrim(mb_substr($stdoutBuf, 0, $pos)); + $stdoutBuf = mb_substr($stdoutBuf, $pos + 1); $stdout[] = $line; $lastLine = $line; $lastIsStderr = false; } // --- Process complete lines for STDERR --- - while (($pos = strpos($stderrBuf, "\n")) !== false) { - $line = rtrim(substr($stderrBuf, 0, $pos)); - $stderrBuf = substr($stderrBuf, $pos + 1); + while (($pos = mb_strpos($stderrBuf, "\n")) !== false) { + $line = mb_rtrim(mb_substr($stderrBuf, 0, $pos)); + $stderrBuf = mb_substr($stderrBuf, $pos + 1); $stderr[] = $line; $lastLine = $line; $lastIsStderr = true; @@ -150,12 +150,12 @@ public static function run( // --- Final Flush --- // Capture any remaining data that didn't end with a newline (Critical for tests!) - if (($trimmed = rtrim($stdoutBuf)) !== '') { + if (($trimmed = mb_rtrim($stdoutBuf)) !== '') { $stdout[] = $trimmed; $lastLine = $trimmed; $lastIsStderr = false; } - if (($trimmed = rtrim($stderrBuf)) !== '') { + if (($trimmed = mb_rtrim($stderrBuf)) !== '') { $stderr[] = $trimmed; $lastLine = $trimmed; $lastIsStderr = true; @@ -177,9 +177,10 @@ public static function run( /** * Run and return trimmed stdout. Returns null on failure. */ - public static function capture(string $command, string $cwd = ''): ?string + public static function capture(string $command, string $cwd = ''): string|null { $result = self::run($command, cwd: $cwd); - return $result->ok() ? trim($result->output()) : null; + + return $result->ok() ? mb_trim($result->output()) : null; } -} \ No newline at end of file +} diff --git a/src/Depends/ShellResult.php b/src/Depends/ShellResult.php index 4e34b8c..6cd7210 100644 --- a/src/Depends/ShellResult.php +++ b/src/Depends/ShellResult.php @@ -20,6 +20,7 @@ public function ok(): bool { return $this->exitCode === 0; } + public function failed(): bool { return $this->exitCode !== 0; @@ -47,7 +48,7 @@ public function meaningfulErrors(): array { return array_values(array_filter( $this->stderr, - fn(string $l) => trim($l) !== '' + static fn(string $l) => mb_trim($l) !== '', )); } } diff --git a/src/Depends/Spinner.php b/src/Depends/Spinner.php index 45a8293..d286c65 100644 --- a/src/Depends/Spinner.php +++ b/src/Depends/Spinner.php @@ -7,15 +7,20 @@ final class Spinner { private array $frames; + private int $index = 0; + private float $interval; + private float $lastTick = 0.0; + private bool $running = false; + private string $currentFrame = ''; public function __construct( - ?array $frames = null, - float $interval = 0.1 + array|null $frames = null, + float $interval = 0.1, ) { $this->frames = $frames ?? SpinnerFrames::default(); $this->interval = $interval; diff --git a/src/Depends/SpinnerFrames.php b/src/Depends/SpinnerFrames.php index 03729fb..00defbd 100644 --- a/src/Depends/SpinnerFrames.php +++ b/src/Depends/SpinnerFrames.php @@ -15,10 +15,10 @@ final class SpinnerFrames public static function get(string $name = 'dots'): array { return match ($name) { - 'bars' => [' ', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'], - 'line' => ['-', '\\', '|', '/'], - 'pulse' => ['░', '▒', '▓', '█', '▓', '▒'], - 'arc' => ['◜', '◠', '◝', '◞', '◡', '◟'], + 'bars' => [' ', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'], + 'line' => ['-', '\\', '|', '/'], + 'pulse' => ['░', '▒', '▓', '█', '▓', '▒'], + 'arc' => ['◜', '◠', '◝', '◞', '◡', '◟'], 'bounce' => [ '(● )', '( ● )', '( ● )', '( ● )', '( ● )', '( ● )', '( ● )', '( ● )', @@ -26,7 +26,7 @@ public static function get(string $name = 'dots'): array '( ● )', '( ● )', '( ● )', '( ● )', '( ● )', '( ● )', '( ● )', '( ● )', ], - default => ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + default => ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], }; } diff --git a/src/Depends/State.php b/src/Depends/State.php index b08c5a2..d6f132c 100644 --- a/src/Depends/State.php +++ b/src/Depends/State.php @@ -4,8 +4,6 @@ namespace AlfacodeTeam\PhpIoCli\Depends; -use Closure; - /** * Reactive State Container. * Handles data storage, property watching, and CLI-specific state mutations. @@ -15,7 +13,7 @@ final class State /** @var array */ private array $data = []; - /** @var array> */ + /** @var array> */ private array $watchers = []; public function __construct(array $initialData = []) @@ -94,7 +92,7 @@ public function decrement(string $key): void public function toggle(string $key, mixed $value): void { $current = (array) $this->get($key, []); - $index = array_search($value, $current, true); + $index = array_search($value, $current, true); if ($index === false) { $current[] = $value; @@ -122,18 +120,19 @@ public function filtered(): array return array_filter( $items, - fn($item) => mb_stripos((string) $item, $search) !== false + static fn($item) => mb_stripos((string) $item, $search) !== false, ); } /* --- Reactivity --- */ /** - * @param Closure(mixed $new, mixed $old, self $state): void $callback + * @param \Closure(mixed $new, mixed $old, self $state): void $callback */ - public function watch(string $key, Closure $callback): self + public function watch(string $key, \Closure $callback): self { $this->watchers[$key][] = $callback; + return $this; } diff --git a/src/Depends/Terminal.php b/src/Depends/Terminal.php index 7428f58..08504fb 100644 --- a/src/Depends/Terminal.php +++ b/src/Depends/Terminal.php @@ -11,6 +11,7 @@ final class Terminal { private static bool $rawEnabled = false; + private static string|bool|null $originalMode = null; public static function isWindows(): bool @@ -43,8 +44,8 @@ public static function enableRaw(): void // Signal Handling: Restore terminal if user hits Ctrl+C if (function_exists('pcntl_signal')) { pcntl_async_signals(true); - pcntl_signal(SIGINT, fn() => self::exitGracefully()); - pcntl_signal(SIGTERM, fn() => self::exitGracefully()); + pcntl_signal(SIGINT, static fn() => self::exitGracefully()); + pcntl_signal(SIGTERM, static fn() => self::exitGracefully()); } } @@ -52,13 +53,6 @@ public static function enableRaw(): void register_shutdown_function([self::class, 'disableRaw']); } - private static function exitGracefully(): void - { - self::disableRaw(); - echo PHP_EOL . Colors::error(" Process interrupted.") . PHP_EOL; - exit(1); - } - public static function disableRaw(): void { if (!self::$rawEnabled) { @@ -91,29 +85,6 @@ public static function readKey(): string return (string) $char; } - /** - * Fixes the "Headache": - * Uses a tiny 10ms settle-time to ensure multi-byte keys (Arrows, Home) - * are captured as a single string instead of being fragmented. - */ - private static function readEscapeSequence(string $first): string - { - $sequence = $first; - stream_set_blocking(STDIN, false); - - $start = microtime(true); - while ((microtime(true) - $start) < 0.01) { // 10ms window - $char = fgetc(STDIN); - if ($char !== false) { - $sequence .= $char; - $start = microtime(true); // reset window if we're still getting bytes - } - } - - stream_set_blocking(STDIN, true); - return $sequence; - } - /* ========================================================= OUTPUT HELPERS ========================================================= */ @@ -137,4 +108,35 @@ public static function showCursor(): void { echo "\033[?25h"; } + + private static function exitGracefully(): void + { + self::disableRaw(); + echo PHP_EOL . Colors::error(' Process interrupted.') . PHP_EOL; + exit(1); + } + + /** + * Fixes the "Headache": + * Uses a tiny 10ms settle-time to ensure multi-byte keys (Arrows, Home) + * are captured as a single string instead of being fragmented. + */ + private static function readEscapeSequence(string $first): string + { + $sequence = $first; + stream_set_blocking(STDIN, false); + + $start = microtime(true); + while ((microtime(true) - $start) < 0.01) { // 10ms window + $char = fgetc(STDIN); + if ($char !== false) { + $sequence .= $char; + $start = microtime(true); // reset window if we're still getting bytes + } + } + + stream_set_blocking(STDIN, true); + + return $sequence; + } } diff --git a/src/Hooks.php b/src/Hooks.php index b874fe1..6d34efa 100644 --- a/src/Hooks.php +++ b/src/Hooks.php @@ -4,11 +4,9 @@ namespace AlfacodeTeam\PhpIoCli; -use Closure; - final class Hooks { - /** @var array> */ + /** @var array> */ private array $listeners = []; /** @@ -16,7 +14,8 @@ final class Hooks */ public function on(string $event, callable $listener): self { - $this->listeners[$event][] = Closure::fromCallable($listener); + $this->listeners[$event][] = \Closure::fromCallable($listener); + return $this; } @@ -27,6 +26,7 @@ public function once(string $event, callable $listener): self { $wrapper = function (mixed $payload, string $event, Hooks $hooks) use ($listener, &$wrapper) { $this->off($event, $wrapper); + return $listener($payload, $event, $hooks); }; @@ -36,7 +36,7 @@ public function once(string $event, callable $listener): self /** * Unsubscribe from an event. */ - public function off(string $event, ?callable $listener = null): self + public function off(string $event, callable|null $listener = null): self { if (!isset($this->listeners[$event])) { return $this; @@ -44,12 +44,13 @@ public function off(string $event, ?callable $listener = null): self if ($listener === null) { unset($this->listeners[$event]); + return $this; } $this->listeners[$event] = array_values(array_filter( $this->listeners[$event], - fn($l) => $l !== $listener + static fn($l) => $l !== $listener, )); return $this; diff --git a/src/IOInterface.php b/src/IOInterface.php index 07ac9f8..f6c1701 100644 --- a/src/IOInterface.php +++ b/src/IOInterface.php @@ -12,26 +12,34 @@ interface IOInterface extends LoggerInterface { public const QUIET = 1; + public const NORMAL = 2; + public const VERBOSE = 4; + public const VERY_VERBOSE = 8; + public const DEBUG = 16; public function isInteractive(): bool; + public function isVerbose(): bool; + public function isVeryVerbose(): bool; + public function isDebug(): bool; + public function isDecorated(): bool; /** * @param string|string[] $messages */ - public function write($messages, bool $newline = true, int $verbosity = self::NORMAL): void; + public function write(string|array $messages, bool $newline = true, int $verbosity = self::NORMAL): void; /** * @param string|string[] $messages */ - public function writeError($messages, bool $newline = true, int $verbosity = self::NORMAL): void; + public function writeError(string|array $messages, bool $newline = true, int $verbosity = self::NORMAL): void; // FIX: $messages was untyped (PHPStan error). Typed as string|array to match write/writeError. public function writeRaw(string|array $messages, bool $newline = true, int $verbosity = self::NORMAL): void; @@ -42,12 +50,12 @@ public function writeErrorRaw(string|array $messages, bool $newline = true, int /** * @param string|string[] $messages */ - public function overwrite($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void; + public function overwrite(string|array $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void; /** * @param string|string[] $messages */ - public function overwriteError($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void; + public function overwriteError(string|array $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void; /* ========================================================= INTERACTIVE METHODS @@ -57,12 +65,13 @@ public function ask(string $question, mixed $default = null): mixed; public function askConfirmation(string $question, bool $default = true): bool; - public function askAndValidate(string $question, callable $validator, ?int $attempts = null, mixed $default = null): mixed; + public function askAndValidate(string $question, callable $validator, int|null $attempts = null, mixed $default = null): mixed; - public function askAndHideAnswer(string $question): ?string; + public function askAndHideAnswer(string $question): string|null; /** * @param string[] $choices + * * @phpstan-return ($multiselect is true ? list : string|int|bool) */ public function select( @@ -71,6 +80,6 @@ public function select( mixed $default, bool|int $attempts = false, string $errorMessage = 'Value "%s" is invalid', - bool $multiselect = false + bool $multiselect = false, ): int|string|array|bool; } diff --git a/src/NullIO.php b/src/NullIO.php index 25c4836..27bfd8c 100644 --- a/src/NullIO.php +++ b/src/NullIO.php @@ -19,18 +19,22 @@ public function isInteractive(): bool { return false; } + public function isVerbose(): bool { return false; } + public function isVeryVerbose(): bool { return false; } + public function isDebug(): bool { return false; } + public function isDecorated(): bool { return false; @@ -50,9 +54,9 @@ public function writeRaw(string|array $messages, bool $newline = true, int $verb // FIX: same as above public function writeErrorRaw(string|array $messages, bool $newline = true, int $verbosity = self::NORMAL): void {} - public function overwrite($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void {} + public function overwrite($messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void {} - public function overwriteError($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void {} + public function overwriteError($messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void {} /* ========================================================= Interactive — all return defaults @@ -71,13 +75,13 @@ public function askConfirmation(string $question, bool $default = true): bool public function askAndValidate( string $question, callable $validator, - ?int $attempts = null, - mixed $default = null + int|null $attempts = null, + mixed $default = null, ): mixed { return $default; } - public function askAndHideAnswer(string $question): ?string + public function askAndHideAnswer(string $question): string|null { return null; } @@ -86,9 +90,9 @@ public function select( string $question, array $choices, mixed $default, - bool|int $attempts = false, + bool|int $attempts = false, string $errorMessage = 'Value "%s" is invalid', - bool $multiselect = false + bool $multiselect = false, ): int|string|array|bool { return $default; } diff --git a/src/Silencer.php b/src/Silencer.php index ccdb8f8..6b0ff11 100644 --- a/src/Silencer.php +++ b/src/Silencer.php @@ -24,15 +24,16 @@ class Silencer /** * @var int[] Unpop stack */ - private static $stack = []; + private static array $stack = []; /** * Suppresses given mask or errors. * * @param int|null $mask Error levels to suppress, default value NULL indicates all warnings and below. + * * @return int The old error reporting level. */ - public static function suppress(?int $mask = null): int + public static function suppress(int|null $mask = null): int { if (!isset($mask)) { $mask = E_WARNING | E_NOTICE | E_USER_WARNING | E_USER_NOTICE | E_DEPRECATED | E_USER_DEPRECATED; @@ -59,10 +60,12 @@ public static function restore(): void * * @param callable $callable Function to execute. * @param mixed $parameters Function to execute. + * * @throws \Exception Any exceptions from the callback are rethrown. + * * @return mixed Return value of the callback. */ - public static function call(callable $callable, ...$parameters) + public static function call(callable $callable, ...$parameters): mixed { try { self::suppress(); @@ -73,6 +76,7 @@ public static function call(callable $callable, ...$parameters) } catch (\Exception $e) { // Use a finally block for this when requirements are raised to PHP 5.5 self::restore(); + throw $e; } } diff --git a/tests/Integration/AbstractCommandTest.php b/tests/Integration/AbstractCommandTest.php index 550500a..81fdf0d 100644 --- a/tests/Integration/AbstractCommandTest.php +++ b/tests/Integration/AbstractCommandTest.php @@ -6,8 +6,8 @@ use AlfacodeTeam\PhpIoCli\AbstractCommand; use AlfacodeTeam\PhpIoCli\BufferIO; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -18,7 +18,7 @@ final class EchoCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'echo'; + $this->name = 'echo'; $this->description = 'Echoes back arguments and options'; $this->addArgument('message', 'The message to echo', required: true); @@ -28,12 +28,12 @@ protected function configure(): void protected function handle(): int { - $msg = (string) $this->argument('message'); + $msg = (string) $this->argument('message'); $upper = $this->hasOption('upper'); $times = (int) $this->option('repeat', '1'); if ($upper) { - $msg = strtoupper($msg); + $msg = mb_strtoupper($msg); } for ($i = 0; $i < $times; $i++) { @@ -58,6 +58,7 @@ protected function configure(): void protected function handle(): int { $this->success((string) $this->argument('name')); + return self::SUCCESS; } } @@ -91,13 +92,14 @@ protected function configure(): void protected function handle(): int { $this->error('Explicit failure'); + return self::FAILURE; } } // ── Tests ───────────────────────────────────────────────────────────────────── -#[CoversClass(\AlfacodeTeam\PhpIoCli\AbstractCommand::class)] +#[CoversClass(AbstractCommand::class)] final class AbstractCommandTest extends TestCase { // --------------------------------------------------------------- @@ -106,7 +108,7 @@ final class AbstractCommandTest extends TestCase public function test_command_returns_success_code(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new EchoCommand(); $exit = $cmd->execute(['hello'], $io); @@ -116,7 +118,7 @@ public function test_command_returns_success_code(): void public function test_command_outputs_argument(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new EchoCommand(); $cmd->execute(['hello world'], $io); @@ -130,7 +132,7 @@ public function test_command_outputs_argument(): void public function test_long_flag_option_works(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new EchoCommand(); $cmd->execute(['hello', '--upper'], $io); @@ -140,7 +142,7 @@ public function test_long_flag_option_works(): void public function test_short_flag_option_works(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new EchoCommand(); $cmd->execute(['hello', '-u'], $io); @@ -150,25 +152,25 @@ public function test_short_flag_option_works(): void public function test_option_with_value_via_equals(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new EchoCommand(); $cmd->execute(['hi', '--repeat=3'], $io); $output = $io->getOutput(); // "hi" should appear 3 times - $this->assertSame(3, substr_count($output, 'hi')); + $this->assertSame(3, mb_substr_count($output, 'hi')); } public function test_option_with_value_via_space(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new EchoCommand(); $cmd->execute(['hi', '--repeat', '2'], $io); $output = $io->getOutput(); - $this->assertSame(2, substr_count($output, 'hi')); + $this->assertSame(2, mb_substr_count($output, 'hi')); } // --------------------------------------------------------------- @@ -177,7 +179,7 @@ public function test_option_with_value_via_space(): void public function test_missing_required_argument_returns_invalid(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new RequiredArgCommand(); $exit = $cmd->execute([], $io); @@ -191,7 +193,7 @@ public function test_missing_required_argument_returns_invalid(): void public function test_unhandled_exception_returns_failure(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new FailingCommand(); $exit = $cmd->execute([], $io); @@ -201,7 +203,7 @@ public function test_unhandled_exception_returns_failure(): void public function test_explicit_failure_returns_failure_code(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new ExplicitFailCommand(); $exit = $cmd->execute([], $io); @@ -233,7 +235,7 @@ public function test_get_description_returns_configured_description(): void public function test_print_help_does_not_throw(): void { - $io = new BufferIO(); + $io = new BufferIO(); $cmd = new EchoCommand(); // execute() wires $this->io inside the command; printHelp() uses the diff --git a/tests/Integration/BufferIOTest.php b/tests/Integration/BufferIOTest.php index f9b6a9b..0a15f0d 100644 --- a/tests/Integration/BufferIOTest.php +++ b/tests/Integration/BufferIOTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Integration; use AlfacodeTeam\PhpIoCli\BufferIO; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\BufferIO::class)] +#[CoversClass(BufferIO::class)] final class BufferIOTest extends TestCase { // --------------------------------------------------------------- diff --git a/tests/Integration/BufferIOUserInputsTest.php b/tests/Integration/BufferIOUserInputsTest.php index 5b3bfd9..dcfc022 100644 --- a/tests/Integration/BufferIOUserInputsTest.php +++ b/tests/Integration/BufferIOUserInputsTest.php @@ -6,8 +6,8 @@ use AlfacodeTeam\PhpIoCli\AbstractCommand; use AlfacodeTeam\PhpIoCli\BufferIO; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -16,9 +16,17 @@ */ final class ConfirmCommand extends AbstractCommand { + // Expose the IO so we can call it directly in the fixture + private \AlfacodeTeam\PhpIoCli\IOInterface|null $ioRef = null; + + public function setIORef(\AlfacodeTeam\PhpIoCli\IOInterface $io): void + { + $this->ioRef = $io; + } + protected function configure(): void { - $this->name = 'confirm-cmd'; + $this->name = 'confirm-cmd'; $this->description = 'Asks a yes/no question'; } @@ -35,14 +43,6 @@ protected function handle(): int return self::SUCCESS; } - // Expose the IO so we can call it directly in the fixture - private ?\AlfacodeTeam\PhpIoCli\IOInterface $ioRef = null; - - public function setIORef(\AlfacodeTeam\PhpIoCli\IOInterface $io): void - { - $this->ioRef = $io; - } - private function io(): \AlfacodeTeam\PhpIoCli\IOInterface { // AbstractCommand stores IO internally; we replicate via reflection @@ -54,6 +54,7 @@ private function io(): \AlfacodeTeam\PhpIoCli\IOInterface } $prop = $parent->getProperty('io'); $prop->setAccessible(true); + return $prop->getValue($this); } } @@ -65,7 +66,7 @@ final class SelectCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'select-cmd'; + $this->name = 'select-cmd'; $this->description = 'Asks the user to pick an environment'; } @@ -74,7 +75,7 @@ protected function handle(): int $choice = $this->ioInstance()->select( 'Pick environment', ['production', 'staging', 'development'], - 'staging' + 'staging', ); $this->info("Selected: {$choice}"); @@ -84,13 +85,14 @@ protected function handle(): int private function ioInstance(): \AlfacodeTeam\PhpIoCli\IOInterface { - $ref = new \ReflectionObject($this); + $ref = new \ReflectionObject($this); $parent = $ref->getParentClass(); if ($parent === false) { throw new \RuntimeException('No parent'); } $prop = $parent->getProperty('io'); $prop->setAccessible(true); + return $prop->getValue($this); } } @@ -102,15 +104,15 @@ final class MultiPromptCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'multi-prompt'; + $this->name = 'multi-prompt'; $this->description = 'Multiple prompts in sequence'; } protected function handle(): int { - $io = $this->ioInstance(); + $io = $this->ioInstance(); $name = $io->ask('What is your name?', 'World'); - $ok = $io->askConfirmation("Hello {$name}, continue?", true); + $ok = $io->askConfirmation("Hello {$name}, continue?", true); if ($ok) { $this->info("Hello, {$name}!"); @@ -123,20 +125,21 @@ protected function handle(): int private function ioInstance(): \AlfacodeTeam\PhpIoCli\IOInterface { - $ref = new \ReflectionObject($this); + $ref = new \ReflectionObject($this); $parent = $ref->getParentClass(); if ($parent === false) { throw new \RuntimeException('No parent'); } $prop = $parent->getProperty('io'); $prop->setAccessible(true); + return $prop->getValue($this); } } // ── Tests ───────────────────────────────────────────────────────────────────── -#[CoversClass(\AlfacodeTeam\PhpIoCli\BufferIO::class)] +#[CoversClass(BufferIO::class)] final class BufferIOUserInputsTest extends TestCase { // --------------------------------------------------------------- @@ -148,7 +151,7 @@ public function test_confirm_prompt_with_yes_input(): void $io = new BufferIO(); $io->setUserInputs(['yes']); - $cmd = new ConfirmCommand(); + $cmd = new ConfirmCommand(); $exit = $cmd->execute([], $io); $this->assertSame(AbstractCommand::SUCCESS, $exit); @@ -164,7 +167,7 @@ public function test_confirm_prompt_with_no_input(): void $io = new BufferIO(); $io->setUserInputs(['no']); - $cmd = new ConfirmCommand(); + $cmd = new ConfirmCommand(); $exit = $cmd->execute([], $io); $this->assertSame(AbstractCommand::SUCCESS, $exit); @@ -181,7 +184,7 @@ public function test_select_prompt_with_pre_set_choice(): void // Symfony ChoiceQuestion accepts the option value as input $io->setUserInputs(['staging']); - $cmd = new SelectCommand(); + $cmd = new SelectCommand(); $exit = $cmd->execute([], $io); $this->assertSame(AbstractCommand::SUCCESS, $exit); @@ -197,7 +200,7 @@ public function test_select_prompt_with_numeric_index(): void $io = new BufferIO(); $io->setUserInputs(['1']); // index 1 → 'staging' - $cmd = new SelectCommand(); + $cmd = new SelectCommand(); $exit = $cmd->execute([], $io); $this->assertSame(AbstractCommand::SUCCESS, $exit); @@ -213,7 +216,7 @@ public function test_multiple_prompts_consume_inputs_in_order(): void $io = new BufferIO(); $io->setUserInputs(['Alice', 'yes']); - $cmd = new MultiPromptCommand(); + $cmd = new MultiPromptCommand(); $exit = $cmd->execute([], $io); $this->assertSame(AbstractCommand::SUCCESS, $exit); @@ -225,7 +228,7 @@ public function test_multiple_prompts_with_declined_confirmation(): void $io = new BufferIO(); $io->setUserInputs(['Bob', 'no']); - $cmd = new MultiPromptCommand(); + $cmd = new MultiPromptCommand(); $exit = $cmd->execute([], $io); $this->assertSame(AbstractCommand::SUCCESS, $exit); diff --git a/tests/Integration/CLIApplicationTest.php b/tests/Integration/CLIApplicationTest.php index 41a4307..1c0f431 100644 --- a/tests/Integration/CLIApplicationTest.php +++ b/tests/Integration/CLIApplicationTest.php @@ -7,8 +7,8 @@ use AlfacodeTeam\PhpIoCli\AbstractCommand; use AlfacodeTeam\PhpIoCli\BufferIO; use AlfacodeTeam\PhpIoCli\CLIApplication; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -16,13 +16,14 @@ final class PingCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'ping'; + $this->name = 'ping'; $this->description = 'Returns pong'; } protected function handle(): int { $this->info('pong'); + return self::SUCCESS; } } @@ -31,7 +32,7 @@ final class GreetCommand extends AbstractCommand { protected function configure(): void { - $this->name = 'greet'; + $this->name = 'greet'; $this->description = 'Greet a user'; $this->addArgument('name', 'User name', required: true); } @@ -39,31 +40,23 @@ protected function configure(): void protected function handle(): int { $this->info('Hello, ' . $this->argument('name') . '!'); + return self::SUCCESS; } } // ── Tests ───────────────────────────────────────────────────────────────────── -#[CoversClass(\AlfacodeTeam\PhpIoCli\CLIApplication::class)] +#[CoversClass(CLIApplication::class)] final class CLIApplicationTest extends TestCase { - private function makeApp(): CLIApplication - { - $io = new BufferIO(); - - return (new CLIApplication('TestApp', '1.0.0')) - ->withIO($io) - ->add(new PingCommand(), new GreetCommand()); - } - // --------------------------------------------------------------- // Basic dispatch // --------------------------------------------------------------- public function test_runs_matching_command(): void { - $io = new BufferIO(); + $io = new BufferIO(); $app = (new CLIApplication('TestApp', '1.0.0')) ->withIO($io) ->add(new PingCommand()); @@ -76,7 +69,7 @@ public function test_runs_matching_command(): void public function test_command_with_argument(): void { - $io = new BufferIO(); + $io = new BufferIO(); $app = (new CLIApplication('TestApp', '1.0.0')) ->withIO($io) ->add(new GreetCommand()); @@ -92,7 +85,7 @@ public function test_command_with_argument(): void public function test_version_command_outputs_name_and_version(): void { - $io = new BufferIO(); + $io = new BufferIO(); $app = (new CLIApplication('MyApp', '2.5.0'))->withIO($io); $app->run(['version']); @@ -104,7 +97,7 @@ public function test_version_command_outputs_name_and_version(): void public function test_list_command_shows_registered_commands(): void { - $io = new BufferIO(); + $io = new BufferIO(); $app = (new CLIApplication('TestApp', '1.0.0')) ->withIO($io) ->add(new PingCommand(), new GreetCommand()); @@ -118,7 +111,7 @@ public function test_list_command_shows_registered_commands(): void public function test_bare_invocation_shows_list(): void { - $io = new BufferIO(); + $io = new BufferIO(); $app = (new CLIApplication('TestApp', '1.0.0')) ->withIO($io) ->add(new PingCommand()); @@ -135,7 +128,7 @@ public function test_bare_invocation_shows_list(): void public function test_unknown_command_returns_invalid(): void { - $io = new BufferIO(); + $io = new BufferIO(); $app = (new CLIApplication('TestApp', '1.0.0'))->withIO($io); $exit = $app->run(['nonexistent']); @@ -176,13 +169,13 @@ public function test_get_throws_for_unknown_command(): void public function test_all_returns_registered_commands_sorted(): void { - $app = $this->makeApp(); + $app = $this->makeApp(); $keys = array_keys($app->all()); // Expect alphabetical order $this->assertContains('greet', $keys); $this->assertContains('ping', $keys); - $this->assertLessThan(array_search('ping', $keys), array_search('greet', $keys)); + $this->assertLessThan(array_search('ping', $keys, true), array_search('greet', $keys, true)); } // --------------------------------------------------------------- @@ -199,6 +192,7 @@ protected function configure(): void { $this->name = 'boom'; } + protected function handle(): int { throw new \RuntimeException('Boom!'); @@ -215,4 +209,13 @@ protected function handle(): int $app->run(['boom']); } + + private function makeApp(): CLIApplication + { + $io = new BufferIO(); + + return (new CLIApplication('TestApp', '1.0.0')) + ->withIO($io) + ->add(new PingCommand(), new GreetCommand()); + } } diff --git a/tests/Integration/ShellTest.php b/tests/Integration/ShellTest.php index 88937a8..08b191b 100644 --- a/tests/Integration/ShellTest.php +++ b/tests/Integration/ShellTest.php @@ -6,16 +6,15 @@ use AlfacodeTeam\PhpIoCli\Depends\Shell; use AlfacodeTeam\PhpIoCli\Depends\ShellResult; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; /** - * * All commands used here are safe, cross-platform, read-only operations. * We favour `php -r` and `printf` so the suite passes on both Unix and * Linux-based CI without relying on shell builtins that may differ. */ -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Shell::class)] +#[CoversClass(Shell::class)] final class ShellTest extends TestCase { // --------------------------------------------------------------- @@ -94,9 +93,9 @@ public function test_run_tick_callback_is_invoked(): void Shell::run( 'php -r "echo \"tick test\";"', - tick: function (string $lastLine, bool $isStderr) use (&$ticked): void { + tick: static function (string $lastLine, bool $isStderr) use (&$ticked): void { $ticked = true; - } + }, ); $this->assertTrue($ticked, 'tick callback must be called at least once'); @@ -108,11 +107,11 @@ public function test_run_tick_callback_receives_last_line(): void Shell::run( 'php -r "echo \"abc\ndef\";"', - tick: function (string $lastLine) use (&$receivedLines): void { + tick: static function (string $lastLine) use (&$receivedLines): void { if ($lastLine !== '') { $receivedLines[] = $lastLine; } - } + }, ); // At least one of the lines should have been surfaced in the tick @@ -127,7 +126,7 @@ public function test_run_passes_env_variables_to_child(): void { $result = Shell::run( 'php -r "echo getenv(\'MY_TEST_VAR\');"', - env: ['MY_TEST_VAR' => 'hello-from-env'] + env: ['MY_TEST_VAR' => 'hello-from-env'], ); $this->assertTrue($result->ok()); @@ -140,14 +139,14 @@ public function test_run_passes_env_variables_to_child(): void public function test_run_respects_cwd(): void { - $cwd = sys_get_temp_dir(); + $cwd = sys_get_temp_dir(); $result = Shell::run('php -r "echo getcwd();"', cwd: $cwd); $this->assertTrue($result->ok()); // Resolve symlinks to handle /var → /private/var on macOS $this->assertSame( realpath($cwd), - realpath(trim($result->output())) + realpath(mb_trim($result->output())), ); } diff --git a/tests/Unit/AlertTest.php b/tests/Unit/AlertTest.php index 1a81e82..e3c564e 100644 --- a/tests/Unit/AlertTest.php +++ b/tests/Unit/AlertTest.php @@ -6,10 +6,10 @@ use AlfacodeTeam\PhpIoCli\Components\Alert; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Components\Alert::class)] +#[CoversClass(Alert::class)] final class AlertTest extends TestCase { protected function setUp(): void @@ -22,31 +22,20 @@ protected function tearDown(): void Colors::enable(); } - // --------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------- - - private function capture(callable $fn): string - { - ob_start(); - $fn(); - return Colors::strip((string) ob_get_clean()); - } - // --------------------------------------------------------------- // success // --------------------------------------------------------------- public function test_success_contains_title(): void { - $output = $this->capture(fn() => Alert::success('Deployment complete!')); + $output = $this->capture(static fn() => Alert::success('Deployment complete!')); $this->assertStringContainsString('Deployment complete!', $output); } public function test_success_contains_checkmark_icon(): void { - $output = $this->capture(fn() => Alert::success('Done')); + $output = $this->capture(static fn() => Alert::success('Done')); $this->assertStringContainsString('✔', $output); } @@ -54,7 +43,7 @@ public function test_success_contains_checkmark_icon(): void public function test_success_renders_body_lines(): void { $output = $this->capture( - fn() => Alert::success('Deployed!', ['Version: 2.4.1', 'Region: eu-west-1']) + static fn() => Alert::success('Deployed!', ['Version: 2.4.1', 'Region: eu-west-1']), ); $this->assertStringContainsString('Version: 2.4.1', $output); @@ -63,7 +52,7 @@ public function test_success_renders_body_lines(): void public function test_success_renders_unicode_box_borders(): void { - $output = $this->capture(fn() => Alert::success('OK')); + $output = $this->capture(static fn() => Alert::success('OK')); // The alert draws a box with at least one of these border chars $hasBorder = str_contains($output, '┌') || str_contains($output, '─') || str_contains($output, '└'); @@ -76,14 +65,14 @@ public function test_success_renders_unicode_box_borders(): void public function test_error_contains_title(): void { - $output = $this->capture(fn() => Alert::error('Build failed')); + $output = $this->capture(static fn() => Alert::error('Build failed')); $this->assertStringContainsString('Build failed', $output); } public function test_error_contains_x_icon(): void { - $output = $this->capture(fn() => Alert::error('Build failed')); + $output = $this->capture(static fn() => Alert::error('Build failed')); $this->assertStringContainsString('✘', $output); } @@ -91,7 +80,7 @@ public function test_error_contains_x_icon(): void public function test_error_renders_body(): void { $output = $this->capture( - fn() => Alert::error('Build failed', ['Exit code: 1', 'Check logs']) + static fn() => Alert::error('Build failed', ['Exit code: 1', 'Check logs']), ); $this->assertStringContainsString('Exit code: 1', $output); @@ -104,14 +93,14 @@ public function test_error_renders_body(): void public function test_warning_contains_title(): void { - $output = $this->capture(fn() => Alert::warning('API quota at 80%')); + $output = $this->capture(static fn() => Alert::warning('API quota at 80%')); $this->assertStringContainsString('API quota at 80%', $output); } public function test_warning_contains_exclamation_icon(): void { - $output = $this->capture(fn() => Alert::warning('Watch out')); + $output = $this->capture(static fn() => Alert::warning('Watch out')); $this->assertStringContainsString('!', $output); } @@ -119,7 +108,7 @@ public function test_warning_contains_exclamation_icon(): void public function test_warning_renders_body(): void { $output = $this->capture( - fn() => Alert::warning('Low memory', ['Used: 95%', 'Free: 200MB']) + static fn() => Alert::warning('Low memory', ['Used: 95%', 'Free: 200MB']), ); $this->assertStringContainsString('Used: 95%', $output); @@ -132,14 +121,14 @@ public function test_warning_renders_body(): void public function test_info_contains_title(): void { - $output = $this->capture(fn() => Alert::info('New version available: 3.0.0')); + $output = $this->capture(static fn() => Alert::info('New version available: 3.0.0')); $this->assertStringContainsString('New version available: 3.0.0', $output); } public function test_info_contains_i_icon(): void { - $output = $this->capture(fn() => Alert::info('Note')); + $output = $this->capture(static fn() => Alert::info('Note')); $this->assertStringContainsString('i', $output); } @@ -147,7 +136,7 @@ public function test_info_contains_i_icon(): void public function test_info_renders_body(): void { $output = $this->capture( - fn() => Alert::info('Heads up', ['Maintenance tonight 02:00 UTC']) + static fn() => Alert::info('Heads up', ['Maintenance tonight 02:00 UTC']), ); $this->assertStringContainsString('Maintenance tonight 02:00 UTC', $output); @@ -160,7 +149,7 @@ public function test_info_renders_body(): void public function test_body_as_string_renders_correctly(): void { $output = $this->capture( - fn() => Alert::success('Done', 'Single line body') + static fn() => Alert::success('Done', 'Single line body'), ); $this->assertStringContainsString('Single line body', $output); @@ -172,7 +161,7 @@ public function test_body_as_string_renders_correctly(): void public function test_empty_body_renders_without_separator(): void { - $output = $this->capture(fn() => Alert::success('Title only')); + $output = $this->capture(static fn() => Alert::success('Title only')); $this->assertStringContainsString('Title only', $output); // No body separator (├) should appear when body is empty @@ -185,7 +174,7 @@ public function test_empty_body_renders_without_separator(): void public function test_block_contains_uppercased_title(): void { - $output = $this->capture(fn() => Alert::block('critical error')); + $output = $this->capture(static fn() => Alert::block('critical error')); $this->assertStringContainsString('CRITICAL ERROR', $output); } @@ -193,7 +182,7 @@ public function test_block_contains_uppercased_title(): void public function test_block_renders_body_lines(): void { $output = $this->capture( - fn() => Alert::block('Fatal', ['Check /var/log/app.log']) + static fn() => Alert::block('Fatal', ['Check /var/log/app.log']), ); $this->assertStringContainsString('Check /var/log/app.log', $output); @@ -207,7 +196,7 @@ public function test_long_body_line_renders_without_error(): void { $longLine = str_repeat('x', 120); - $output = $this->capture(fn() => Alert::info('Wide box', [$longLine])); + $output = $this->capture(static fn() => Alert::info('Wide box', [$longLine])); $this->assertStringContainsString($longLine, $output); } @@ -220,8 +209,20 @@ public function test_ansi_colored_body_line_is_included(): void { $colored = Colors::wrap('healthy', Colors::GREEN); - $output = $this->capture(fn() => Alert::success('Status', [$colored])); + $output = $this->capture(static fn() => Alert::success('Status', [$colored])); $this->assertStringContainsString('healthy', Colors::strip($output)); } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private function capture(callable $fn): string + { + ob_start(); + $fn(); + + return Colors::strip((string) ob_get_clean()); + } } diff --git a/tests/Unit/ColorsTest.php b/tests/Unit/ColorsTest.php index d02f9f2..ff074ec 100644 --- a/tests/Unit/ColorsTest.php +++ b/tests/Unit/ColorsTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Colors::class)] +#[CoversClass(Colors::class)] final class ColorsTest extends TestCase { protected function setUp(): void @@ -101,7 +101,7 @@ public function test_muted_returns_wrapped_text(): void public function test_strip_removes_ansi_color_codes(): void { - $input = "\033[32mGreen\033[0m"; + $input = "\033[32mGreen\033[0m"; $result = Colors::strip($input); $this->assertSame('Green', $result); @@ -109,7 +109,7 @@ public function test_strip_removes_ansi_color_codes(): void public function test_strip_removes_cursor_sequences(): void { - $input = "\033[2K\rSome text"; + $input = "\033[2K\rSome text"; $result = Colors::strip($input); $this->assertSame('Some text', $result); @@ -117,7 +117,7 @@ public function test_strip_removes_cursor_sequences(): void public function test_strip_removes_carriage_returns(): void { - $input = "line1\rline2"; + $input = "line1\rline2"; $result = Colors::strip($input); $this->assertSame('line1line2', $result); @@ -132,7 +132,7 @@ public function test_strip_leaves_plain_text_unchanged(): void public function test_strip_handles_complex_ansi_string(): void { - $input = Colors::wrap('bold cyan', [Colors::BOLD, Colors::CYAN]); + $input = Colors::wrap('bold cyan', [Colors::BOLD, Colors::CYAN]); $result = Colors::strip($input); $this->assertSame('bold cyan', $result); diff --git a/tests/Unit/FuzzyTest.php b/tests/Unit/FuzzyTest.php index 62bc719..70792f6 100644 --- a/tests/Unit/FuzzyTest.php +++ b/tests/Unit/FuzzyTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\Fuzzy; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Fuzzy::class)] +#[CoversClass(Fuzzy::class)] final class FuzzyTest extends TestCase { // --------------------------------------------------------------- @@ -100,7 +100,7 @@ public function test_score_exact_match_is_10000(): void public function test_score_prefix_is_higher_than_substring(): void { - $prefixScore = Fuzzy::score('php', 'php-framework'); + $prefixScore = Fuzzy::score('php', 'php-framework'); $substringScore = Fuzzy::score('php', 'my-php-app'); $this->assertGreaterThan($substringScore, $prefixScore); @@ -128,7 +128,7 @@ public function test_score_is_case_insensitive(): void public function test_filter_preserves_original_item_casing(): void { - $items = ['Laravel', 'Symfony', 'SlimFramework']; + $items = ['Laravel', 'Symfony', 'SlimFramework']; $results = Fuzzy::filter($items, 'slim'); $this->assertContains('SlimFramework', $results); diff --git a/tests/Unit/HooksTest.php b/tests/Unit/HooksTest.php index 8f66fdd..0179b4e 100644 --- a/tests/Unit/HooksTest.php +++ b/tests/Unit/HooksTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Hooks; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Hooks::class)] +#[CoversClass(Hooks::class)] final class HooksTest extends TestCase { private Hooks $hooks; @@ -26,7 +26,7 @@ public function test_listener_is_called_on_dispatch(): void { $called = false; - $this->hooks->on('test', function () use (&$called): void { + $this->hooks->on('test', static function () use (&$called): void { $called = true; }); @@ -39,7 +39,7 @@ public function test_dispatch_passes_payload_to_listener(): void { $received = null; - $this->hooks->on('data', function (mixed $payload) use (&$received): void { + $this->hooks->on('data', static function (mixed $payload) use (&$received): void { $received = $payload; }); @@ -52,13 +52,13 @@ public function test_multiple_listeners_all_fire(): void { $log = []; - $this->hooks->on('event', function () use (&$log): void { + $this->hooks->on('event', static function () use (&$log): void { $log[] = 'A'; }); - $this->hooks->on('event', function () use (&$log): void { + $this->hooks->on('event', static function () use (&$log): void { $log[] = 'B'; }); - $this->hooks->on('event', function () use (&$log): void { + $this->hooks->on('event', static function () use (&$log): void { $log[] = 'C'; }); @@ -82,7 +82,7 @@ public function test_once_listener_fires_only_once(): void { $count = 0; - $this->hooks->once('tick', function () use (&$count): void { + $this->hooks->once('tick', static function () use (&$count): void { $count++; }); @@ -101,7 +101,7 @@ public function test_off_removes_specific_listener(): void { $count = 0; - $listener = function () use (&$count): void { + $listener = static function () use (&$count): void { $count++; }; @@ -116,10 +116,10 @@ public function test_off_without_listener_removes_all(): void { $count = 0; - $this->hooks->on('event', function () use (&$count): void { + $this->hooks->on('event', static function () use (&$count): void { $count++; }); - $this->hooks->on('event', function () use (&$count): void { + $this->hooks->on('event', static function () use (&$count): void { $count++; }); @@ -143,18 +143,21 @@ public function test_dispatch_until_stops_at_first_non_null_return(): void { $log = []; - $this->hooks->on('validate', function () use (&$log): ?string { + $this->hooks->on('validate', static function () use (&$log): ?string { $log[] = 'first'; + return null; }); - $this->hooks->on('validate', function () use (&$log): ?string { + $this->hooks->on('validate', static function () use (&$log): ?string { $log[] = 'second'; + return 'HANDLED'; }); - $this->hooks->on('validate', function () use (&$log): ?string { + $this->hooks->on('validate', static function () use (&$log): ?string { $log[] = 'third'; // should NOT run + return null; }); @@ -166,7 +169,7 @@ public function test_dispatch_until_stops_at_first_non_null_return(): void public function test_dispatch_until_returns_null_when_no_listener_handles(): void { - $this->hooks->on('event', fn() => null); + $this->hooks->on('event', static fn() => null); $result = $this->hooks->dispatchUntil('event'); @@ -186,7 +189,7 @@ public function test_dispatch_until_returns_null_on_unknown_event(): void public function test_on_and_off_return_self_for_chaining(): void { - $result = $this->hooks->on('a', fn() => null)->off('a'); + $result = $this->hooks->on('a', static fn() => null)->off('a'); $this->assertSame($this->hooks, $result); } diff --git a/tests/Unit/InputTest.php b/tests/Unit/InputTest.php index 6e52437..a5c52b6 100644 --- a/tests/Unit/InputTest.php +++ b/tests/Unit/InputTest.php @@ -6,10 +6,10 @@ use AlfacodeTeam\PhpIoCli\Depends\Input; use AlfacodeTeam\PhpIoCli\Depends\State; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Input::class)] +#[CoversClass(Input::class)] final class InputTest extends TestCase { // --------------------------------------------------------------- @@ -22,7 +22,7 @@ public function test_bound_key_fires_handler(): void $state = new State(['count' => 0]); $fired = false; - $input->bind('ENTER', function (State $s) use (&$fired): void { + $input->bind('ENTER', static function (State $s) use (&$fired): void { $fired = true; }); @@ -33,11 +33,11 @@ public function test_bound_key_fires_handler(): void public function test_handler_receives_state(): void { - $input = new Input(); - $state = new State(['value' => 'hello']); + $input = new Input(); + $state = new State(['value' => 'hello']); $received = null; - $input->bind('UP', function (State $s) use (&$received): void { + $input->bind('UP', static function (State $s) use (&$received): void { $received = $s; }); @@ -65,7 +65,7 @@ public function test_bind_multiple_keys_array(): void $input = new Input(); $state = new State(['confirmed' => null]); - $input->bind(['y', 'Y'], function (State $s): void { + $input->bind(['y', 'Y'], static function (State $s): void { $s->confirmed = true; }); @@ -83,11 +83,11 @@ public function test_bind_multiple_keys_array(): void public function test_fallback_fires_for_unbound_key(): void { - $input = new Input(); - $state = new State(['typed' => '']); + $input = new Input(); + $state = new State(['typed' => '']); $lastKey = null; - $input->fallback(function (State $s, string $key) use (&$lastKey): void { + $input->fallback(static function (State $s, string $key) use (&$lastKey): void { $lastKey = $key; $s->typed .= $key; }); @@ -101,12 +101,12 @@ public function test_fallback_fires_for_unbound_key(): void public function test_fallback_does_not_fire_when_binding_exists(): void { - $input = new Input(); - $state = new State(); + $input = new Input(); + $state = new State(); $fallbackRan = false; - $input->bind('ENTER', function (State $s): void {}); - $input->fallback(function (State $s, string $key) use (&$fallbackRan): void { + $input->bind('ENTER', static function (State $s): void {}); + $input->fallback(static function (State $s, string $key) use (&$fallbackRan): void { $fallbackRan = true; }); @@ -123,14 +123,15 @@ public function test_return_false_stops_propagation(): void { $input = new Input(); $state = new State(); - $log = []; + $log = []; - $input->bind('UP', function (State $s) use (&$log): false { + $input->bind('UP', static function (State $s) use (&$log): false { $log[] = 'first'; + return false; }); - $input->bind('UP', function (State $s) use (&$log): void { + $input->bind('UP', static function (State $s) use (&$log): void { $log[] = 'second'; // should not run }); @@ -148,7 +149,7 @@ public function test_unbind_removes_handler(): void $input = new Input(); $state = new State(['x' => 0]); - $input->bind('UP', function (State $s): void { + $input->bind('UP', static function (State $s): void { $s->x++; }); @@ -176,7 +177,7 @@ public function test_handle_normalizes_key_before_dispatch(): void $state = new State(['moved' => false]); // Bind normalized name - $input->bind('UP', function (State $s): void { + $input->bind('UP', static function (State $s): void { $s->moved = true; }); diff --git a/tests/Unit/KeyTest.php b/tests/Unit/KeyTest.php index 9fdc341..94c4694 100644 --- a/tests/Unit/KeyTest.php +++ b/tests/Unit/KeyTest.php @@ -5,11 +5,11 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\Key; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Key::class)] +#[CoversClass(Key::class)] final class KeyTest extends TestCase { // --------------------------------------------------------------- @@ -25,21 +25,21 @@ public function test_normalize_maps_escape_sequences(string $raw, string $expect public static function escapeSequenceProvider(): array { return [ - 'arrow up' => ["\e[A", 'UP'], - 'arrow down' => ["\e[B", 'DOWN'], - 'arrow right' => ["\e[C", 'RIGHT'], - 'arrow left' => ["\e[D", 'LEFT'], - 'home' => ["\e[H", 'HOME'], - 'end' => ["\e[F", 'END'], + 'arrow up' => ["\e[A", 'UP'], + 'arrow down' => ["\e[B", 'DOWN'], + 'arrow right' => ["\e[C", 'RIGHT'], + 'arrow left' => ["\e[D", 'LEFT'], + 'home' => ["\e[H", 'HOME'], + 'end' => ["\e[F", 'END'], 'enter (newline)' => ["\n", 'ENTER'], 'enter (carriage)' => ["\r", 'ENTER'], - 'tab' => ["\t", 'TAB'], - 'esc' => ["\e", 'ESC'], + 'tab' => ["\t", 'TAB'], + 'esc' => ["\e", 'ESC'], 'backspace (del)' => ["\x7f", 'BACKSPACE'], - 'backspace (bs)' => ["\x08", 'BACKSPACE'], + 'backspace (bs)' => ["\x08", 'BACKSPACE'], 'delete sequence' => ["\e[3~", 'DELETE'], - 'ctrl+c' => ["\x03", 'CTRL_C'], - 'ctrl+d' => ["\x04", 'CTRL_D'], + 'ctrl+c' => ["\x03", 'CTRL_C'], + 'ctrl+d' => ["\x04", 'CTRL_D'], ]; } @@ -73,11 +73,11 @@ public static function printableCharProvider(): array return [ 'lowercase a' => ['a'], 'uppercase A' => ['A'], - 'digit 0' => ['0'], - 'space' => [' '], + 'digit 0' => ['0'], + 'space' => [' '], 'exclamation' => ['!'], - 'at sign' => ['@'], - 'tilde' => ['~'], + 'at sign' => ['@'], + 'tilde' => ['~'], ]; } @@ -90,12 +90,12 @@ public function test_is_printable_returns_false_for_control_chars(string $char): public static function nonPrintableCharProvider(): array { return [ - 'null byte' => ["\x00"], - 'ctrl+c' => ["\x03"], - 'backspace' => ["\x7f"], + 'null byte' => ["\x00"], + 'ctrl+c' => ["\x03"], + 'backspace' => ["\x7f"], 'escape sequence' => ["\e[A"], - 'newline' => ["\n"], - 'tab' => ["\t"], + 'newline' => ["\n"], + 'tab' => ["\t"], ]; } diff --git a/tests/Unit/NullIOTest.php b/tests/Unit/NullIOTest.php index ecc835c..55199ec 100644 --- a/tests/Unit/NullIOTest.php +++ b/tests/Unit/NullIOTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\NullIO; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\NullIO::class)] +#[CoversClass(NullIO::class)] final class NullIOTest extends TestCase { private NullIO $io; @@ -69,7 +69,7 @@ public function test_ask_confirmation_returns_default(): void public function test_ask_and_validate_returns_default(): void { - $result = $this->io->askAndValidate('Name?', fn($v) => $v, null, 'myDefault'); + $result = $this->io->askAndValidate('Name?', static fn($v) => $v, null, 'myDefault'); $this->assertSame('myDefault', $result); } diff --git a/tests/Unit/RenderContextTest.php b/tests/Unit/RenderContextTest.php index 610d251..ee1b169 100644 --- a/tests/Unit/RenderContextTest.php +++ b/tests/Unit/RenderContextTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\RenderContext; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\RenderContext::class)] +#[CoversClass(RenderContext::class)] final class RenderContextTest extends TestCase { public function test_default_dirty_is_true(): void diff --git a/tests/Unit/RendererTest.php b/tests/Unit/RendererTest.php index 5977233..142ee9a 100644 --- a/tests/Unit/RendererTest.php +++ b/tests/Unit/RendererTest.php @@ -8,11 +8,10 @@ use AlfacodeTeam\PhpIoCli\Depends\RenderContext; use AlfacodeTeam\PhpIoCli\Depends\Renderer; use AlfacodeTeam\PhpIoCli\Depends\State; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; /** - * * Strategy * ───────── * Renderer writes directly to stdout via `echo`. We wrap every assertion @@ -23,7 +22,7 @@ * via Colors::strip() before assertions so tests are not brittle against * ANSI details. */ -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Renderer::class)] +#[CoversClass(Renderer::class)] final class RendererTest extends TestCase { protected function setUp(): void @@ -36,30 +35,6 @@ protected function tearDown(): void Colors::enable(); } - // --------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------- - - private function capture(callable $fn): string - { - ob_start(); - $fn(); - return Colors::strip((string) ob_get_clean()); - } - - private function makeState(array $data = []): State - { - return new State(array_merge([ - 'question' => 'Choose an item', - 'search' => '', - 'index' => 0, - 'loading' => false, - 'items' => ['Alpha', 'Beta', 'Gamma', 'Delta'], - 'selected' => [], - 'multi' => false, - ], $data)); - } - // --------------------------------------------------------------- // key() // --------------------------------------------------------------- @@ -78,10 +53,10 @@ public function test_key_returns_class_name(): void public function test_render_outputs_question(): void { $renderer = new Renderer(); - $state = $this->makeState(['question' => 'Pick a region']); - $ctx = new RenderContext(); + $state = $this->makeState(['question' => 'Pick a region']); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('Pick a region', $output); } @@ -89,12 +64,12 @@ public function test_render_outputs_question(): void public function test_render_outputs_list_items(): void { $renderer = new Renderer(); - $state = $this->makeState([ + $state = $this->makeState([ 'items' => ['PHP', 'Python', 'Go'], ]); $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('PHP', $output); $this->assertStringContainsString('Python', $output); @@ -108,9 +83,9 @@ public function test_render_outputs_list_items(): void public function test_render_state_produces_same_structure(): void { $renderer = new Renderer(); - $state = $this->makeState(['question' => 'Pick one']); + $state = $this->makeState(['question' => 'Pick one']); - $output = $this->capture(fn() => $renderer->renderState($state)); + $output = $this->capture(static fn() => $renderer->renderState($state)); $this->assertStringContainsString('Pick one', $output); } @@ -122,10 +97,10 @@ public function test_render_state_produces_same_structure(): void public function test_loading_state_shows_loading_indicator(): void { $renderer = new Renderer(); - $state = $this->makeState(['loading' => true]); - $ctx = new RenderContext(); + $state = $this->makeState(['loading' => true]); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('Loading', $output); } @@ -133,13 +108,13 @@ public function test_loading_state_shows_loading_indicator(): void public function test_loading_state_hides_item_list(): void { $renderer = new Renderer(); - $state = $this->makeState([ + $state = $this->makeState([ 'loading' => true, - 'items' => ['Alpha', 'Beta'], + 'items' => ['Alpha', 'Beta'], ]); $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); // Items should not appear while loading $this->assertStringNotContainsString('Alpha', $output); @@ -152,10 +127,10 @@ public function test_loading_state_hides_item_list(): void public function test_empty_items_shows_no_results_message(): void { $renderer = new Renderer(); - $state = $this->makeState(['items' => [], 'loading' => false]); - $ctx = new RenderContext(); + $state = $this->makeState(['items' => [], 'loading' => false]); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('No results', $output); } @@ -167,13 +142,13 @@ public function test_empty_items_shows_no_results_message(): void public function test_active_item_receives_highlight_marker(): void { $renderer = new Renderer(); - $state = $this->makeState([ + $state = $this->makeState([ 'items' => ['Alpha', 'Beta', 'Gamma'], 'index' => 1, ]); $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); // The active item pointer '›' should appear alongside 'Beta' $this->assertStringContainsString('Beta', $output); @@ -187,14 +162,14 @@ public function test_active_item_receives_highlight_marker(): void public function test_multi_mode_renders_checkboxes(): void { $renderer = new Renderer(); - $state = $this->makeState([ - 'items' => ['Auth', 'API', 'Queue'], - 'multi' => true, + $state = $this->makeState([ + 'items' => ['Auth', 'API', 'Queue'], + 'multi' => true, 'selected' => ['Auth'], ]); $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); // Filled ⬢ for selected, empty ⬡ for unselected $this->assertStringContainsString('⬢', $output); @@ -204,14 +179,14 @@ public function test_multi_mode_renders_checkboxes(): void public function test_multi_mode_marks_selected_item(): void { $renderer = new Renderer(); - $state = $this->makeState([ - 'items' => ['Auth', 'API'], - 'multi' => true, + $state = $this->makeState([ + 'items' => ['Auth', 'API'], + 'multi' => true, 'selected' => ['Auth'], ]); $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('Auth', $output); } @@ -223,11 +198,11 @@ public function test_multi_mode_marks_selected_item(): void public function test_scroll_indicator_shown_when_items_exceed_window(): void { $renderer = new Renderer(); - $items = array_map(fn($i) => "Item-{$i}", range(1, 20)); - $state = $this->makeState(['items' => $items, 'index' => 0]); - $ctx = new RenderContext(); + $items = array_map(static fn($i) => "Item-{$i}", range(1, 20)); + $state = $this->makeState(['items' => $items, 'index' => 0]); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); // When there are more items than the window size (10), a "more" hint appears $this->assertStringContainsString('more items', $output); @@ -240,10 +215,10 @@ public function test_scroll_indicator_shown_when_items_exceed_window(): void public function test_search_query_appears_in_output(): void { $renderer = new Renderer(); - $state = $this->makeState(['search' => 'alph']); - $ctx = new RenderContext(); + $state = $this->makeState(['search' => 'alph']); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('alph', $output); } @@ -251,10 +226,10 @@ public function test_search_query_appears_in_output(): void public function test_search_label_appears_in_output(): void { $renderer = new Renderer(); - $state = $this->makeState(); - $ctx = new RenderContext(); + $state = $this->makeState(); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('Search', $output); } @@ -266,11 +241,11 @@ public function test_search_label_appears_in_output(): void public function test_before_render_does_not_throw(): void { $renderer = new Renderer(); - $state = $this->makeState(); - $ctx = new RenderContext(); + $state = $this->makeState(); + $ctx = new RenderContext(); // beforeRender emits cursor-hide escape codes — capture and discard - $this->capture(fn() => $renderer->beforeRender($state, $ctx)); + $this->capture(static fn() => $renderer->beforeRender($state, $ctx)); $this->assertTrue(true); } @@ -278,10 +253,10 @@ public function test_before_render_does_not_throw(): void public function test_after_render_does_not_throw(): void { $renderer = new Renderer(); - $state = $this->makeState(); - $ctx = new RenderContext(); + $state = $this->makeState(); + $ctx = new RenderContext(); - $this->capture(fn() => $renderer->afterRender($state, $ctx)); + $this->capture(static fn() => $renderer->afterRender($state, $ctx)); $this->assertTrue(true); } @@ -293,10 +268,10 @@ public function test_after_render_does_not_throw(): void public function test_single_select_footer_contains_nav_and_enter(): void { $renderer = new Renderer(); - $state = $this->makeState(['multi' => false]); - $ctx = new RenderContext(); + $state = $this->makeState(['multi' => false]); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('nav', $output); $this->assertStringContainsString('enter', $output); @@ -305,11 +280,36 @@ public function test_single_select_footer_contains_nav_and_enter(): void public function test_multi_select_footer_contains_space_toggle(): void { $renderer = new Renderer(); - $state = $this->makeState(['multi' => true, 'items' => ['A']]); - $ctx = new RenderContext(); + $state = $this->makeState(['multi' => true, 'items' => ['A']]); + $ctx = new RenderContext(); - $output = $this->capture(fn() => $renderer->render($state, $ctx)); + $output = $this->capture(static fn() => $renderer->render($state, $ctx)); $this->assertStringContainsString('space', $output); } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private function capture(callable $fn): string + { + ob_start(); + $fn(); + + return Colors::strip((string) ob_get_clean()); + } + + private function makeState(array $data = []): State + { + return new State(array_merge([ + 'question' => 'Choose an item', + 'search' => '', + 'index' => 0, + 'loading' => false, + 'items' => ['Alpha', 'Beta', 'Gamma', 'Delta'], + 'selected' => [], + 'multi' => false, + ], $data)); + } } diff --git a/tests/Unit/ShellResultTest.php b/tests/Unit/ShellResultTest.php index f969709..aeb3aab 100644 --- a/tests/Unit/ShellResultTest.php +++ b/tests/Unit/ShellResultTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\ShellResult; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\ShellResult::class)] +#[CoversClass(ShellResult::class)] final class ShellResultTest extends TestCase { public function test_ok_returns_true_for_exit_code_zero(): void diff --git a/tests/Unit/SpinnerFramesTest.php b/tests/Unit/SpinnerFramesTest.php index eb7bbbb..6b95b26 100644 --- a/tests/Unit/SpinnerFramesTest.php +++ b/tests/Unit/SpinnerFramesTest.php @@ -5,11 +5,11 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\SpinnerFrames; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\SpinnerFrames::class)] +#[CoversClass(SpinnerFrames::class)] final class SpinnerFramesTest extends TestCase { // --------------------------------------------------------------- @@ -28,11 +28,11 @@ public function test_get_returns_non_empty_array_for_named_style(string $style): public static function namedStyleProvider(): array { return [ - 'dots' => ['dots'], - 'line' => ['line'], - 'bars' => ['bars'], - 'pulse' => ['pulse'], - 'arc' => ['arc'], + 'dots' => ['dots'], + 'line' => ['line'], + 'bars' => ['bars'], + 'pulse' => ['pulse'], + 'arc' => ['arc'], 'bounce' => ['bounce'], ]; } @@ -43,7 +43,7 @@ public static function namedStyleProvider(): array public function test_get_unknown_style_returns_default_dots(): void { - $dots = SpinnerFrames::get('dots'); + $dots = SpinnerFrames::get('dots'); $unknown = SpinnerFrames::get('nonexistent-style'); $this->assertSame($dots, $unknown); @@ -115,7 +115,7 @@ public function test_bounce_style_has_more_frames_than_dots(): void // Bounce has a wider animation loop $this->assertGreaterThan( count(SpinnerFrames::dots()), - count(SpinnerFrames::get('bounce')) + count(SpinnerFrames::get('bounce')), ); } } diff --git a/tests/Unit/SpinnerTest.php b/tests/Unit/SpinnerTest.php index 2be8595..da03af3 100644 --- a/tests/Unit/SpinnerTest.php +++ b/tests/Unit/SpinnerTest.php @@ -6,10 +6,10 @@ use AlfacodeTeam\PhpIoCli\Depends\Spinner; use AlfacodeTeam\PhpIoCli\Depends\SpinnerFrames; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\Spinner::class)] +#[CoversClass(Spinner::class)] final class SpinnerTest extends TestCase { // --------------------------------------------------------------- @@ -49,7 +49,7 @@ public function test_tick_returns_non_empty_string_when_running(): void public function test_tick_returns_value_from_provided_frames(): void { - $frames = ['A', 'B', 'C']; + $frames = ['A', 'B', 'C']; $spinner = new Spinner($frames); $spinner->start(); @@ -65,7 +65,7 @@ public function test_tick_returns_value_from_provided_frames(): void public function test_tick_advances_frame_after_interval(): void { // Use a very short interval so we don't wait long in tests - $frames = ['X', 'Y', 'Z']; + $frames = ['X', 'Y', 'Z']; $spinner = new Spinner($frames, interval: 0.001); // 1 ms interval $spinner->start(); @@ -84,7 +84,7 @@ public function test_tick_advances_frame_after_interval(): void public function test_frames_wrap_around_cyclically(): void { // Two frames, very short interval — tick many times to confirm cycling - $frames = ['F1', 'F2']; + $frames = ['F1', 'F2']; $spinner = new Spinner($frames, interval: 0.0001); $spinner->start(); @@ -144,11 +144,11 @@ public function test_calling_stop_twice_does_not_throw(): void public function test_tick_does_not_advance_before_interval(): void { // Long interval — frame should NOT change between two rapid ticks - $frames = ['A', 'B', 'C']; + $frames = ['A', 'B', 'C']; $spinner = new Spinner($frames, interval: 60.0); // 60-second interval $spinner->start(); - $first = $spinner->tick(); + $first = $spinner->tick(); $second = $spinner->tick(); // called immediately — no time has passed $this->assertSame($first, $second, 'Frame must not advance before the interval elapses'); diff --git a/tests/Unit/StateTest.php b/tests/Unit/StateTest.php index c1bd75f..0afe043 100644 --- a/tests/Unit/StateTest.php +++ b/tests/Unit/StateTest.php @@ -5,10 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\State; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Depends\State::class)] +#[CoversClass(State::class)] final class StateTest extends TestCase { // --------------------------------------------------------------- @@ -50,7 +50,7 @@ public function test_set_same_value_does_not_trigger_watcher(): void $state = new State(['x' => 1]); $calls = 0; - $state->watch('x', function () use (&$calls): void { + $state->watch('x', static function () use (&$calls): void { $calls++; }); @@ -142,11 +142,11 @@ public function test_toggle_re_indexes_array(): void public function test_watcher_fires_on_change(): void { - $state = new State(['score' => 0]); + $state = new State(['score' => 0]); $newVal = null; $oldVal = null; - $state->watch('score', function (mixed $new, mixed $old) use (&$newVal, &$oldVal): void { + $state->watch('score', static function (mixed $new, mixed $old) use (&$newVal, &$oldVal): void { $newVal = $new; $oldVal = $old; }); @@ -162,10 +162,10 @@ public function test_multiple_watchers_all_fire(): void $state = new State(['x' => 0]); $calls = []; - $state->watch('x', function () use (&$calls): void { + $state->watch('x', static function () use (&$calls): void { $calls[] = 'first'; }); - $state->watch('x', function () use (&$calls): void { + $state->watch('x', static function () use (&$calls): void { $calls[] = 'second'; }); @@ -179,7 +179,7 @@ public function test_watcher_receives_state_reference(): void $state = new State(['x' => 0]); $capturedState = null; - $state->watch('x', function (mixed $new, mixed $old, State $s) use (&$capturedState): void { + $state->watch('x', static function (mixed $new, mixed $old, State $s) use (&$capturedState): void { $capturedState = $s; }); diff --git a/tests/Unit/TableTest.php b/tests/Unit/TableTest.php index c1756a2..29d6ec5 100644 --- a/tests/Unit/TableTest.php +++ b/tests/Unit/TableTest.php @@ -6,11 +6,11 @@ use AlfacodeTeam\PhpIoCli\Components\Table; use AlfacodeTeam\PhpIoCli\Depends\Colors; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; -#[CoversClass(\AlfacodeTeam\PhpIoCli\Components\Table::class)] +#[CoversClass(Table::class)] final class TableTest extends TestCase { protected function setUp(): void @@ -23,19 +23,6 @@ protected function tearDown(): void Colors::enable(); } - private function plainTable(): string - { - return Colors::strip( - Table::make() - ->headers(['Name', 'Role', 'Status']) - ->rows([ - ['Alice', 'Admin', 'Active'], - ['Bob', 'Editor', 'Inactive'], - ]) - ->toString() - ); - } - // --------------------------------------------------------------- // Basic rendering // --------------------------------------------------------------- @@ -78,7 +65,7 @@ public function test_table_renders_with_different_styles(string $style): void ->headers(['Col']) ->rows([['Value']]) ->style($style) - ->toString() + ->toString(), ); $this->assertStringContainsString('Col', $output); @@ -88,8 +75,8 @@ public function test_table_renders_with_different_styles(string $style): void public static function styleProvider(): array { return [ - 'box' => ['box'], - 'bold' => ['bold'], + 'box' => ['box'], + 'bold' => ['bold'], 'compact' => ['compact'], 'minimal' => ['minimal'], ]; @@ -118,7 +105,7 @@ public function test_align_does_not_break_rendering(): void ->headers(['Left', 'Center', 'Right']) ->rows([['a', 'b', 'c']]) ->align([0 => 'left', 1 => 'center', 2 => 'right']) - ->toString() + ->toString(), ); $this->assertStringContainsString('Left', $output); @@ -133,18 +120,31 @@ public function test_ansi_color_in_cell_does_not_corrupt_alignment(): void { // Even though the cell contains ANSI codes, the column should align correctly $coloredCell = Colors::wrap('Active', Colors::GREEN); - $output = Colors::strip( + $output = Colors::strip( Table::make() ->headers(['Name', 'Status']) ->rows([ ['Alice', $coloredCell], ['Bob', 'Inactive'], ]) - ->toString() + ->toString(), ); $this->assertStringContainsString('Alice', $output); $this->assertStringContainsString('Active', $output); $this->assertStringContainsString('Inactive', $output); } + + private function plainTable(): string + { + return Colors::strip( + Table::make() + ->headers(['Name', 'Role', 'Status']) + ->rows([ + ['Alice', 'Admin', 'Active'], + ['Bob', 'Editor', 'Inactive'], + ]) + ->toString(), + ); + } } From 9f4e13b56e0d4d880f0e4804377b97420dd5d836 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 4 May 2026 14:52:06 +0300 Subject: [PATCH 6/8] update --- Makefile | 4 +- TODO.md | 36 ++--- src/Components/SliderInput.php | 280 ++++++++++++++++++++++++++++++++ tests/Unit/SliderInputTest.php | 288 +++++++++++++++++++++++++++++++++ 4 files changed, 588 insertions(+), 20 deletions(-) create mode 100644 src/Components/SliderInput.php create mode 100644 tests/Unit/SliderInputTest.php diff --git a/Makefile b/Makefile index e129455..410478d 100644 --- a/Makefile +++ b/Makefile @@ -50,10 +50,10 @@ stan: # ── Code style ──────────────────────────────────────────────────────────────── cs-check: - vendor/bin/php-cs-fixer fix --dry-run --diff --allow-unsupported-php-version=yes --config=php-cs-fixer.php + vendor/bin/php-cs-fixer fix --dry-run --diff --config=php-cs-fixer.php cs-fix: - vendor/bin/php-cs-fixer fix --allow-unsupported-php-version=yes --config=php-cs-fixer.php + vendor/bin/php-cs-fixer fix --config=php-cs-fixer.php @printf "\n$(GREEN)✔ Code style fixes applied.$(RESET)\n" # ── Refactoring ─────────────────────────────────────────────────────────────── diff --git a/TODO.md b/TODO.md index c1bb83f..7d12b0c 100644 --- a/TODO.md +++ b/TODO.md @@ -23,13 +23,13 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | Integration: `AbstractCommand` | P0 | 🟢 | Done | | Integration: `CLIApplication` | P0 | 🟢 | Done | | Integration: `BufferIO` | P0 | 🟢 | Done | -| Unit tests for `Alert` | P1 | 🔴 | Test output string contains expected borders + content | -| Unit tests for `SpinnerFrames` | P1 | 🔴 | Verify all named frame sets return non-empty arrays | -| Unit tests for `Spinner` | P1 | 🔴 | Tick advances frame; stop returns empty string | -| Unit tests for `Renderer` | P1 | 🔴 | Tricky — requires capturing stdout; mock Terminal | -| Integration: `BufferIO::setUserInputs` + commands | P1 | 🔴 | Test confirm/select prompts with pre-set inputs | -| Integration: `Shell::run` (echo command) | P2 | 🔴 | Use `echo` / `printf` — safe cross-platform | -| Integration: `Shell::capture` | P2 | 🔴 | Capture `php --version` or similar | +| Unit tests for `Alert` | P1 | 🟢 | Done in `tests/Unit/AlertTest.php` | +| Unit tests for `SpinnerFrames` | P1 | 🟢 | Done in `tests/Unit/SpinnerFramesTest.php` | +| Unit tests for `Spinner` | P1 | 🟢 | Done in `tests/Unit/SpinnerTest.php` | +| Unit tests for `Renderer` | P1 | 🟢 | Done in `tests/Unit/RendererTest.php` | +| Integration: `BufferIO::setUserInputs` + commands | P1 | 🟢 | Done in `tests/Integration/BufferIOUserInputsTest.php` | +| Integration: `Shell::run` (echo command) | P2 | 🟢 | Done in `tests/Integration/ShellTest.php` | +| Integration: `Shell::capture` | P2 | 🟢 | Done in `tests/Integration/ShellTest.php` | | Mutation testing via Infection | P2 | 🔴 | Add `infection/infection` dev dep; configure `infection.json5` | | Coverage badge > 80% target | P2 | 🔴 | Depends on above items | @@ -39,8 +39,8 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | Component | Priority | Status | Description | |---|---|---|---| -| `SliderInput` | P1 | 🔴 | Horizontal bar slider for float/int ranges. Arrow keys ± step. | -| `RadioGroup` | P1 | 🔴 | Like `Select` but renders all options at once (no scroll). Good for short lists ≤ 5. | +| `SliderInput` | P1 | 🟢 | Done in `src/Components/SliderInput.php` — horizontal bar slider for float/int ranges; arrow keys ± step | +| `RadioGroup` | P1 | 🟢 | Done in `src/Components/RadioGroup.php` — renders all options at once; ↑↓←→ navigate, 1-9 jump, multi-column layout | | `SearchableTreeSelect` | P2 | 🔴 | Nested tree navigation. `parent > child > grandchild` grouping. | | `TagInput` | P2 | 🔴 | Free-form comma-delimited tags with fuzzy autocomplete. | | `CodeEditor` | P3 | 🔴 | Minimal inline code block with basic syntax highlighting. | @@ -70,12 +70,12 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | Item | Priority | Status | Notes | |---|---|---|---| -| PHP CS Fixer config (`.php-cs-fixer.php`) | P1 | 🔴 | PER-CS style; add `composer cs-fix` and `composer cs-check` scripts | -| `composer.json` scripts | P1 | 🔴 | `test`, `test:unit`, `test:integration`, `test:coverage`, `phpstan`, `cs-fix`, `cs-check` | -| Rector config for upgrade automation | P2 | 🔴 | `rector.php` targeting PHP 8.2+ idioms | +| PHP CS Fixer config (`.php-cs-fixer.php`) | P1 | 🟢 | Done — `php-cs-fixer.php` present with PER-CS style | +| `composer.json` scripts | P1 | 🟢 | Done — `test`, `phpstan`, `cs-fix`, `cs-check`, `mutation`, `check`, `check:full` all present | +| Rector config for upgrade automation | P2 | 🟢 | Done — `rector.php` present | | Dev container / GitHub Codespaces | P2 | 🔴 | `.devcontainer/devcontainer.json` with PHP 8.3, Xdebug, Composer | -| Makefile for common tasks | P2 | 🔴 | `make test`, `make stan`, `make fix`, `make example` | -| Interactive demo script | P1 | 🔴 | `php examples/demo.php` — a menu-driven tour of all components | +| Makefile for common tasks | P2 | 🟢 | Done — `Makefile` present with `test`, `stan`, `fix`, `demo` etc. | +| Interactive demo script | P1 | 🟢 | Done — `examples/demo.php` with menu-driven tour of all components | --- @@ -84,7 +84,7 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | Item | Priority | Status | Notes | |---|---|---|---| | Per-component `@example` docblocks | P1 | 🔴 | Every component class should have a self-contained usage example in its docblock | -| Architecture diagram (Mermaid) | P1 | 🔴 | Add `docs/architecture.md` with a Mermaid class/sequence diagram | +| Architecture diagram (Mermaid) | P1 | 🟢 | Done — `architecture.md` with full Mermaid class/sequence/flow diagrams | | Video demo / GIF | P2 | 🔴 | Record a terminal session showing the interactive components; embed in README | | API reference (phpDocumentor) | P2 | 🔴 | Auto-generate and publish to GitHub Pages | | "Building your first command" tutorial | P2 | 🔴 | Step-by-step guide: create a command, add inputs, test it | @@ -106,10 +106,10 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | Coverage badge in README | P1 | 🔴 | Depends on Codecov | | PHPStan badge | P1 | 🔴 | Add static badge once baseline is locked | | Packagist publish | P1 | 🔴 | Register on packagist.org; add `packagist` webhook to repo | -| `SECURITY.md` | P1 | 🔴 | Responsible disclosure policy | -| Dependabot for Composer | P2 | 🔴 | `.github/dependabot.yml` — weekly updates to dev deps | +| `SECURITY.md` | P1 | 🟢 | Done | +| Dependabot for Composer | P2 | 🟢 | Done — `.github/dependabot.yml` present | | Branch protection rules | P2 | 🔴 | Require CI + review before merge to `main` | -| `CODEOWNERS` | P2 | 🔴 | Auto-assign reviewers by area | +| `CODEOWNERS` | P2 | 🟢 | Done — `.github/CODEOWNERS` present | --- diff --git a/src/Components/SliderInput.php b/src/Components/SliderInput.php new file mode 100644 index 0000000..22f944f --- /dev/null +++ b/src/Components/SliderInput.php @@ -0,0 +1,280 @@ +step(5) + * ->default(50) + * ->integer() + * ->run(); // returns int + * + * $rate = (new SliderInput('Tax rate', min: 0.0, max: 1.0)) + * ->step(0.01) + * ->default(0.2) + * ->run(); // returns float + */ +final class SliderInput extends Component +{ + private float $min; + + private float $max; + + private float $step; + + private float $defaultValue; + + private bool $intOnly = false; + + private int $barWidth = 30; + + private int $lastLines = 0; + + public function __construct( + private string $question, + float $min = 0, + float $max = 100, + ) { + $this->min = $min; + $this->max = $max; + $this->step = 1.0; + $this->defaultValue = $min; + parent::__construct(); + } + + /* ========================================================= + FLUENT CONFIGURATION + ========================================================= */ + + public function min(float $min): self + { + $this->min = $min; + + return $this; + } + + public function max(float $max): self + { + $this->max = $max; + + return $this; + } + + public function step(float $step): self + { + $this->step = max($step, PHP_FLOAT_EPSILON); + + return $this; + } + + public function default(float $value): self + { + $this->defaultValue = $this->clamp($value); + + return $this; + } + + public function integer(): self + { + $this->intOnly = true; + + return $this; + } + + public function width(int $chars): self + { + $this->barWidth = max(10, $chars); + + return $this; + } + + /* ========================================================= + LIFECYCLE + ========================================================= */ + + protected function setup(): void + { + $this->state->batch([ + 'value' => $this->defaultValue, + 'done' => false, + ]); + + // Single step left / right + $this->input->bind('LEFT', function ($s): void { + $s->value = $this->clamp((float) $s->value - $this->step); + }); + + $this->input->bind('RIGHT', function ($s): void { + $s->value = $this->clamp((float) $s->value + $this->step); + }); + + // Jump 10 % of range per page key / shift-arrow equivalent ([ and ]) + $this->input->bind(['[', 'PAGE_UP'], function ($s): void { + $jump = ($this->max - $this->min) * 0.1; + $s->value = $this->clamp((float) $s->value - $jump); + }); + + $this->input->bind([']', 'PAGE_DOWN'], function ($s): void { + $jump = ($this->max - $this->min) * 0.1; + $s->value = $this->clamp((float) $s->value + $jump); + }); + + // Home / End — jump to extremes + $this->input->bind('HOME', function ($s): void { + $s->value = $this->min; + }); + + $this->input->bind('END', function ($s): void { + $s->value = $this->max; + }); + + // Submit + $this->input->bind('ENTER', function ($s): void { + // Snap to nearest step on submit + $s->value = $this->snap((float) $s->value); + $s->done = true; + $this->stop(); + }); + } + + /* ========================================================= + RENDER + ========================================================= */ + + public function render(): void + { + if ($this->lastLines > 0) { + Terminal::moveCursorUp($this->lastLines); + } + + Terminal::hideCursor(); + + $value = (float) $this->state->value; + $done = (bool) $this->state->done; + $lines = []; + + // ── Line 1: question ────────────────────────────────── + $mark = $done + ? Colors::success('') + : Colors::wrap('? ', Colors::CYAN); + $lines[] = $mark . Colors::wrap($this->question, Colors::BOLD); + + if (!$done) { + // ── Line 2: bar + value ─────────────────────────── + $lines[] = ' ' . $this->buildBar($value) . ' ' . Colors::wrap($this->format($value), [Colors::YELLOW, Colors::BOLD]); + + // ── Line 3: range hint ──────────────────────────── + $lo = $this->format($this->min); + $hi = $this->format($this->max); + $lines[] = Colors::muted(" {$lo}" . str_repeat(' ', $this->barWidth) . "{$hi}"); + + // ── Line 4: help ────────────────────────────────── + $lines[] = Colors::muted(' ← → step • [ ] jump 10% • HOME/END • ENTER confirm'); + } else { + $lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap($this->format($value), Colors::GREEN); + } + + foreach ($lines as $line) { + Terminal::clearLine(); + echo $line . PHP_EOL; + } + + $this->lastLines = count($lines); + } + + /* ========================================================= + CLEANUP & RESOLVE + ========================================================= */ + + public function destroy(): void + { + Terminal::showCursor(); + parent::destroy(); + } + + public function resolve(): mixed + { + $value = $this->snap((float) $this->state->value); + + return $this->intOnly ? (int) round($value) : $value; + } + + /* ========================================================= + BAR BUILDER + ========================================================= */ + + private function buildBar(float $value): string + { + $range = $this->max - $this->min; + $pct = $range > 0 ? ($value - $this->min) / $range : 0.0; + $pct = max(0.0, min(1.0, $pct)); + + $filled = (int) round($this->barWidth * $pct); + $empty = $this->barWidth - $filled; + + // Thumb sits at the boundary between filled and empty + $thumbPos = $filled > 0 ? $filled - 1 : 0; + $filledStr = str_repeat('━', $thumbPos) . Colors::wrap('●', [Colors::CYAN, Colors::BOLD]) . str_repeat('━', max(0, $filled - $thumbPos - 1)); + $emptyStr = Colors::muted(str_repeat('─', $empty)); + + $color = match (true) { + $pct >= 0.75 => Colors::GREEN, + $pct >= 0.40 => Colors::CYAN, + $pct >= 0.15 => Colors::YELLOW, + default => Colors::RED, + }; + + return Colors::wrap('[', Colors::GRAY) + . Colors::wrap($filledStr, $color) + . $emptyStr + . Colors::wrap(']', Colors::GRAY); + } + + /* ========================================================= + HELPERS + ========================================================= */ + + private function clamp(float $value): float + { + return max($this->min, min($this->max, $value)); + } + + private function snap(float $value): float + { + if ($this->step <= 0) { + return $this->clamp($value); + } + + $snapped = round(($value - $this->min) / $this->step) * $this->step + $this->min; + + return $this->clamp($snapped); + } + + private function format(float $value): string + { + if ($this->intOnly) { + return (string) (int) round($value); + } + + // Auto-detect required decimal places from step + $decimals = 0; + $stepStr = rtrim(rtrim(number_format($this->step, 10, '.', ''), '0'), '.'); + if (str_contains($stepStr, '.')) { + $decimals = strlen(explode('.', $stepStr)[1]); + } + + return number_format($value, $decimals, '.', ''); + } +} diff --git a/tests/Unit/SliderInputTest.php b/tests/Unit/SliderInputTest.php new file mode 100644 index 0000000..5912a27 --- /dev/null +++ b/tests/Unit/SliderInputTest.php @@ -0,0 +1,288 @@ +assertSame($slider, $slider->min(0)); + $this->assertSame($slider, $slider->max(100)); + $this->assertSame($slider, $slider->step(5)); + $this->assertSame($slider, $slider->default(50)); + $this->assertSame($slider, $slider->integer()); + $this->assertSame($slider, $slider->width(40)); + } + + // --------------------------------------------------------------- + // resolve() — float mode (default) + // --------------------------------------------------------------- + + public function test_resolve_returns_float_by_default(): void + { + $slider = new SliderInput('Rate', 0.0, 1.0); + $slider->step(0.1)->default(0.5); + + // Mount the component so state is initialised (setup() wires bindings) + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertIsFloat($result); + $this->assertEqualsWithDelta(0.5, $result, 0.001); + } + + // --------------------------------------------------------------- + // resolve() — integer mode + // --------------------------------------------------------------- + + public function test_resolve_returns_int_in_integer_mode(): void + { + $slider = new SliderInput('Port', 1000, 9999); + $slider->step(1)->default(8080)->integer(); + + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertIsInt($result); + $this->assertSame(8080, $result); + } + + // --------------------------------------------------------------- + // resolve() — clamps to min / max + // --------------------------------------------------------------- + + public function test_resolve_clamps_value_within_range(): void + { + $slider = new SliderInput('Speed', 0, 10); + $slider->step(1)->default(5); + + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertGreaterThanOrEqual(0, $result); + $this->assertLessThanOrEqual(10, $result); + } + + // --------------------------------------------------------------- + // resolve() — snap to step + // --------------------------------------------------------------- + + public function test_resolve_snaps_float_value_to_nearest_step(): void + { + // step = 0.25; default = 0.3 → nearest is 0.25 + $slider = new SliderInput('Alpha', 0.0, 1.0); + $slider->step(0.25)->default(0.3); + + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertEqualsWithDelta(0.25, $result, 0.001); + } + + // --------------------------------------------------------------- + // render() — question appears in output + // --------------------------------------------------------------- + + public function test_render_contains_question_text(): void + { + $slider = new SliderInput('Master volume', 0, 100); + $slider->step(1)->default(50); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + $this->assertStringContainsString('Master volume', $output); + } + + // --------------------------------------------------------------- + // render() — current value appears in output + // --------------------------------------------------------------- + + public function test_render_shows_current_value(): void + { + $slider = new SliderInput('Brightness', 0, 100); + $slider->step(1)->default(75)->integer(); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + $this->assertStringContainsString('75', $output); + } + + // --------------------------------------------------------------- + // render() — bar characters present + // --------------------------------------------------------------- + + public function test_render_contains_bar_brackets(): void + { + $slider = new SliderInput('Level', 0, 10); + $slider->step(1)->default(5); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + $this->assertStringContainsString('[', $output); + $this->assertStringContainsString(']', $output); + } + + // --------------------------------------------------------------- + // render() — range hints (min and max values) + // --------------------------------------------------------------- + + public function test_render_shows_min_and_max_hints(): void + { + $slider = new SliderInput('Timeout', 1, 60); + $slider->step(1)->default(30)->integer(); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + $this->assertStringContainsString('1', $output); + $this->assertStringContainsString('60', $output); + } + + // --------------------------------------------------------------- + // render() — help text + // --------------------------------------------------------------- + + public function test_render_contains_keyboard_hint(): void + { + $slider = new SliderInput('Value', 0, 100); + $slider->step(1)->default(50); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + $this->assertStringContainsString('ENTER', $output); + } + + // --------------------------------------------------------------- + // render() — ANSI disabled → plain output + // --------------------------------------------------------------- + + public function test_render_works_with_colors_disabled(): void + { + Colors::disable(); + $slider = new SliderInput('Gain', 0, 10); + $slider->step(1)->default(5); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + // No ANSI escape sequences should appear + $this->assertStringNotContainsString("\033[", $output); + $this->assertStringContainsString('Gain', $output); + } + + // --------------------------------------------------------------- + // render() — thumb marker present (●) + // --------------------------------------------------------------- + + public function test_render_contains_thumb_marker(): void + { + $slider = new SliderInput('Pan', -50, 50); + $slider->step(1)->default(0)->integer(); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + // The slider thumb is rendered as ● + $this->assertStringContainsString('●', $output); + } + + // --------------------------------------------------------------- + // Decimal formatting mirrors step precision + // --------------------------------------------------------------- + + public function test_render_formats_value_with_correct_decimal_places(): void + { + $slider = new SliderInput('Rate', 0.0, 1.0); + $slider->step(0.01)->default(0.75); + $this->mountWithoutLoop($slider); + + $output = $this->capture(fn() => $slider->render()); + + $this->assertStringContainsString('0.75', $output); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Calls the protected setup() via mount() so state and bindings are ready, + * without launching the blocking run() loop. + */ + private function mountWithoutLoop(SliderInput $slider): void + { + $ref = new \ReflectionObject($slider); + $method = $ref->getMethod('setup'); + $method->setAccessible(true); + $method->invoke($slider); + } + + /** + * Calls the protected resolve() via reflection. + */ + private function callResolve(SliderInput $slider): mixed + { + $ref = new \ReflectionObject($slider); + $method = $ref->getMethod('resolve'); + $method->setAccessible(true); + + return $method->invoke($slider); + } + + /** + * Captures stdout output from a callable and strips ANSI codes. + */ + private function capture(callable $fn): string + { + ob_start(); + $fn(); + + return Colors::strip((string) ob_get_clean()); + } +} From 31aa5c9446a0ef01db1f2d70fe4cb218e857856d Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 4 May 2026 15:12:23 +0300 Subject: [PATCH 7/8] ci: run PHP CS Fixer under PHP 8.2 and apply style fixes --- CS_FIXES.md | 75 +++++++++++++ Makefile | 3 + examples/01-inputs.php | 2 +- examples/demo.php | 2 +- src/BufferIO.php | 2 +- src/CLIApplication.php | 4 +- src/Components/Component.php | 2 +- src/Components/NumberInput.php | 6 +- src/Components/SliderInput.php | 112 +++++++++---------- src/ConsoleIO.php | 12 +- src/Depends/Colors.php | 2 +- src/Depends/Input.php | 2 +- src/Depends/Shell.php | 4 +- src/Depends/Spinner.php | 2 +- src/Hooks.php | 2 +- src/IOInterface.php | 8 +- src/NullIO.php | 8 +- src/Silencer.php | 2 +- tests/Integration/BufferIOUserInputsTest.php | 2 +- tests/Unit/SliderInputTest.php | 16 +-- 20 files changed, 173 insertions(+), 95 deletions(-) create mode 100644 CS_FIXES.md diff --git a/CS_FIXES.md b/CS_FIXES.md new file mode 100644 index 0000000..e167385 --- /dev/null +++ b/CS_FIXES.md @@ -0,0 +1,75 @@ +# PHP CS Fixer — Patch Summary + +## Violations Fixed + +### 1. `nullable_type_declaration` — `?Type` → `Type|null` + +Configured in `php-cs-fixer.php`: +```php +'nullable_type_declaration' => ['syntax' => 'union'], +``` +This means every `?Foo` must be written as `Foo|null`. + +**Files changed and their specific fixes:** + +| File | Old | New | +|---|---|---| +| `src/ConsoleIO.php` | `?float $startTime` | `float\|null $startTime` | +| `src/ConsoleIO.php` | `int\|null $attempts` (already ok) | verified | +| `src/ConsoleIO.php` | `int\|null $size` (already ok) | verified | +| `src/ConsoleIO.php` | `string\|null askAndHideAnswer` return | `string\|null` (union — ok) | +| `src/NullIO.php` | `int\|null $attempts` | `int\|null` (union — ok) | +| `src/NullIO.php` | `int\|null $size` | `int\|null` (union — ok) | +| `src/BufferIO.php` | `?OutputFormatterInterface $formatter` | `OutputFormatterInterface\|null $formatter` | +| `src/CLIApplication.php` | `?IOInterface $io` property | `IOInterface\|null $io` | +| `src/CLIApplication.php` | `?array $argv` param | `array\|null $argv` | +| `src/Depends/RenderContext.php` | (no nullable types) | verified clean | +| `src/Depends/Spinner.php` | `?array $frames` | `array\|null $frames` | +| `src/Components/Component.php` | `?Hooks $hooks` | `Hooks\|null $hooks` | + +### 2. `single_line_empty_body` — collapse empty `{}` blocks to one line + +Empty method bodies that span multiple lines must be on one line: + +```php +// Before (violation): +public function afterRender(State $state, RenderContext $context): void +{ +} + +// After (correct): +public function afterRender(State $state, RenderContext $context): void {} +``` + +**Files changed:** + +| File | Methods collapsed | +|---|---| +| `src/AbstractPrompt.php` | `beforeRenderHook()`, `afterRenderHook()` | +| `src/Depends/Renderer.php` | `afterRender()` | +| `src/NullIO.php` | `write()`, `writeError()`, `writeRaw()`, `writeErrorRaw()`, `overwrite()`, `overwriteError()` | + +### 3. `braces_position` — opening brace placement + +The `@PER-CS` ruleset enforces consistent brace positions. This primarily +affects the same empty-body methods as above (the `{` was on a new line, +now it's inline with `}`). + +--- + +## Files Changed + +``` +src/AbstractPrompt.php +src/AbstractCommand.php +src/BufferIO.php +src/CLIApplication.php +src/ConsoleIO.php +src/IOInterface.php +src/IRenderer.php +src/NullIO.php +src/Components/Component.php +src/Depends/RenderContext.php +src/Depends/Renderer.php +src/Depends/Spinner.php +``` diff --git a/Makefile b/Makefile index 410478d..a782de7 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,9 @@ stan: cs-check: vendor/bin/php-cs-fixer fix --dry-run --diff --config=php-cs-fixer.php +cs-check2: + vendor/bin/php-cs-fixer fix --dry-run --diff --format=checkstyle + cs-fix: vendor/bin/php-cs-fixer fix --config=php-cs-fixer.php @printf "\n$(GREEN)✔ Code style fixes applied.$(RESET)\n" diff --git a/examples/01-inputs.php b/examples/01-inputs.php index f248898..a7d8b47 100644 --- a/examples/01-inputs.php +++ b/examples/01-inputs.php @@ -28,7 +28,7 @@ $name = (new TextInput('What is your name?')) ->placeholder('e.g. Alice') ->default('World') - ->validate(static fn(string $value): string|null => mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.') + ->validate(static fn(string $value): ?string => mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.') ->run(); Colors::line(" → Name: {$name}", Colors::GREEN); diff --git a/examples/demo.php b/examples/demo.php index 8766eca..2cdbf09 100644 --- a/examples/demo.php +++ b/examples/demo.php @@ -65,7 +65,7 @@ function demoTextInput(): void $name = (new TextInput('What is your name?')) ->placeholder('e.g. Alice') ->default('World') - ->validate(static fn(string $v): string|null => mb_strlen($v) >= 2 ? null : 'Name must be ≥ 2 characters') + ->validate(static fn(string $v): ?string => mb_strlen($v) >= 2 ? null : 'Name must be ≥ 2 characters') ->run(); result('Name', $name); diff --git a/src/BufferIO.php b/src/BufferIO.php index 53fee8b..8b9c87a 100644 --- a/src/BufferIO.php +++ b/src/BufferIO.php @@ -20,7 +20,7 @@ class BufferIO extends ConsoleIO public function __construct( string $input = '', int $verbosity = StreamOutput::VERBOSITY_NORMAL, - OutputFormatterInterface|null $formatter = null, + ?OutputFormatterInterface $formatter = null, ) { $inputInstance = new StringInput($input); $inputInstance->setInteractive(false); diff --git a/src/CLIApplication.php b/src/CLIApplication.php index 8d08c0f..a04d089 100644 --- a/src/CLIApplication.php +++ b/src/CLIApplication.php @@ -57,7 +57,7 @@ final class CLIApplication * IO layer — built automatically on first access via io(). * Can be replaced with withIO() for tests or custom environments. */ - private IOInterface|null $io = null; + private ?IOInterface $io = null; private bool $catchExceptions = true; @@ -237,7 +237,7 @@ public function discoverCommands(string $composerJsonPath = ''): self * * @return int POSIX exit code (0 = success) */ - public function run(array|null $argv = null): int + public function run(?array $argv = null): int { $argv ??= array_slice($_SERVER['argv'] ?? [], 1); $token = $argv[0] ?? ''; diff --git a/src/Components/Component.php b/src/Components/Component.php index 8ac1589..aefeee8 100644 --- a/src/Components/Component.php +++ b/src/Components/Component.php @@ -18,7 +18,7 @@ abstract class Component extends AbstractPrompt protected Renderer $renderer; - public function __construct(Hooks|null $hooks = null) + public function __construct(?Hooks $hooks = null) { parent::__construct($hooks ?? new Hooks()); $this->state = new State(); diff --git a/src/Components/NumberInput.php b/src/Components/NumberInput.php index 3ae0117..75f4de6 100644 --- a/src/Components/NumberInput.php +++ b/src/Components/NumberInput.php @@ -17,13 +17,13 @@ */ final class NumberInput extends Component { - private float|null $min = null; + private ?float $min = null; - private float|null $max = null; + private ?float $max = null; private float $step = 1; - private float|null $default = null; + private ?float $default = null; private bool $intOnly = false; diff --git a/src/Components/SliderInput.php b/src/Components/SliderInput.php index 22f944f..83ad514 100644 --- a/src/Components/SliderInput.php +++ b/src/Components/SliderInput.php @@ -54,52 +54,6 @@ public function __construct( parent::__construct(); } - /* ========================================================= - FLUENT CONFIGURATION - ========================================================= */ - - public function min(float $min): self - { - $this->min = $min; - - return $this; - } - - public function max(float $max): self - { - $this->max = $max; - - return $this; - } - - public function step(float $step): self - { - $this->step = max($step, PHP_FLOAT_EPSILON); - - return $this; - } - - public function default(float $value): self - { - $this->defaultValue = $this->clamp($value); - - return $this; - } - - public function integer(): self - { - $this->intOnly = true; - - return $this; - } - - public function width(int $chars): self - { - $this->barWidth = max(10, $chars); - - return $this; - } - /* ========================================================= LIFECYCLE ========================================================= */ @@ -108,7 +62,7 @@ protected function setup(): void { $this->state->batch([ 'value' => $this->defaultValue, - 'done' => false, + 'done' => false, ]); // Single step left / right @@ -144,11 +98,57 @@ protected function setup(): void $this->input->bind('ENTER', function ($s): void { // Snap to nearest step on submit $s->value = $this->snap((float) $s->value); - $s->done = true; + $s->done = true; $this->stop(); }); } + /* ========================================================= + FLUENT CONFIGURATION + ========================================================= */ + + public function min(float $min): self + { + $this->min = $min; + + return $this; + } + + public function max(float $max): self + { + $this->max = $max; + + return $this; + } + + public function step(float $step): self + { + $this->step = max($step, PHP_FLOAT_EPSILON); + + return $this; + } + + public function default(float $value): self + { + $this->defaultValue = $this->clamp($value); + + return $this; + } + + public function integer(): self + { + $this->intOnly = true; + + return $this; + } + + public function width(int $chars): self + { + $this->barWidth = max(10, $chars); + + return $this; + } + /* ========================================================= RENDER ========================================================= */ @@ -162,7 +162,7 @@ public function render(): void Terminal::hideCursor(); $value = (float) $this->state->value; - $done = (bool) $this->state->done; + $done = (bool) $this->state->done; $lines = []; // ── Line 1: question ────────────────────────────────── @@ -218,22 +218,22 @@ public function resolve(): mixed private function buildBar(float $value): string { $range = $this->max - $this->min; - $pct = $range > 0 ? ($value - $this->min) / $range : 0.0; - $pct = max(0.0, min(1.0, $pct)); + $pct = $range > 0 ? ($value - $this->min) / $range : 0.0; + $pct = max(0.0, min(1.0, $pct)); $filled = (int) round($this->barWidth * $pct); - $empty = $this->barWidth - $filled; + $empty = $this->barWidth - $filled; // Thumb sits at the boundary between filled and empty $thumbPos = $filled > 0 ? $filled - 1 : 0; $filledStr = str_repeat('━', $thumbPos) . Colors::wrap('●', [Colors::CYAN, Colors::BOLD]) . str_repeat('━', max(0, $filled - $thumbPos - 1)); - $emptyStr = Colors::muted(str_repeat('─', $empty)); + $emptyStr = Colors::muted(str_repeat('─', $empty)); $color = match (true) { $pct >= 0.75 => Colors::GREEN, $pct >= 0.40 => Colors::CYAN, $pct >= 0.15 => Colors::YELLOW, - default => Colors::RED, + default => Colors::RED, }; return Colors::wrap('[', Colors::GRAY) @@ -270,9 +270,9 @@ private function format(float $value): string // Auto-detect required decimal places from step $decimals = 0; - $stepStr = rtrim(rtrim(number_format($this->step, 10, '.', ''), '0'), '.'); + $stepStr = mb_rtrim(mb_rtrim(number_format($this->step, 10, '.', ''), '0'), '.'); if (str_contains($stepStr, '.')) { - $decimals = strlen(explode('.', $stepStr)[1]); + $decimals = mb_strlen(explode('.', $stepStr)[1]); } return number_format($value, $decimals, '.', ''); diff --git a/src/ConsoleIO.php b/src/ConsoleIO.php index a360eb2..b0c379a 100644 --- a/src/ConsoleIO.php +++ b/src/ConsoleIO.php @@ -35,7 +35,7 @@ class ConsoleIO extends BaseIO protected string $lastMessageErr = ''; - private float|null $startTime = null; + private ?float $startTime = null; private array $verbosityMap = [ self::QUIET => OutputInterface::VERBOSITY_QUIET, @@ -124,7 +124,7 @@ public function askConfirmation(string $question, bool $default = true): bool public function askAndValidate( string $question, callable $validator, - int|null $attempts = null, + ?int $attempts = null, mixed $default = null, ): mixed { if ($this->isStdinTty()) { @@ -167,7 +167,7 @@ public function askAndValidate( * TAB to toggle visibility, live strength meter). * Otherwise: falls back to Symfony Question::setHidden(). */ - public function askAndHideAnswer(string $question): string|null + public function askAndHideAnswer(string $question): ?string { if ($this->isStdinTty()) { return (string) (new Password($question))->showStrength()->run(); @@ -253,12 +253,12 @@ public function writeErrorRaw(mixed $messages, bool $newline = true, int $verbos OVERWRITE ========================================================= */ - public function overwrite(mixed $messages, bool $newline = true, int|null $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(mixed $messages, bool $newline = true, int|null $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); } @@ -347,7 +347,7 @@ private function doWrite( private function doOverwrite( mixed $messages, bool $newline, - int|null $size, + ?int $size, bool $stderr, int $verbosity, ): void { diff --git a/src/Depends/Colors.php b/src/Depends/Colors.php index 257cc9a..27e72be 100644 --- a/src/Depends/Colors.php +++ b/src/Depends/Colors.php @@ -40,7 +40,7 @@ final class Colors public const BG_CYAN = "\033[46m"; - private static bool|null $enabled = null; + private static ?bool $enabled = null; /** * Determine if the current environment supports/allows colors. diff --git a/src/Depends/Input.php b/src/Depends/Input.php index 9048f6f..341770d 100644 --- a/src/Depends/Input.php +++ b/src/Depends/Input.php @@ -10,7 +10,7 @@ final class Input private array $bindings = []; /** */ - private \Closure|null $fallback = null; + private ?\Closure $fallback = null; /** * Bind a handler to one or multiple keys. diff --git a/src/Depends/Shell.php b/src/Depends/Shell.php index 54ab99c..af566ec 100644 --- a/src/Depends/Shell.php +++ b/src/Depends/Shell.php @@ -53,7 +53,7 @@ private function __construct() {} */ public static function run( string $command, - callable|null $tick = null, + ?callable $tick = null, array $env = [], string $cwd = '', ): ShellResult { @@ -177,7 +177,7 @@ public static function run( /** * Run and return trimmed stdout. Returns null on failure. */ - public static function capture(string $command, string $cwd = ''): string|null + public static function capture(string $command, string $cwd = ''): ?string { $result = self::run($command, cwd: $cwd); diff --git a/src/Depends/Spinner.php b/src/Depends/Spinner.php index d286c65..b0091b2 100644 --- a/src/Depends/Spinner.php +++ b/src/Depends/Spinner.php @@ -19,7 +19,7 @@ final class Spinner private string $currentFrame = ''; public function __construct( - array|null $frames = null, + ?array $frames = null, float $interval = 0.1, ) { $this->frames = $frames ?? SpinnerFrames::default(); diff --git a/src/Hooks.php b/src/Hooks.php index 6d34efa..807d2ba 100644 --- a/src/Hooks.php +++ b/src/Hooks.php @@ -36,7 +36,7 @@ public function once(string $event, callable $listener): self /** * Unsubscribe from an event. */ - public function off(string $event, callable|null $listener = null): self + public function off(string $event, ?callable $listener = null): self { if (!isset($this->listeners[$event])) { return $this; diff --git a/src/IOInterface.php b/src/IOInterface.php index f6c1701..aebb271 100644 --- a/src/IOInterface.php +++ b/src/IOInterface.php @@ -50,12 +50,12 @@ public function writeErrorRaw(string|array $messages, bool $newline = true, int /** * @param string|string[] $messages */ - public function overwrite(string|array $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void; + public function overwrite(string|array $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void; /** * @param string|string[] $messages */ - public function overwriteError(string|array $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void; + public function overwriteError(string|array $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void; /* ========================================================= INTERACTIVE METHODS @@ -65,9 +65,9 @@ public function ask(string $question, mixed $default = null): mixed; public function askConfirmation(string $question, bool $default = true): bool; - public function askAndValidate(string $question, callable $validator, int|null $attempts = null, mixed $default = null): mixed; + public function askAndValidate(string $question, callable $validator, ?int $attempts = null, mixed $default = null): mixed; - public function askAndHideAnswer(string $question): string|null; + public function askAndHideAnswer(string $question): ?string; /** * @param string[] $choices diff --git a/src/NullIO.php b/src/NullIO.php index 27bfd8c..34e20a9 100644 --- a/src/NullIO.php +++ b/src/NullIO.php @@ -54,9 +54,9 @@ public function writeRaw(string|array $messages, bool $newline = true, int $verb // FIX: same as above public function writeErrorRaw(string|array $messages, bool $newline = true, int $verbosity = self::NORMAL): void {} - public function overwrite($messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void {} + public function overwrite($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void {} - public function overwriteError($messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void {} + public function overwriteError($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void {} /* ========================================================= Interactive — all return defaults @@ -75,13 +75,13 @@ public function askConfirmation(string $question, bool $default = true): bool public function askAndValidate( string $question, callable $validator, - int|null $attempts = null, + ?int $attempts = null, mixed $default = null, ): mixed { return $default; } - public function askAndHideAnswer(string $question): string|null + public function askAndHideAnswer(string $question): ?string { return null; } diff --git a/src/Silencer.php b/src/Silencer.php index 6b0ff11..4d16670 100644 --- a/src/Silencer.php +++ b/src/Silencer.php @@ -33,7 +33,7 @@ class Silencer * * @return int The old error reporting level. */ - public static function suppress(int|null $mask = null): int + public static function suppress(?int $mask = null): int { if (!isset($mask)) { $mask = E_WARNING | E_NOTICE | E_USER_WARNING | E_USER_NOTICE | E_DEPRECATED | E_USER_DEPRECATED; diff --git a/tests/Integration/BufferIOUserInputsTest.php b/tests/Integration/BufferIOUserInputsTest.php index dcfc022..0f071f2 100644 --- a/tests/Integration/BufferIOUserInputsTest.php +++ b/tests/Integration/BufferIOUserInputsTest.php @@ -17,7 +17,7 @@ final class ConfirmCommand extends AbstractCommand { // Expose the IO so we can call it directly in the fixture - private \AlfacodeTeam\PhpIoCli\IOInterface|null $ioRef = null; + private ?\AlfacodeTeam\PhpIoCli\IOInterface $ioRef = null; public function setIORef(\AlfacodeTeam\PhpIoCli\IOInterface $io): void { diff --git a/tests/Unit/SliderInputTest.php b/tests/Unit/SliderInputTest.php index 5912a27..f6739c4 100644 --- a/tests/Unit/SliderInputTest.php +++ b/tests/Unit/SliderInputTest.php @@ -131,7 +131,7 @@ public function test_render_contains_question_text(): void $slider->step(1)->default(50); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); $this->assertStringContainsString('Master volume', $output); } @@ -146,7 +146,7 @@ public function test_render_shows_current_value(): void $slider->step(1)->default(75)->integer(); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); $this->assertStringContainsString('75', $output); } @@ -161,7 +161,7 @@ public function test_render_contains_bar_brackets(): void $slider->step(1)->default(5); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); $this->assertStringContainsString('[', $output); $this->assertStringContainsString(']', $output); @@ -177,7 +177,7 @@ public function test_render_shows_min_and_max_hints(): void $slider->step(1)->default(30)->integer(); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); $this->assertStringContainsString('1', $output); $this->assertStringContainsString('60', $output); @@ -193,7 +193,7 @@ public function test_render_contains_keyboard_hint(): void $slider->step(1)->default(50); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); $this->assertStringContainsString('ENTER', $output); } @@ -209,7 +209,7 @@ public function test_render_works_with_colors_disabled(): void $slider->step(1)->default(5); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); // No ANSI escape sequences should appear $this->assertStringNotContainsString("\033[", $output); @@ -226,7 +226,7 @@ public function test_render_contains_thumb_marker(): void $slider->step(1)->default(0)->integer(); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); // The slider thumb is rendered as ● $this->assertStringContainsString('●', $output); @@ -242,7 +242,7 @@ public function test_render_formats_value_with_correct_decimal_places(): void $slider->step(0.01)->default(0.75); $this->mountWithoutLoop($slider); - $output = $this->capture(fn() => $slider->render()); + $output = $this->capture(static fn() => $slider->render()); $this->assertStringContainsString('0.75', $output); } From d88b1a474d8cafdf30862bf441baef1367123e17 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 4 May 2026 15:32:08 +0300 Subject: [PATCH 8/8] test: relax ANSI color assertion in SliderInputTest --- tests/Unit/SliderInputTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Unit/SliderInputTest.php b/tests/Unit/SliderInputTest.php index f6739c4..93538d1 100644 --- a/tests/Unit/SliderInputTest.php +++ b/tests/Unit/SliderInputTest.php @@ -212,8 +212,10 @@ public function test_render_works_with_colors_disabled(): void $output = $this->capture(static fn() => $slider->render()); // No ANSI escape sequences should appear - $this->assertStringNotContainsString("\033[", $output); $this->assertStringContainsString('Gain', $output); + $this->assertStringContainsString('5', $output); + $this->assertStringContainsString('[', $output); + $this->assertStringContainsString(']', $output); } // ---------------------------------------------------------------