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..e129455 --- /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 --no-coverage + +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 --allow-unsupported-php-version=yes --config=php-cs-fixer.php + +cs-fix: + 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 ─────────────────────────────────────────────────────────────── +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..17275d6 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,31 +52,54 @@ } }, "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 --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", + + "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": { @@ -91,4 +114,4 @@ } } } -} \ No newline at end of file +} 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 new file mode 100644 index 0000000..8766eca --- /dev/null +++ b/examples/demo.php @@ -0,0 +1,340 @@ +#!/usr/bin/env php +placeholder('e.g. Alice') + ->default('World') + ->validate(static fn(string $v): string|null => 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: static 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..932ebb5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,38 +1,23 @@ - - - - - tests/Unit - - - tests/Integration - - - - - - src - - - src/Silencer.php - - - - - - - - - - - - - + + + + tests/Unit + + + tests/Integration + + + + + + + + + src + + + src/Silencer.php + + 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 2de7d6a..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,21 +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() - && function_exists('posix_isatty') - && @posix_isatty(STDIN); - } - /* ========================================================= INTERACTIVE — ask (plain text) ========================================================= */ @@ -96,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), ); } @@ -120,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), ); } @@ -138,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(); @@ -180,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(); @@ -208,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) { @@ -261,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; @@ -277,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; @@ -294,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); @@ -334,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 39805ff..54ab99c 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. + * Enterprise Shell Wrapper * - * Features + * v1-> Features * ───────── * • Streams stdout AND stderr simultaneously with stream_select() so neither * pipe can block the other (the classic deadlock trap with proc_open). @@ -16,6 +16,11 @@ * • 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 …'); @@ -48,112 +53,119 @@ 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 — 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), - $fullEnv + $cwd !== '' ? $cwd : null, + $fullEnv, ); if (!is_resource($process)) { 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 = 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 = 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; } - // ── Tick callback (animation + last-line sub-label) ─ + // --- 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 = mb_rtrim($stdoutBuf)) !== '') { + $stdout[] = $trimmed; + $lastLine = $trimmed; + $lastIsStderr = false; + } + if (($trimmed = mb_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,12 @@ 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 + 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; } } 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 ecdee54..81fdf0d 100644 --- a/tests/Integration/AbstractCommandTest.php +++ b/tests/Integration/AbstractCommandTest.php @@ -6,6 +6,7 @@ use AlfacodeTeam\PhpIoCli\AbstractCommand; use AlfacodeTeam\PhpIoCli\BufferIO; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -17,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); @@ -27,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++) { @@ -57,6 +58,7 @@ protected function configure(): void protected function handle(): int { $this->success((string) $this->argument('name')); + return self::SUCCESS; } } @@ -90,15 +92,14 @@ protected function configure(): void protected function handle(): int { $this->error('Explicit failure'); + return self::FAILURE; } } // ── Tests ───────────────────────────────────────────────────────────────────── -/** - * @covers \AlfacodeTeam\PhpIoCli\AbstractCommand - */ +#[CoversClass(AbstractCommand::class)] final class AbstractCommandTest extends TestCase { // --------------------------------------------------------------- @@ -107,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); @@ -117,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); @@ -131,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); @@ -141,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); @@ -151,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')); } // --------------------------------------------------------------- @@ -178,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); @@ -192,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); @@ -202,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); @@ -234,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 d20dd7c..0a15f0d 100644 --- a/tests/Integration/BufferIOTest.php +++ b/tests/Integration/BufferIOTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Integration; use AlfacodeTeam\PhpIoCli\BufferIO; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\BufferIO - */ +#[CoversClass(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..dcfc022 --- /dev/null +++ b/tests/Integration/BufferIOUserInputsTest.php @@ -0,0 +1,263 @@ +ioRef = $io; + } + + protected function configure(): void + { + $this->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; + } + + 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(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..1c0f431 100644 --- a/tests/Integration/CLIApplicationTest.php +++ b/tests/Integration/CLIApplicationTest.php @@ -7,6 +7,7 @@ use AlfacodeTeam\PhpIoCli\AbstractCommand; use AlfacodeTeam\PhpIoCli\BufferIO; use AlfacodeTeam\PhpIoCli\CLIApplication; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -15,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; } } @@ -30,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); } @@ -38,33 +40,23 @@ protected function configure(): void protected function handle(): int { $this->info('Hello, ' . $this->argument('name') . '!'); + return self::SUCCESS; } } // ── Tests ───────────────────────────────────────────────────────────────────── -/** - * @covers \AlfacodeTeam\PhpIoCli\CLIApplication - */ +#[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()); @@ -77,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()); @@ -93,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']); @@ -105,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()); @@ -119,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()); @@ -136,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']); @@ -177,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)); } // --------------------------------------------------------------- @@ -200,6 +192,7 @@ protected function configure(): void { $this->name = 'boom'; } + protected function handle(): int { throw new \RuntimeException('Boom!'); @@ -216,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 new file mode 100644 index 0000000..08b191b --- /dev/null +++ b/tests/Integration/ShellTest.php @@ -0,0 +1,210 @@ +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: static 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: static 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(mb_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..e3c564e --- /dev/null +++ b/tests/Unit/AlertTest.php @@ -0,0 +1,228 @@ +capture(static fn() => Alert::success('Deployment complete!')); + + $this->assertStringContainsString('Deployment complete!', $output); + } + + public function test_success_contains_checkmark_icon(): void + { + $output = $this->capture(static fn() => Alert::success('Done')); + + $this->assertStringContainsString('✔', $output); + } + + public function test_success_renders_body_lines(): void + { + $output = $this->capture( + static 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(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, '└'); + $this->assertTrue($hasBorder, 'Expected Unicode box border characters in output'); + } + + // --------------------------------------------------------------- + // error + // --------------------------------------------------------------- + + public function test_error_contains_title(): void + { + $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(static fn() => Alert::error('Build failed')); + + $this->assertStringContainsString('✘', $output); + } + + public function test_error_renders_body(): void + { + $output = $this->capture( + static 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(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(static fn() => Alert::warning('Watch out')); + + $this->assertStringContainsString('!', $output); + } + + public function test_warning_renders_body(): void + { + $output = $this->capture( + static 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(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(static fn() => Alert::info('Note')); + + $this->assertStringContainsString('i', $output); + } + + public function test_info_renders_body(): void + { + $output = $this->capture( + static 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( + static 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(static 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(static fn() => Alert::block('critical error')); + + $this->assertStringContainsString('CRITICAL ERROR', $output); + } + + public function test_block_renders_body_lines(): void + { + $output = $this->capture( + static 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(static 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(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 9838305..ff074ec 100644 --- a/tests/Unit/ColorsTest.php +++ b/tests/Unit/ColorsTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\Colors; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Colors - */ +#[CoversClass(Colors::class)] final class ColorsTest extends TestCase { protected function setUp(): void @@ -102,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); @@ -110,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); @@ -118,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); @@ -133,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 7b32c28..70792f6 100644 --- a/tests/Unit/FuzzyTest.php +++ b/tests/Unit/FuzzyTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\Fuzzy; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Fuzzy - */ +#[CoversClass(Fuzzy::class)] final class FuzzyTest extends TestCase { // --------------------------------------------------------------- @@ -101,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); @@ -129,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 447f3e0..0179b4e 100644 --- a/tests/Unit/HooksTest.php +++ b/tests/Unit/HooksTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Hooks; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Hooks - */ +#[CoversClass(Hooks::class)] final class HooksTest extends TestCase { private Hooks $hooks; @@ -27,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; }); @@ -40,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; }); @@ -53,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'; }); @@ -83,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++; }); @@ -102,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++; }; @@ -117,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++; }); @@ -144,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; }); @@ -167,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'); @@ -187,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 b9c0cec..a5c52b6 100644 --- a/tests/Unit/InputTest.php +++ b/tests/Unit/InputTest.php @@ -6,11 +6,10 @@ use AlfacodeTeam\PhpIoCli\Depends\Input; use AlfacodeTeam\PhpIoCli\Depends\State; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Input - */ +#[CoversClass(Input::class)] final class InputTest extends TestCase { // --------------------------------------------------------------- @@ -23,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; }); @@ -34,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; }); @@ -66,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; }); @@ -84,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; }); @@ -102,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; }); @@ -124,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 }); @@ -149,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++; }); @@ -177,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 4f4d166..94c4694 100644 --- a/tests/Unit/KeyTest.php +++ b/tests/Unit/KeyTest.php @@ -5,20 +5,18 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\Key; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\Key - */ +#[CoversClass(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)); @@ -27,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'], ]; } @@ -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)); @@ -77,17 +73,15 @@ 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' => ['~'], ]; } - /** - * @dataProvider nonPrintableCharProvider - */ + #[DataProvider('nonPrintableCharProvider')] public function test_is_printable_returns_false_for_control_chars(string $char): void { $this->assertFalse(Key::isPrintable($char)); @@ -96,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 a9890c9..55199ec 100644 --- a/tests/Unit/NullIOTest.php +++ b/tests/Unit/NullIOTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\NullIO; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\NullIO - */ +#[CoversClass(NullIO::class)] final class NullIOTest extends TestCase { private NullIO $io; @@ -70,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 64afb11..ee1b169 100644 --- a/tests/Unit/RenderContextTest.php +++ b/tests/Unit/RenderContextTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\RenderContext; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\RenderContext - */ +#[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 new file mode 100644 index 0000000..142ee9a --- /dev/null +++ b/tests/Unit/RendererTest.php @@ -0,0 +1,315 @@ +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(static 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(static 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(static 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(static 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(static 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(static 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(static 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(static 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(static 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(static fn($i) => "Item-{$i}", range(1, 20)); + $state = $this->makeState(['items' => $items, 'index' => 0]); + $ctx = new RenderContext(); + + $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); + } + + // --------------------------------------------------------------- + // 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(static 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(static 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(static 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(static 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(static 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(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 68e86d7..aeb3aab 100644 --- a/tests/Unit/ShellResultTest.php +++ b/tests/Unit/ShellResultTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\ShellResult; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\ShellResult - */ +#[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 new file mode 100644 index 0000000..6b95b26 --- /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..da03af3 --- /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..0afe043 100644 --- a/tests/Unit/StateTest.php +++ b/tests/Unit/StateTest.php @@ -5,11 +5,10 @@ namespace AlfacodeTeam\PhpIoCli\Tests\Unit; use AlfacodeTeam\PhpIoCli\Depends\State; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Depends\State - */ +#[CoversClass(State::class)] final class StateTest extends TestCase { // --------------------------------------------------------------- @@ -51,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++; }); @@ -143,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; }); @@ -163,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'; }); @@ -180,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 12a55cd..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\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -/** - * @covers \AlfacodeTeam\PhpIoCli\Components\Table - */ +#[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 // --------------------------------------------------------------- @@ -70,9 +57,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( @@ -80,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); @@ -90,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'], ]; @@ -120,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); @@ -135,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(), + ); + } }