diff --git a/.actrc b/.actrc new file mode 100644 index 0000000..99e6b7e --- /dev/null +++ b/.actrc @@ -0,0 +1,3 @@ +# Configuration file for nektos/act. +# See https://github.com/nektos/act#configuration +-P ubuntu-latest=shivammathur/node:latest diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..84f918e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# WordPress Coding Standards +# https://make.wordpress.org/core/handbook/coding-standards/ + +# From https://github.com/WordPress/wordpress-develop/blob/trunk/.editorconfig with a couple of additions. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab + +[{*.yml,*.feature,.jshintrc,*.json}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[{*.txt,wp-config-sample.php}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d84f4ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/.actrc export-ignore +/.distignore export-ignore +/.editorconfig export-ignore +/.github export-ignore +/.gitignore export-ignore +/.typos.toml export-ignore +/AGENTS.md export-ignore +/behat.yml export-ignore +/features export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/wp-cli.yml export-ignore diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f69375f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @wp-cli/committers diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d6c7b8b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + labels: + - scope:distribution + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + labels: + - scope:distribution + diff --git a/.github/workflows/check-branch-alias.yml b/.github/workflows/check-branch-alias.yml new file mode 100644 index 0000000..78da637 --- /dev/null +++ b/.github/workflows/check-branch-alias.yml @@ -0,0 +1,14 @@ +name: Check Branch Alias + +on: + release: + types: [released] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + check-branch-alias: + uses: wp-cli/.github/.github/workflows/reusable-check-branch-alias.yml@main diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..e9fe577 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,14 @@ +name: Code Quality Checks + +on: + pull_request: + push: + branches: + - main + - master + schedule: + - cron: '17 2 * * *' # Run every day on a seemly random time. + +jobs: + code-quality: + uses: wp-cli/.github/.github/workflows/reusable-code-quality.yml@main diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..844ffe2 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,47 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +permissions: + contents: read + +jobs: + copilot-setup-steps: + name: Setup environment + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Check existence of composer.json file + id: check_composer_file + run: echo "files_exists=$(test -f composer.json && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Set up PHP environment + if: steps.check_composer_file.outputs.files_exists == 'true' + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: 'latest' + ini-values: zend.assertions=1, error_reporting=-1, display_errors=On + coverage: 'none' + tools: composer,cs2pr + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Composer dependencies & cache dependencies + if: steps.check_composer_file.outputs.files_exists == 'true' + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + env: + COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..6833470 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,33 @@ +--- +name: Issue and PR Triage + +'on': + issues: + types: [opened] + pull_request_target: + types: [opened] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue/PR number to triage (leave empty to process all)' + required: false + type: string + +permissions: + issues: write + pull-requests: write + actions: write + contents: read + models: read + +jobs: + issue-triage: + uses: wp-cli/.github/.github/workflows/reusable-issue-triage.yml@main + with: + issue_number: >- + ${{ + (github.event_name == 'workflow_dispatch' && inputs.issue_number) || + (github.event_name == 'pull_request_target' && github.event.pull_request.number) || + (github.event_name == 'issues' && github.event.issue.number) || + '' + }} diff --git a/.github/workflows/manage-labels.yml b/.github/workflows/manage-labels.yml new file mode 100644 index 0000000..45711bd --- /dev/null +++ b/.github/workflows/manage-labels.yml @@ -0,0 +1,19 @@ +--- +name: Manage Labels + +'on': + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'composer.json' + +permissions: + issues: write + contents: read + +jobs: + manage-labels: + uses: wp-cli/.github/.github/workflows/reusable-manage-labels.yml@main diff --git a/.github/workflows/regenerate-readme.yml b/.github/workflows/regenerate-readme.yml new file mode 100644 index 0000000..6198d63 --- /dev/null +++ b/.github/workflows/regenerate-readme.yml @@ -0,0 +1,19 @@ +name: Regenerate README file + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths-ignore: + - "features/**" + - "README.md" + +permissions: + contents: write + pull-requests: write + +jobs: + regenerate-readme: + uses: wp-cli/.github/.github/workflows/reusable-regenerate-readme.yml@main diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..bf67592 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,15 @@ +name: Testing + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - master + schedule: + - cron: '17 1 * * *' # Run every day on a seemly random time. + +jobs: + test: + uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main diff --git a/.github/workflows/welcome-new-contributors.yml b/.github/workflows/welcome-new-contributors.yml new file mode 100644 index 0000000..bc01490 --- /dev/null +++ b/.github/workflows/welcome-new-contributors.yml @@ -0,0 +1,15 @@ +name: Welcome New Contributors + +on: + pull_request_target: + types: [opened] + branches: + - main + - master + +permissions: + pull-requests: write + +jobs: + welcome: + uses: wp-cli/.github/.github/workflows/reusable-welcome-new-contributors.yml@main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..379cd9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +vendor +.*.swp +composer.lock +.phpunit.result.cache diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ff84f6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# Instructions + +This package is part of WP-CLI, the official command line interface for WordPress. For a detailed explanation of the project structure and development workflow, please refer to the main @README.md file. + +## Best Practices for Code Contributions + +When contributing to this package, please adhere to the following guidelines: + +* **Follow Existing Conventions:** Before writing any code, analyze the existing codebase in this package to understand the coding style, naming conventions, and architectural patterns. +* **Focus on the Package's Scope:** All changes should be relevant to the functionality of the package. +* **Write Tests:** All new features and bug fixes must be accompanied by acceptance tests using Behat. You can find the existing tests in the `features/` directory. There may be PHPUnit unit tests as well in the `tests/` directory. +* **Update Documentation:** If your changes affect the user-facing functionality, please update the relevant inline code documentation. + +### Building and running + +Before submitting any changes, it is crucial to validate them by running the full suite of static code analysis and tests. To run the full suite of checks, execute the following command: `composer test`. + +This single command ensures that your changes meet all the quality gates of the project. While you can run the individual steps separately, it is highly recommended to use this single command to ensure a comprehensive validation. + +### Useful Composer Commands + +The project uses Composer to manage dependencies and run scripts. The following commands are available: + +* `composer install`: Install dependencies. +* `composer test`: Run the full test suite, including linting, code style checks, static analysis, and unit/behavior tests. +* `composer lint`: Check for syntax errors. +* `composer phpcs`: Check for code style violations. +* `composer phpcbf`: Automatically fix code style violations. +* `composer phpstan`: Run static analysis. +* `composer phpunit`: Run unit tests. +* `composer behat`: Run behavior-driven tests. + +### Coding Style + +The project follows the `WP_CLI_CS` coding standard, which is enforced by PHP_CodeSniffer. The configuration can be found in `phpcs.xml.dist`. Before submitting any code, please run `composer phpcs` to check for violations and `composer phpcbf` to automatically fix them. + +## Documentation + +The `README.md` file might be generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). In that case, changes need to be made against the corresponding part of the codebase. + +### Inline Documentation + +Only write high-value comments if at all. Avoid talking to the user through comments. + +## Testing + +The project has a comprehensive test suite that includes unit tests, behavior-driven tests, and static analysis. + +* **Unit tests** are written with PHPUnit and can be found in the `tests/` directory. The configuration is in `phpunit.xml.dist`. +* **Behavior-driven tests** are written with Behat and can be found in the `features/` directory. The configuration is in `behat.yml`. +* **Static analysis** is performed with PHPStan. + +All tests are run on GitHub Actions for every pull request. + +When writing tests, aim to follow existing patterns. Key conventions include: + +* When adding tests, first examine existing tests to understand and conform to established conventions. +* For unit tests, extend the base `WP_CLI\Tests\TestCase` test class. +* For Behat tests, only WP-CLI commands installed in `composer.json` can be run. + +### Behat Steps + +WP-CLI makes use of a Behat-based testing framework and provides a set of custom step definitions to write feature tests. + +> **Note:** If you are expecting an error output in a test, you need to use `When I try ...` instead of `When I run ...` . + +#### Given + +* `Given an empty directory` - Creates an empty directory. +* `Given /^an? (empty|non-existent) ([^\s]+) directory$/` - Creates or deletes a specific directory. +* `Given an empty cache` - Clears the WP-CLI cache directory. +* `Given /^an? ([^\s]+) (file|cache file):$/` - Creates a file with the given contents. +* `Given /^"([^"]+)" replaced with "([^"]+)" in the ([^\s]+) file$/` - Search and replace a string in a file using regex. +* `Given /^that HTTP requests to (.*?) will respond with:$/` - Mock HTTP requests to a given URL. +* `Given WP files` - Download WordPress files without installing. +* `Given wp-config.php` - Create a wp-config.php file using `wp config create`. +* `Given a database` - Creates an empty database. +* `Given a WP install(ation)` - Installs WordPress. +* `Given a WP install(ation) in :subdir` - Installs WordPress in a given directory. +* `Given a WP install(ation) with Composer` - Installs WordPress with Composer. +* `Given a WP install(ation) with Composer and a custom vendor directory :vendor_directory` - Installs WordPress with Composer and a custom vendor directory. +* `Given /^a WP multisite (subdirectory|subdomain)?\s?(install|installation)$/` - Installs WordPress Multisite. +* `Given these installed and active plugins:` - Installs and activates one or more plugins. +* `Given a custom wp-content directory` - Configure a custom `wp-content` directory. +* `Given download:` - Download multiple files into the given destinations. +* `Given /^save (STDOUT|STDERR) ([\'].+[^\'])?\s?as \{(\w+)\}$/` - Store STDOUT or STDERR contents in a variable. +* `Given /^a new Phar with (?:the same version|version "([^"]+)")$/` - Build a new WP-CLI Phar file with a given version. +* `Given /^a downloaded Phar with (?:the same version|version "([^"]+)")$/` - Download a specific WP-CLI Phar version from GitHub. +* `Given /^save the (.+) file ([\'].+[^\'])? as \{(\w+)\}$/` - Stores the contents of the given file in a variable. +* `Given a misconfigured WP_CONTENT_DIR constant directory` - Modify wp-config.php to set `WP_CONTENT_DIR` to an empty string. +* `Given a dependency on current wp-cli` - Add `wp-cli/wp-cli` as a Composer dependency. +* `Given a PHP built-in web server` - Start a PHP built-in web server in the current directory. +* `Given a PHP built-in web server to serve :subdir` - Start a PHP built-in web server in the given subdirectory. + +#### When + +* ``When /^I launch in the background `([^`]+)`$/`` - Launch a given command in the background. +* ``When /^I (run|try) `([^`]+)`$/`` - Run or try a given command. +* ``When /^I (run|try) `([^`]+)` from '([^\s]+)'$/`` - Run or try a given command in a subdirectory. +* `When /^I (run|try) the previous command again$/` - Run or try the previous command again. + +#### Then + +* `Then /^the return code should( not)? be (\d+)$/` - Expect a specific exit code of the previous command. +* `Then /^(STDOUT|STDERR) should( strictly)? (be|contain|not contain):$/` - Check the contents of STDOUT or STDERR. +* `Then /^(STDOUT|STDERR) should be a number$/` - Expect STDOUT or STDERR to be a numeric value. +* `Then /^(STDOUT|STDERR) should not be a number$/` - Expect STDOUT or STDERR to not be a numeric value. +* `Then /^STDOUT should be a table containing rows:$/` - Expect STDOUT to be a table containing the given rows. +* `Then /^STDOUT should end with a table containing rows:$/` - Expect STDOUT to end with a table containing the given rows. +* `Then /^STDOUT should be JSON containing:$/` - Expect valid JSON output in STDOUT. +* `Then /^STDOUT should be a JSON array containing:$/` - Expect valid JSON array output in STDOUT. +* `Then /^STDOUT should be CSV containing:$/` - Expect STDOUT to be CSV containing certain values. +* `Then /^STDOUT should be YAML containing:$/` - Expect STDOUT to be YAML containing certain content. +* `Then /^(STDOUT|STDERR) should be empty$/` - Expect STDOUT or STDERR to be empty. +* `Then /^(STDOUT|STDERR) should not be empty$/` - Expect STDOUT or STDERR not to be empty. +* `Then /^(STDOUT|STDERR) should be a version string (<|<=|>|>=|==|=|<>) ([+\w.{}-]+)$/` - Expect STDOUT or STDERR to be a version string comparing to the given version. +* `Then /^the (.+) (file|directory) should( strictly)? (exist|not exist|be:|contain:|not contain):$/` - Expect a certain file or directory to (not) exist or (not) contain certain contents. +* `Then /^the contents of the (.+) file should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match file contents against a regex. +* `Then /^(STDOUT|STDERR) should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match STDOUT or STDERR against a regex. +* `Then /^an email should (be sent|not be sent)$/` - Expect an email to be sent (or not). +* `Then the HTTP status code should be :code` - Expect the HTTP status code for visiting `http://localhost:8080`. diff --git a/README.md b/README.md index 18be584..0c9b5e3 100644 --- a/README.md +++ b/README.md @@ -5,43 +5,56 @@ A collection of functions and classes to assist with command line development. Requirements - * PHP >= 5.3 + * PHP >= 5.6 + +Suggested PHP extensions + + * mbstring - Used for calculating string widths. Function List ------------- - * `\cli\out($msg, ...)` - * `\cli\out_padded($msg, ...)` - * `\cli\err($msg, ...)` - * `\cli\line($msg = '', ...)` - * `\cli\input()` - * `\cli\prompt($question, $default = false, $marker = ':')` - * `\cli\choose($question, $choices = 'yn', $default = 'n')` - * `\cli\menu($items, $default = false, $title = 'Choose an Item')` + * `cli\out($msg, ...)` + * `cli\out_padded($msg, ...)` + * `cli\err($msg, ...)` + * `cli\line($msg = '', ...)` + * `cli\input()` + * `cli\prompt($question, $default = false, $marker = ':')` + * `cli\choose($question, $choices = 'yn', $default = 'n')` + * `cli\menu($items, $default = false, $title = 'Choose an Item')` Progress Indicators ------------------- - * `\cli\notifier\Dots($msg, $dots = 3, $interval = 100)` - * `\cli\notifier\Spinner($msg, $interval = 100)` - * `\cli\progress\Bar($msg, $total, $interval = 100)` + * `cli\notify\Dots($msg, $dots = 3, $interval = 100)` + * `cli\notify\Spinner($msg, $interval = 100)` + * `cli\progress\Bar($msg, $total, $interval = 100)` Tabular Display --------------- - * `\cli\Table::__construct(array $headers = null, array $rows = null)` - * `\cli\Table::setHeaders(array $headers)` - * `\cli\Table::setRows(array $rows)` - * `\cli\Table::setRenderer(\cli\table\Renderer $renderer)` - * `\cli\Table::addRow(array $row)` - * `\cli\Table::sort($column)` - * `\cli\Table::display()` + * `cli\Table::__construct(array $headers = null, array $rows = null)` + * `cli\Table::setHeaders(array $headers)` + * `cli\Table::setRows(array $rows)` + * `cli\Table::setRenderer(cli\table\Renderer $renderer)` + * `cli\Table::addRow(array $row)` + * `cli\Table::sort($column)` + * `cli\Table::display()` The display function will detect if output is piped and, if it is, render a tab delimited table instead of the ASCII table rendered for visual display. -You can also explicitly set the renderer used by calling `\cli\Table::setRenderer()` and giving it an instance of one -of the concrete `\cli\table\Renderer` classes. +You can also explicitly set the renderer used by calling `cli\Table::setRenderer()` and giving it an instance of one +of the concrete `cli\table\Renderer` classes. + +Tree Display +------------ + + * `cli\Tree::__construct()` + * `cli\Tree::setData(array $data)` + * `cli\Tree::setRenderer(cli\tree\Renderer $renderer)` + * `cli\Tree::render()` + * `cli\Tree::display()` Argument Parser --------------- @@ -50,12 +63,12 @@ Argument parsing uses a simple framework for taking a list of command line argum usually straight from `$_SERVER['argv']`, and parses the input against a set of defined rules. -Check `example_args.php` for an example. +Check `examples/arguments.php` for an example. Usage ----- -See `example.php` for examples. +See `examples/` directory for examples. Todo diff --git a/composer.json b/composer.json index e8d8e95..c9b58c0 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,16 @@ { - "name": "jlogsdon/cli", + "name": "wp-cli/php-cli-tools", "type": "library", "description": "Console utilities for PHP", "keywords": ["console", "cli"], - "homepage": "http://github.com/jlogsdon/php-cli-tools", + "homepage": "http://github.com/wp-cli/php-cli-tools", "license": "MIT", "authors": [ + { + "name": "Daniel Bachhuber", + "email": "daniel@handbuilt.co", + "role": "Maintainer" + }, { "name": "James Logsdon", "email": "jlogsdon@php.net", @@ -13,9 +18,48 @@ } ], "require": { - "php": ">= 5.3.0" + "php": ">= 7.2.24" + }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "wp-cli/wp-cli-tests": "^5" + }, + "extra": { + "branch-alias": { + "dev-main": "0.12.x-dev" + } }, + "minimum-stability": "dev", + "prefer-stable": true, "autoload": { - "psr-0": {"cli": "lib/"} + "psr-0": { + "cli": "lib/" + }, + "files": [ + "lib/cli/cli.php" + ] + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "johnpbloch/wordpress-core-installer": true, + "phpstan/extension-installer": true + } + }, + "scripts": { + "behat": "run-behat-tests", + "behat-rerun": "rerun-behat-tests", + "lint": "run-linter-tests", + "phpcs": "run-phpcs-tests", + "phpstan": "run-phpstan-tests", + "phpunit": "run-php-unit-tests", + "prepare-tests": "install-package-tests", + "test": [ + "@lint", + "@phpcs", + "@phpstan", + "@phpunit", + "@behat" + ] } } diff --git a/examples/arguments.php b/examples/arguments.php index 7f52d25..652c988 100644 --- a/examples/arguments.php +++ b/examples/arguments.php @@ -17,15 +17,21 @@ $arguments = new \cli\Arguments(compact('strict')); $arguments->addFlag(array('verbose', 'v'), 'Turn on verbose output'); -$arguments->addFlag('version', 'Turn on verbose output'); +$arguments->addFlag('version', 'Display the version'); $arguments->addFlag(array('quiet', 'q'), 'Disable all output'); +$arguments->addFlag(array('help', 'h'), 'Show this help screen'); $arguments->addOption(array('cache', 'C'), array( - 'default' => __DIR__, - 'description' => 'Set the cache directory. Defaults to the current directory')); + 'default' => getcwd(), + 'description' => 'Set the cache directory')); $arguments->addOption(array('name', 'n'), array( - 'default' => null, - 'description' => 'Set a name.')); + 'default' => 'James', + 'description' => 'Set a name with a really long description and a default so we can see what line wrapping looks like which is probably a goo idea')); $arguments->parse(); +if ($arguments['help']) { + echo $arguments->getHelpScreen(); + echo "\n\n"; +} + echo $arguments->asJSON() . "\n"; diff --git a/examples/colors.php b/examples/colors.php index 5e90604..59cc4be 100644 --- a/examples/colors.php +++ b/examples/colors.php @@ -1,6 +1,32 @@ tick(); if ($sleep) usleep($sleep); } $notify->finish(); } + +function test_notify_msg(cli\Notify $notify, $cycle = 1000000, $sleep = null) { + $notify->display(); + for ($i = 0; $i < $cycle; $i++) { + // Sleep before tick to simulate time-intensive work and give time + // for the initial message to display before it is changed + if ($sleep) usleep($sleep); + $msg = sprintf(' Finished step %d', $i + 1); + $notify->tick(1, $msg); + } + $notify->finish(); +} diff --git a/examples/menu.php b/examples/menu.php index 869cb4f..df0bb3e 100644 --- a/examples/menu.php +++ b/examples/menu.php @@ -19,6 +19,6 @@ break; } - include "${choice}.php"; + include "{$choice}.php"; \cli\line(); } diff --git a/examples/progress-step-format.php b/examples/progress-step-format.php new file mode 100644 index 0000000..a79965d --- /dev/null +++ b/examples/progress-step-format.php @@ -0,0 +1,60 @@ +tick(); + usleep(100000); +} +$progress->finish(); + +echo "\n"; + +// Example 2: Step-based format (current/total) +echo "Example 2: Step-based format (current/total)\n"; +$progress = new \cli\progress\Bar( + 'Step format', + 10, + 100, + '{:msg} {:current}/{:total} [' // Custom formatMessage with current/total +); +for ($i = 0; $i < 10; $i++) { + $progress->tick(); + usleep(100000); +} +$progress->finish(); + +echo "\n"; + +// Example 3: Custom format combining steps and percentage +echo "Example 3: Custom format combining steps and percentage\n"; +$progress = new \cli\progress\Bar( + 'Mixed format', + 50, + 100, + '{:msg} {:current}/{:total} ({:percent}%) [' // Both current/total and percent +); +for ($i = 0; $i < 50; $i++) { + $progress->tick(); + usleep(50000); +} +$progress->finish(); + +echo "\n"; + +// Example 4: Large numbers with step format +echo "Example 4: Large numbers with step format\n"; +$progress = new \cli\progress\Bar( + 'Processing items', + 1000, + 100, + '{:msg} {:current}/{:total} [' +); +for ($i = 0; $i < 1000; $i += 50) { + $progress->tick(50); + usleep(20000); +} +$progress->finish(); diff --git a/examples/progress.php b/examples/progress.php index d79d126..6f05109 100644 --- a/examples/progress.php +++ b/examples/progress.php @@ -4,3 +4,4 @@ test_notify(new \cli\progress\Bar(' \cli\progress\Bar displays a progress bar', 1000000)); test_notify(new \cli\progress\Bar(' It sizes itself dynamically', 1000000)); +test_notify_msg(new \cli\progress\Bar(' It can even change its message', 5), 5, 1000000); diff --git a/examples/table-alignment.php b/examples/table-alignment.php new file mode 100755 index 0000000..cc55e63 --- /dev/null +++ b/examples/table-alignment.php @@ -0,0 +1,103 @@ +#!/usr/bin/env php +setHeaders(['Product', 'Price', 'Stock']); +$table->addRow(['Widget', '$19.99', '150']); +$table->addRow(['Gadget', '$29.99', '75']); +$table->addRow(['Tool', '$9.99', '200']); +$table->display(); +cli\line(); + +// Example 2: Right Alignment for Numeric Columns +cli\line('%Y## Example 2: Right Alignment for Numeric Columns%n'); +cli\line('Notice how the numeric values are much easier to compare when right-aligned.'); +cli\line(); +$table = new cli\Table(); +$table->setHeaders(['Product', 'Price', 'Stock']); +$table->setAlignments([ + 'Product' => cli\table\Column::ALIGN_LEFT, + 'Price' => cli\table\Column::ALIGN_RIGHT, + 'Stock' => cli\table\Column::ALIGN_RIGHT, +]); +$table->addRow(['Widget', '$19.99', '150']); +$table->addRow(['Gadget', '$29.99', '75']); +$table->addRow(['Tool', '$9.99', '200']); +$table->display(); +cli\line(); + +// Example 3: Center Alignment +cli\line('%Y## Example 3: Center Alignment%n'); +cli\line(); +$table = new cli\Table(); +$table->setHeaders(['Left', 'Center', 'Right']); +$table->setAlignments([ + 'Left' => cli\table\Column::ALIGN_LEFT, + 'Center' => cli\table\Column::ALIGN_CENTER, + 'Right' => cli\table\Column::ALIGN_RIGHT, +]); +$table->addRow(['Text', 'Centered', 'More']); +$table->addRow(['Data', 'Values', 'Here']); +$table->addRow(['A', 'B', 'C']); +$table->display(); +cli\line(); + +// Example 4: Real-world Database Table Sizes +cli\line('%Y## Example 4: Database Table Sizes (Real-world Use Case)%n'); +cli\line('This example shows how the alignment feature makes database'); +cli\line('statistics much more readable and easier to compare.'); +cli\line(); +$table = new cli\Table(); +$table->setHeaders(['Table Name', 'Rows', 'Data Size', 'Index Size']); +$table->setAlignments([ + 'Table Name' => cli\table\Column::ALIGN_LEFT, + 'Rows' => cli\table\Column::ALIGN_RIGHT, + 'Data Size' => cli\table\Column::ALIGN_RIGHT, + 'Index Size' => cli\table\Column::ALIGN_RIGHT, +]); +$table->addRow(['wp_posts', '1,234', '5.2 MB', '1.1 MB']); +$table->addRow(['wp_postmeta', '45,678', '23.4 MB', '8.7 MB']); +$table->addRow(['wp_comments', '9,012', '2.3 MB', '0.8 MB']); +$table->addRow(['wp_options', '456', '1.5 MB', '0.2 MB']); +$table->addRow(['wp_users', '89', '0.1 MB', '0.05 MB']); +$table->display(); +cli\line(); + +// Example 5: Alignment Constants +cli\line('%Y## Alignment Constants%n'); +cli\line(); +cli\line('You can use the following constants from %Ccli\table\Column%n:'); +cli\line(' %G*%n %CALIGN_LEFT%n - Left align (default)'); +cli\line(' %G*%n %CALIGN_RIGHT%n - Right align (good for numbers)'); +cli\line(' %G*%n %CALIGN_CENTER%n - Center align'); +cli\line(); +cli\line('Example usage:'); +cli\line(' %c$table->setAlignments([%n'); +cli\line(' %c\'Column1\' => cli\table\Column::ALIGN_LEFT,%n'); +cli\line(' %c\'Column2\' => cli\table\Column::ALIGN_RIGHT,%n'); +cli\line(' %c]);%n'); +cli\line(); + +cli\line('%GDone!%n'); +cli\line(); diff --git a/examples/table-wrapping.php b/examples/table-wrapping.php new file mode 100644 index 0000000..4c91976 --- /dev/null +++ b/examples/table-wrapping.php @@ -0,0 +1,89 @@ +#!/usr/bin/env php +setHeaders($headers); +$table->setRows($data); +$renderer = new \cli\table\Ascii(); +$renderer->setConstraintWidth(70); // Simulate narrower terminal +$table->setRenderer($renderer); +$table->display(); +cli\line(); + +// Example 2: Word-wrap mode (wrap at word boundaries) +cli\line('%Y## Example 2: Word-Wrap Mode (Wrap at Word Boundaries)%n'); +cli\line('Word-wrap mode keeps words together by wrapping at spaces and hyphens.'); +cli\line('This makes it easier to read and copy/paste long values.'); +cli\line(); +$table = new \cli\Table(); +$table->setHeaders($headers); +$table->setRows($data); +$renderer = new \cli\table\Ascii(); +$renderer->setConstraintWidth(70); // Simulate narrower terminal +$table->setRenderer($renderer); +$table->setWrappingMode('word-wrap'); +$table->display(); +cli\line(); + +// Example 3: Truncate mode (truncate with ellipsis) +cli\line('%Y## Example 3: Truncate Mode (Truncate with Ellipsis)%n'); +cli\line('Truncate mode cuts off long content and adds "..." to indicate truncation.'); +cli\line('This is useful when you want a compact display and don\'t need full values.'); +cli\line(); +$table = new \cli\Table(); +$table->setHeaders($headers); +$table->setRows($data); +$renderer = new \cli\table\Ascii(); +$renderer->setConstraintWidth(70); // Simulate narrower terminal +$table->setRenderer($renderer); +$table->setWrappingMode('truncate'); +$table->display(); +cli\line(); + +// Example 4: Usage instructions +cli\line('%Y## Wrapping Mode Options%n'); +cli\line(); +cli\line('You can use the following wrapping modes:'); +cli\line(' %G*%n %Cwrap%n - Default: wrap at character boundaries'); +cli\line(' %G*%n %Cword-wrap%n - Wrap at word boundaries (spaces/hyphens)'); +cli\line(' %G*%n %Ctruncate%n - Truncate with ellipsis (...)'); +cli\line(); +cli\line('Example usage:'); +cli\line(' %c$table->setWrappingMode(\'word-wrap\');%n'); +cli\line(); + +cli\line('%GDone!%n'); +cli\line(); diff --git a/examples/table.php b/examples/table.php index c280d8f..fbdc615 100644 --- a/examples/table.php +++ b/examples/table.php @@ -1,7 +1,11 @@ setHeaders($headers); $table->setRows($data); +$table->setRenderer(new \cli\table\Ascii([10, 10, 20, 5])); $table->display(); diff --git a/examples/tree.php b/examples/tree.php new file mode 100644 index 0000000..068c271 --- /dev/null +++ b/examples/tree.php @@ -0,0 +1,72 @@ + array( + 'Something Cool' => array( + 'This is a 3rd layer', + ), + 'This is a 2nd layer', + ), + 'Other test' => array( + 'This is awesome' => array( + 'This is also cool', + 'This is even cooler', + 'Wow like what is this' => array( + 'Awesome eh?', + 'Totally' => array( + 'Yep!' + ), + ), + ), + ), +); + +printf("ASCII:\n"); + +/** + * ASCII should look something like this: + * + * -Test + * |\-Something Cool + * ||\-This is a 3rd layer + * |\-This is a 2nd layer + * \-Other test + * \-This is awesome + * \-This is also cool + * \-This is even cooler + * \-Wow like what is this + * \-Awesome eh? + * \-Totally + * \-Yep! + */ + +$tree = new \cli\Tree; +$tree->setData($data); +$tree->setRenderer(new \cli\tree\Ascii); +$tree->display(); + +printf("\nMarkdown:\n"); + +/** + * Markdown looks like this: + * + * - Test + * - Something Cool + * - This is a 3rd layer + * - This is a 2nd layer + * - Other test + * - This is awesome + * - This is also cool + * - This is even cooler + * - Wow like what is this + * - Awesome eh? + * - Totally + * - Yep! + */ + +$tree = new \cli\Tree; +$tree->setData($data); +$tree->setRenderer(new \cli\tree\Markdown(4)); +$tree->display(); diff --git a/http-console.php b/http-console.php index 2b11e50..2dafb64 100644 --- a/http-console.php +++ b/http-console.php @@ -3,16 +3,14 @@ * An example application using php-cli-tools and Buzz */ +require_once __DIR__ . '/vendor/autoload.php'; + define('BUZZ_PATH', realpath('../Buzz')); -define('TOOL_PATH', realpath('./')); define('SCRIPT_NAME', array_shift($argv)); require_once BUZZ_PATH . '/lib/Buzz/ClassLoader.php'; Buzz\ClassLoader::register(); -require_once TOOL_PATH . '/lib/cli/cli.php'; -\cli\register_autoload(); - class HttpConsole { protected $_host; protected $_prompt; diff --git a/lib/cli/Arguments.php b/lib/cli/Arguments.php index 4f25e3e..c6e41de 100644 --- a/lib/cli/Arguments.php +++ b/lib/cli/Arguments.php @@ -12,17 +12,30 @@ namespace cli; +use cli\arguments\Argument; +use cli\arguments\HelpScreen; +use cli\arguments\InvalidArguments; +use cli\arguments\Lexer; + /** * Parses command line arguments. + * + * @implements \ArrayAccess */ class Arguments implements \ArrayAccess { + /** @var array> */ protected $_flags = array(); + /** @var array> */ protected $_options = array(); - protected $_enableHelp = true; + /** @var bool */ protected $_strict = false; + /** @var array */ protected $_input = array(); + /** @var array */ protected $_invalid = array(); + /** @var array|null */ protected $_parsed; + /** @var Lexer|null */ protected $_lexer; /** @@ -32,37 +45,52 @@ class Arguments implements \ArrayAccess { * * `'help'` is `true` by default, `'strict'` is false by default. * - * @param array $options An array of options for this parser. + * @param array $options An array of options for this parser. */ public function __construct($options = array()) { $options += array( - 'help' => true, 'strict' => false, - 'input' => array_slice($_SERVER['argv'], 1) + 'input' => isset( $_SERVER['argv'] ) && is_array( $_SERVER['argv'] ) ? array_slice( $_SERVER['argv'], 1 ) : array(), ); - $this->_input = $options['input']; - $this->setHelp($options['help']); - $this->setStrict($options['strict']); + $input = $options['input']; + if ( ! is_array( $input ) ) { + $input = array(); + } + $this->_input = array_map( function( $item ) { return is_scalar( $item ) ? (string) $item : ''; }, $input ); + $this->setStrict( ! empty( $options['strict'] ) ); - if (isset($options['flags'])) { - $this->addFlags($options['flags']); + if ( isset( $options['flags'] ) && is_array( $options['flags'] ) ) { + /** @var array|string> $flags */ + $flags = $options['flags']; + $this->addFlags( $flags ); } - if (isset($options['options'])) { - $this->addOptions($options['options']); + if ( isset( $options['options'] ) && is_array( $options['options'] ) ) { + /** @var array|string> $opts */ + $opts = $options['options']; + $this->addOptions( $opts ); } } /** * Get the list of arguments found by the defined definitions. * - * @return array + * @return array */ public function getArguments() { if (!isset($this->_parsed)) { $this->parse(); } - return $this->_parsed; + return $this->_parsed ?? []; + } + + /** + * Get the help screen. + * + * @return HelpScreen + */ + public function getHelpScreen() { + return new HelpScreen($this); } /** @@ -71,7 +99,11 @@ public function getArguments() { * @return string */ public function asJSON() { - return json_encode($this->_parsed); + $json = json_encode( $this->_parsed ); + if ( false === $json ) { + throw new \RuntimeException( 'Failed to encode arguments as JSON' ); + } + return $json; } /** @@ -80,12 +112,17 @@ public function asJSON() { * @param mixed $offset An Argument object or the name of the argument. * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { - if ($offset instanceOf \cli\arguments\Argument) { + if ($offset instanceOf Argument) { $offset = $offset->key; } - return array_key_exists($this->_parsed, $offset); + if ( ! is_string( $offset ) && ! is_int( $offset ) ) { + return false; + } + + return array_key_exists($offset, $this->_parsed ?? []); } /** @@ -94,12 +131,21 @@ public function offsetExists($offset) { * @param mixed $offset An Argument object or the name of the argument. * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { - if ($offset instanceOf \cli\arguments\Argument) { + if ($offset instanceOf Argument) { $offset = $offset->key; } - return $this->_parsed[$offset]; + if ( ! is_string( $offset ) && ! is_int( $offset ) ) { + return null; + } + + if (isset($this->_parsed[$offset])) { + return $this->_parsed[$offset]; + } + + return null; } /** @@ -108,11 +154,17 @@ public function offsetGet($offset) { * @param mixed $offset An Argument object or the name of the argument. * @param mixed $value The value to set */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { - if ($offset instanceOf \cli\arguments\Argument) { + if ($offset instanceOf Argument) { $offset = $offset->key; } + if ( ! is_string( $offset ) && ! is_int( $offset ) ) { + return; + } + + $offset = (string) $offset; $this->_parsed[$offset] = $value; } @@ -121,11 +173,16 @@ public function offsetSet($offset, $value) { * * @param mixed $offset An Argument object or the name of the argument. */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { - if ($offset instanceOf \cli\arguments\Argument) { + if ($offset instanceOf Argument) { $offset = $offset->key; } + if ( ! is_string( $offset ) && ! is_int( $offset ) ) { + return; + } + unset($this->_parsed[$offset]); } @@ -133,7 +190,7 @@ public function offsetUnset($offset) { * Adds a flag (boolean argument) to the argument list. * * @param mixed $flag A string representing the flag, or an array of strings. - * @param array $settings An array of settings for this flag. + * @param array|string $settings An array of settings for this flag. * @setting string description A description to be shown in --help. * @setting bool default The default value for this flag. * @setting bool stackable Whether the flag is repeatable to increase the value. @@ -148,6 +205,11 @@ public function addFlag($flag, $settings = array()) { $settings['aliases'] = $flag; $flag = array_shift($settings['aliases']); } + if ( is_scalar( $flag ) ) { + $flag = (string) $flag; + } else { + $flag = ''; + } if (isset($this->_flags[$flag])) { $this->_warn('flag already exists: ' . $flag); return $this; @@ -169,7 +231,7 @@ public function addFlag($flag, $settings = array()) { * primary flag character, and the values should be the settings array * used by {addFlag}. * - * @param array $flags An array of flags to add + * @param array|string> $flags An array of flags to add * @return $this */ public function addFlags($flags) { @@ -188,8 +250,8 @@ public function addFlags($flags) { /** * Adds an option (string argument) to the argument list. * - * @param mixed $flag A string representing the option, or an array of strings. - * @param array $settings An array of settings for this option. + * @param mixed $option A string representing the option, or an array of strings. + * @param array|string $settings An array of settings for this option. * @setting string description A description to be shown in --help. * @setting bool default The default value for this option. * @setting array aliases Other ways to trigger this option. @@ -203,6 +265,11 @@ public function addOption($option, $settings = array()) { $settings['aliases'] = $option; $option = array_shift($settings['aliases']); } + if ( is_scalar( $option ) ) { + $option = (string) $option; + } else { + $option = ''; + } if (isset($this->_options[$option])) { $this->_warn('option already exists: ' . $option); return $this; @@ -223,7 +290,7 @@ public function addOption($option, $settings = array()) { * primary option string, and the values should be the settings array * used by {addOption}. * - * @param array $options An array of options to add + * @param array|string> $options An array of options to add * @return $this */ public function addOptions($options) { @@ -257,39 +324,27 @@ public function setStrict($strict) { /** * Get the list of invalid arguments the parser found. * - * @return array + * @return array */ public function getInvalidArguments() { return $this->_invalid; } - /** - * Enable or disable the generated help screen. If enabled, the flags `-h` - * and `--help` will halt execution of the script and display a help screen - * generated from the descriptions of each flag and option. - * - * *Note: with this option active, you cannot add the flags `-h` or - * `--help`; they will be ignored.* - * - * @param bool $help True to enable, false to disable. - * @return $this - */ - public function setHelp($help) { - $this->_enableHelp = (bool)$help; - return $this; - } - /** * Get a flag by primary matcher or any defined aliases. * * @param mixed $flag Either a string representing the flag or an * cli\arguments\Argument object. - * @return array + * @return array|null */ public function getFlag($flag) { - if ($flag instanceOf \cli\arguments\Argument) { + if ($flag instanceOf Argument) { $obj = $flag; - $flag = $flag->value; + $flag = $flag->value(); + } + + if ( ! is_string( $flag ) && ! is_int( $flag ) ) { + return null; } if (isset($this->_flags[$flag])) { @@ -306,6 +361,26 @@ public function getFlag($flag) { return $settings; } } + + return null; + } + + /** + * Get all flags. + * + * @return array> + */ + public function getFlags() { + return $this->_flags; + } + + /** + * Check if there are any flags defined. + * + * @return bool + */ + public function hasFlags() { + return !empty($this->_flags); } /** @@ -337,12 +412,16 @@ public function isStackable($flag) { * * @param mixed $option Either a string representing the option or an * cli\arguments\Argument object. - * @return array + * @return array|null */ public function getOption($option) { - if ($option instanceOf \cli\arguments\Argument) { + if ($option instanceOf Argument) { $obj = $option; - $option = $option->value; + $option = $option->value(); + } + + if ( ! is_string( $option ) && ! is_int( $option ) ) { + return null; } if (isset($this->_options[$option])) { @@ -358,6 +437,26 @@ public function getOption($option) { return $settings; } } + + return null; + } + + /** + * Get all options. + * + * @return array> + */ + public function getOptions() { + return $this->_options; + } + + /** + * Check if there are any options defined. + * + * @return bool + */ + public function hasOptions() { + return !empty($this->_options); } /** @@ -376,33 +475,74 @@ public function isOption($argument) { * will use either the first long name given or the first name in the list * if a long name is not given. * - * @return array + * @return void + * @throws arguments\InvalidArguments */ public function parse() { $this->_invalid = array(); $this->_parsed = array(); - $this->_lexer = new \cli\arguments\Lexer($this->_input); + $this->_lexer = new Lexer($this->_input); - foreach ($this->_lexer as $argument) { - if ($this->_parseFlag($argument)) { - continue; - } - if ($this->_parseOption($argument)) { - continue; - } + $this->_applyDefaults(); + + if ($this->_lexer) { + foreach ($this->_lexer as $argument) { + if (null === $argument) { + continue; + } + if ($this->_parseFlag($argument)) { + continue; + } + if ($this->_parseOption($argument)) { + continue; + } - array_push($this->_invalid, $argument->raw); + $raw = $argument->raw(); + array_push($this->_invalid, is_scalar($raw) ? (string) $raw : ''); + } } if ($this->_strict && !empty($this->_invalid)) { - throw new \cli\arguments\InvalidArguments($this->_invalid); + throw new InvalidArguments($this->_invalid); } } + /** + * This applies the default values, if any, of all of the + * flags and options, so that if there is a default value + * it will be available. + * + * @return void + */ + private function _applyDefaults() { + foreach($this->_flags as $flag => $settings) { + $this[$flag] = $settings['default']; + } + + foreach($this->_options as $option => $settings) { + // If the default is 0 we should still let it be set. + if (!empty($settings['default']) || $settings['default'] === 0) { + $this[$option] = $settings['default']; + } + } + } + + /** + * Warn about something. + * + * @param string $message + * @return void + */ private function _warn($message) { trigger_error('[' . __CLASS__ .'] ' . $message, E_USER_WARNING); } + /** + * Parse a flag. + * + * @param Argument $argument + * @return bool + */ private function _parseFlag($argument) { if (!$this->isFlag($argument)) { return false; @@ -413,7 +553,8 @@ private function _parseFlag($argument) { $this[$argument->key] = 0; } - $this[$argument->key] += 1; + $current = $this[$argument->key]; + $this[$argument->key] = (is_int($current) ? $current : 0) + 1; } else { $this[$argument->key] = true; } @@ -421,33 +562,54 @@ private function _parseFlag($argument) { return true; } + /** + * Parse an option. + * + * @param Argument $option + * @return bool + */ private function _parseOption($option) { if (!$this->isOption($option)) { return false; } + assert(null !== $this->_lexer); + // Peak ahead to make sure we get a value. - if (!$this->_lexer->end() && !$this->_lexer->peek->isValue) { - // Oops! Got no value, throw a warning and continue. - $this->_warn('no value given for ' . $option->raw); - $this[$option->key] = null; + if ($this->_lexer->end() || !$this->_lexer->peek->isValue) { + $optionSettings = $this->getOption($option->key); + + if (empty($optionSettings['default']) && $optionSettings !== 0) { + // Oops! Got no value and no default , throw a warning and continue. + $this->_warn('no value given for ' . $option->raw); + $this[$option->key] = null; + } else { + // No value and we have a default, so we set to the default + $this[$option->key] = $optionSettings['default']; + } return true; } // Store as array and join to string after looping for values $values = array(); + $this->_lexer->next(); + // Loop until we find a flag in peak-ahead - foreach ($this->_lexer as $value) { - array_push($values, $value->raw); + while ( $this->_lexer->valid() ) { + $value = $this->_lexer->current(); + if ( null === $value ) { + break; + } + array_push( $values, $value->raw ); - if (!$this->_lexer->end() && !$this->_lexer->peek->isValue) { + if ( ! $this->_lexer->end() && ! $this->_lexer->peek->isValue ) { break; } + $this->_lexer->next(); } - $this[$option->key] = join($values, ' '); + $this[$option->key] = join(' ', $values); return true; } } - diff --git a/lib/cli/Colors.php b/lib/cli/Colors.php index 8e879aa..fb9e071 100644 --- a/lib/cli/Colors.php +++ b/lib/cli/Colors.php @@ -18,6 +18,7 @@ * Reference: http://graphcomp.com/info/specs/ansi_col.html#colors */ class Colors { + /** @var array> */ static protected $_colors = array( 'color' => array( 'black' => 30, @@ -32,7 +33,7 @@ class Colors { 'style' => array( 'bright' => 1, 'dim' => 2, - 'underscore' => 4, + 'underline' => 4, 'blink' => 5, 'reverse' => 7, 'hidden' => 8 @@ -48,11 +49,52 @@ class Colors { 'white' => 47 ) ); + /** @var bool|null */ + static protected $_enabled = null; + + /** @var array> */ + static protected $_string_cache = array(); + + /** + * Enable colorized output. + * + * @param bool $force Force enable. + * @return void + */ + static public function enable($force = true) { + self::$_enabled = $force === true ? true : null; + } + + /** + * Disable colorized output. + * + * @param bool $force Force disable. + * @return void + */ + static public function disable($force = true) { + self::$_enabled = $force === true ? false : null; + } + + /** + * Check if we should colorize output based on local flags and shell type. + * + * Only check the shell type if `Colors::$_enabled` is null and `$colored` is null. + * + * @param bool|null $colored Force enable or disable the colorized output. + * @return bool + */ + static public function shouldColorize($colored = null) { + return self::$_enabled === true || + (self::$_enabled !== false && + ($colored === true || + ($colored !== false && Streams::isTty()))); + } /** * Set the color. * - * @param string $color The name of the color or style to set. + * @param string|array $color The name of the color or style to set, or an array of options. + * @return string */ static public function color($color) { if (!is_array($color)) { @@ -67,8 +109,8 @@ static public function color($color) { $colors = array(); foreach (array('color', 'style', 'background') as $type) { - $code = @$color[$type]; - if (isset(self::$_colors[$type][$code])) { + $code = $color[$type]; + if (isset($code) && isset(self::$_colors[$type][$code])) { $colors[] = self::$_colors[$type][$code]; } } @@ -80,8 +122,132 @@ static public function color($color) { return "\033[" . join(';', $colors) . "m"; } - static public function colorize($string, $colored = true) { - static $conversions = array( + /** + * Colorize a string using helpful string formatters. If the `Streams::$out` points to a TTY coloring will be enabled, + * otherwise disabled. You can control this check with the `$colored` parameter. + * + * @param string $string + * @param boolean $colored Force enable or disable the colorized output. If left as `null` the TTY will control coloring. + * @return string + */ + static public function colorize($string, $colored = null) { + $passed = $string; + + if (!self::shouldColorize($colored)) { + $return = self::decolorize( $passed, 2 /*keep_encodings*/ ); + self::cacheString($passed, $return); + return $return; + } + + $md5 = md5($passed); + if (isset(self::$_string_cache[$md5]['colorized'])) { + return self::$_string_cache[$md5]['colorized']; + } + + $string = str_replace('%%', '%¾', $string); + + foreach (self::getColors() as $key => $value) { + $string = str_replace($key, self::color($value), $string); + } + + $string = str_replace('%¾', '%', $string); + self::cacheString($passed, $string); + + return $string; + } + + /** + * Remove color information from a string. + * + * @param string $string A string with color information. + * @param int $keep Optional. If the 1 bit is set, color tokens (eg "%n") won't be stripped. If the 2 bit is set, color encodings (ANSI escapes) won't be stripped. Default 0. + * @return string A string with color information removed. + */ + static public function decolorize( $string, $keep = 0 ) { + $string = (string) $string; + + if ( ! ( $keep & 1 ) ) { + // Get rid of color tokens if they exist + $string = str_replace('%%', '%¾', $string); + $string = str_replace(array_keys(self::getColors()), '', $string); + $string = str_replace('%¾', '%', $string); + } + + if ( ! ( $keep & 2 ) ) { + // Remove color encoding if it exists + foreach (self::getColors() as $key => $value) { + $string = str_replace(self::color($value), '', $string); + } + } + + return $string; + } + + /** + * Cache the original, colorized, and decolorized versions of a string. + * + * @param string $passed The original string before colorization. + * @param string $colorized The string after running through self::colorize. + * @param string $deprecated Optional. Not used. Default null. + * @return void + */ + static public function cacheString( $passed, $colorized, $deprecated = null ) { + self::$_string_cache[md5($passed)] = array( + 'passed' => $passed, + 'colorized' => $colorized, + 'decolorized' => self::decolorize($passed), // Not very useful but keep for BC. + ); + } + + /** + * Return the length of the string without color codes. + * + * @param string $string the string to measure + * @return int + */ + static public function length($string) { + return safe_strlen( self::decolorize( $string ) ); + } + + /** + * Return the width (length in characters) of the string without color codes if enabled. + * + * @param string $string The string to measure. + * @param bool $pre_colorized Optional. Set if the string is pre-colorized. Default false. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return int + */ + static public function width( $string, $pre_colorized = false, $encoding = false ) { + return strwidth( $pre_colorized || self::shouldColorize() ? self::decolorize( $string, $pre_colorized ? 1 /*keep_tokens*/ : 0 ) : $string, $encoding ); + } + + /** + * Pad the string to a certain display length. + * + * @param string $string The string to pad. + * @param int $length The display length. + * @param bool $pre_colorized Optional. Set if the string is pre-colorized. Default false. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @param int $pad_type Optional. Can be STR_PAD_RIGHT, STR_PAD_LEFT, or STR_PAD_BOTH. If pad_type is not specified it is assumed to be STR_PAD_RIGHT. + * @return string + */ + static public function pad( $string, $length, $pre_colorized = false, $encoding = false, $pad_type = STR_PAD_RIGHT ) { + $string = (string) $string; + + $real_length = self::width( $string, $pre_colorized, $encoding ); + $diff = strlen( $string ) - $real_length; + $length += $diff; + + return str_pad( $string, $length, ' ', $pad_type ); + } + + /** + * Get the color mapping array. + * + * @return array> Array of color tokens mapped to colors and styles. + */ + static public function getColors() { + return array( '%y' => array('color' => 'yellow'), '%g' => array('color' => 'green'), '%b' => array('color' => 'blue'), @@ -89,7 +255,7 @@ static public function colorize($string, $colored = true) { '%p' => array('color' => 'magenta'), '%m' => array('color' => 'magenta'), '%c' => array('color' => 'cyan'), - '%w' => array('color' => 'grey'), + '%w' => array('color' => 'white'), '%k' => array('color' => 'black'), '%n' => array('color' => 'reset'), '%Y' => array('color' => 'yellow', 'style' => 'bright'), @@ -99,7 +265,7 @@ static public function colorize($string, $colored = true) { '%P' => array('color' => 'magenta', 'style' => 'bright'), '%M' => array('color' => 'magenta', 'style' => 'bright'), '%C' => array('color' => 'cyan', 'style' => 'bright'), - '%W' => array('color' => 'grey', 'style' => 'bright'), + '%W' => array('color' => 'white', 'style' => 'bright'), '%K' => array('color' => 'black', 'style' => 'bright'), '%N' => array('color' => 'reset', 'style' => 'bright'), '%3' => array('background' => 'yellow'), @@ -108,48 +274,128 @@ static public function colorize($string, $colored = true) { '%1' => array('background' => 'red'), '%5' => array('background' => 'magenta'), '%6' => array('background' => 'cyan'), - '%7' => array('background' => 'grey'), + '%7' => array('background' => 'white'), '%0' => array('background' => 'black'), '%F' => array('style' => 'blink'), '%U' => array('style' => 'underline'), - '%8' => array('style' => 'inverse'), + '%8' => array('style' => 'reverse'), '%9' => array('style' => 'bright'), '%_' => array('style' => 'bright') ); + } - if (!$colored) { - return preg_replace('/%((%)|.)/', '$2', $string); - } - - $string = str_replace('%%', '% ', $string); - - foreach ($conversions as $key => $value) { - $string = str_replace($key, self::color($value), $string); - } + /** + * Get the cached string values. + * + * @return array> The cached string values. + */ + static public function getStringCache() { + return self::$_string_cache; + } - return str_replace('% ', '%', $string); + /** + * Clear the string cache. + * + * @return void + */ + static public function clearStringCache() { + self::$_string_cache = array(); } /** - * Return the length of the string without color codes. + * Get the ANSI reset code. * - * @param string $string the string to measure + * @return string The ANSI reset code. */ - static public function length($string) { - return strlen(self::colorize($string, false)); + static public function getResetCode() { + return "\x1b[0m"; } /** - * Pad the string to a certain display length. + * Wrap a pre-colorized string at a specific width, preserving color codes. * - * @param string $string the string to pad - * @param integer $length the display length + * This function wraps text that contains ANSI color codes, ensuring that: + * 1. Color codes are never split in the middle + * 2. Active colors are properly terminated and restored across line breaks + * 3. The wrapped segments maintain the correct display width + * + * Note: This implementation tracks only the most recent ANSI code and does not + * support layered formatting (e.g., bold + color). When multiple formatting + * codes are applied, only the last one will be preserved across line breaks. + * + * @param string $string The string to wrap (with ANSI codes). + * @param int $width The maximum display width per line. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return array Array of wrapped string segments. */ - static public function pad($string, $length) { - $real_length = strlen($string); - $show_length = self::length($string); - $length += $real_length - $show_length; + static public function wrapPreColorized( $string, $width, $encoding = false ) { + $wrapped = array(); + $current_line = ''; + $current_width = 0; + $active_color = ''; + + // Pattern to match ANSI escape sequences + $ansi_pattern = '/(\x1b\[[0-9;]*m)/'; + + // Split the string into parts: ANSI codes and text + $parts = preg_split( $ansi_pattern, $string, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); + + if ( false === $parts ) { + $parts = array( $string ); + } - return str_pad($string, $length); + foreach ( $parts as $part ) { + // Check if this part is an ANSI code + if ( preg_match( $ansi_pattern, $part ) ) { + // It's an ANSI code, add it to current line without counting width + $current_line .= $part; + + // Track the active color - check for reset codes consistently + if ( preg_match( '/\x1b\[0m/', $part ) ) { + // Reset code (ESC[0m) + $active_color = ''; + } elseif ( preg_match( '/\x1b\[([0-9;]+)m/', $part, $matches ) ) { + // Non-reset color/formatting code + $active_color = $part; + } + } else { + // It's text content, process it character by character + $text_length = \cli\safe_strlen( $part, $encoding ); + $offset = 0; + + while ( $offset < $text_length ) { + $char = \cli\safe_substr( $part, $offset, 1, false, $encoding ); + assert( is_string( $char ) ); + $char_width = \cli\strwidth( $char, $encoding ); + + // Check if adding this character would exceed the width + if ( $current_width + $char_width > $width && $current_width > 0 ) { + // Need to wrap - finish current line + if ( $active_color ) { + $current_line .= self::getResetCode(); + } + $wrapped[] = $current_line; + + // Start new line + $current_line = $active_color ? $active_color : ''; + $current_width = 0; + } + + // Add the character + $current_line .= $char; + $current_width += $char_width; + $offset++; + } + } + } + + // Add the last line if there's any displayable content + $visible_content = preg_replace( $ansi_pattern, '', $current_line ); + $visible_width = $visible_content !== null ? \cli\strwidth( $visible_content, $encoding ) : 0; + if ( $visible_width > 0 ) { + $wrapped[] = $current_line; + } + + return $wrapped; } } diff --git a/lib/cli/Memoize.php b/lib/cli/Memoize.php index cedbc19..b08a619 100644 --- a/lib/cli/Memoize.php +++ b/lib/cli/Memoize.php @@ -13,8 +13,15 @@ namespace cli; abstract class Memoize { + /** @var array */ protected $_memoCache = array(); + /** + * Magic getter to retrieve memoized properties. + * + * @param string $name Property name. + * @return mixed + */ public function __get($name) { if (isset($this->_memoCache[$name])) { return $this->_memoCache[$name]; @@ -29,11 +36,16 @@ public function __get($name) { return ($this->_memoCache[$name] = null); } - $method = array($this, $name); - ($this->_memoCache[$name] = call_user_func($method)); + ($this->_memoCache[$name] = $this->$name()); return $this->_memoCache[$name]; } + /** + * Unmemoize a property or all properties. + * + * @param string|bool $name Property name to unmemoize, or true to unmemoize all. + * @return void + */ protected function _unmemo($name) { if ($name === true) { $this->_memoCache = array(); diff --git a/lib/cli/Notify.php b/lib/cli/Notify.php index 765f184..9fa9d42 100644 --- a/lib/cli/Notify.php +++ b/lib/cli/Notify.php @@ -12,6 +12,8 @@ namespace cli; +use cli\Streams; + /** * The `Notify` class is the basis of all feedback classes, such as Indicators * and Progress meters. The default behaviour is to refresh output after 100ms @@ -22,15 +24,27 @@ * of characters to indicate progress is being made. */ abstract class Notify { + /** @var int */ protected $_current = 0; + /** @var bool */ protected $_first = true; + /** @var int */ protected $_interval; + /** @var string */ protected $_message; + /** @var int|null */ protected $_start; + /** @var float|null */ protected $_timer; + /** @var float|int|null */ + protected $_tick; + /** @var int */ + protected $_iteration = 0; + /** @var float|int */ + protected $_speed = 0; /** - * Instatiates a Notification object. + * Instantiates a Notification object. * * @param string $msg The text to display next to the Notifier. * @param int $interval The interval in milliseconds between updates. @@ -47,9 +61,22 @@ public function __construct($msg, $interval = 100) { * @abstract * @param boolean $finish * @see cli\Notify::tick() + * @return void */ abstract public function display($finish = false); + /** + * Reset the notifier state so the same instance can be used in multiple loops. + * + * @return void + */ + public function reset() { + $this->_current = 0; + $this->_first = true; + $this->_start = null; + $this->_timer = null; + } + /** * Returns the formatted tick count. * @@ -77,26 +104,24 @@ public function elapsed() { * Calculates the speed (number of ticks per second) at which the Notifier * is being updated. * - * @return int The number of ticks performed in 1 second. + * @return float|int The number of ticks performed in 1 second. */ public function speed() { - static $tick, $iteration = 0, $speed = 0; - if (!$this->_start) { return 0; - } else if (!$tick) { - $tick = $this->_start; + } else if (!$this->_tick) { + $this->_tick = $this->_start; } $now = microtime(true); - $span = $now - $tick; + $span = $now - $this->_tick; if ($span > 1) { - $iteration++; - $tick = $now; - $speed = ($this->_current / $iteration) / $span; + $this->_iteration++; + $this->_tick = $now; + $this->_speed = ($this->_current / $this->_iteration) / $span; } - return $speed; + return $this->_speed; } /** @@ -107,9 +132,7 @@ public function speed() { * @return string The formatted time span. */ public function formatTime($time) { - $minutes = $time / 60; - $seconds = $time % 60; - return floor($time / 60) . ':' . str_pad($time % 60, 2, 0, STR_PAD_LEFT); + return sprintf('%02d:%02d', (int)floor($time / 60), $time % 60); } /** @@ -117,11 +140,12 @@ public function formatTime($time) { * no longer needed. * * @see cli\Notify::display() + * @return void */ public function finish() { - \cli\out("\r"); + Streams::out("\r"); $this->display(true); - \cli\line(); + Streams::line(); } /** @@ -129,6 +153,7 @@ public function finish() { * the ticker is incremented by 1. * * @param int $increment The amount to increment by. + * @return void */ public function increment($increment = 1) { $this->_current += $increment; @@ -163,12 +188,13 @@ public function shouldUpdate() { * @see cli\Notify::increment() * @see cli\Notify::shouldUpdate() * @see cli\Notify::display() + * @return void */ public function tick($increment = 1) { $this->increment($increment); if ($this->shouldUpdate()) { - \cli\out("\r"); + Streams::out("\r"); $this->display(); } } diff --git a/lib/cli/Progress.php b/lib/cli/Progress.php index 83c2fa7..c9acbfd 100644 --- a/lib/cli/Progress.php +++ b/lib/cli/Progress.php @@ -20,6 +20,7 @@ * @see cli\Notify */ abstract class Progress extends \cli\Notify { + /** @var int */ protected $_total = 0; /** @@ -28,10 +29,21 @@ abstract class Progress extends \cli\Notify { * @param string $msg The text to display next to the Notifier. * @param int $total The total number of ticks we will be performing. * @param int $interval The interval in milliseconds between updates. - * @throws \InvalidArgumentException Thrown if `$total` is less than 0. + * @see cli\Progress::setTotal() */ public function __construct($msg, $total, $interval = 100) { parent::__construct($msg, $interval); + $this->setTotal($total); + } + + /** + * Set the max increments for this progress notifier. + * + * @param int $total The total number of times this indicator should be `tick`ed. + * @throws \InvalidArgumentException Thrown if the `$total` is less than 0. + * @return void + */ + public function setTotal($total) { $this->_total = (int)$total; if ($this->_total < 0) { @@ -39,6 +51,20 @@ public function __construct($msg, $total, $interval = 100) { } } + /** + * Reset the progress state so the same instance can be used in multiple loops. + * + * @param int|null $total Optional new total. + * @return void + */ + public function reset($total = null) { + parent::reset(); + + if ($total) { + $this->setTotal($total); + } + } + /** * Behaves in a similar manner to `cli\Notify::current()`, but the output * is padded to match the length of `cli\Progress::total()`. @@ -64,8 +90,8 @@ public function total() { * Calculates the estimated total time for the tick count to reach the * total ticks given. * - * @return int The estimated total number of seconds for all ticks to be - * completed. This is not the estimated time left, but total. + * @return int|float The estimated total number of seconds for all ticks to be + * completed. This is not the estimated time left, but total. * @see cli\Notify::speed() * @see cli\Notify::elapsed() */ @@ -80,8 +106,10 @@ public function estimated() { } /** - * Forces the current tick count to the total ticks given at instatiation + * Forces the current tick count to the total ticks given at instantiation * time before passing on to `cli\Notify::finish()`. + * + * @return void */ public function finish() { $this->_current = $this->_total; @@ -93,6 +121,7 @@ public function finish() { * the ticker is incremented by 1. * * @param int $increment The amount to increment by. + * @return void */ public function increment($increment = 1) { $this->_current = min($this->_total, $this->_current + $increment); diff --git a/lib/cli/Shell.php b/lib/cli/Shell.php old mode 100644 new mode 100755 index 9436f7b..a3bb95d --- a/lib/cli/Shell.php +++ b/lib/cli/Shell.php @@ -13,10 +13,11 @@ namespace cli; /** - * The `Shell` class is a utility class for shell related information such as - * width. + * The `Shell` class is a utility class for shell related tasks such as + * information on width. */ class Shell { + /** * Returns the number of columns the current shell has for display. * @@ -24,7 +25,48 @@ class Shell { * @todo Test on more systems. */ static public function columns() { - return exec('/usr/bin/env tput cols'); + static $columns; + + if ( getenv( 'PHP_CLI_TOOLS_TEST_SHELL_COLUMNS_RESET' ) ) { + $columns = null; + } + if ( null === $columns ) { + if ( ! ( $columns = (int) getenv( 'COLUMNS' ) ) && function_exists( 'exec' ) ) { + if ( self::is_windows() ) { + // Cater for shells such as Cygwin and Git bash where `mode CON` returns an incorrect value for columns. + if ( ( $shell = getenv( 'SHELL' ) ) && preg_match( '/(?:bash|zsh)(?:\.exe)?$/', $shell ) && getenv( 'TERM' ) ) { + $columns = (int) exec( 'tput cols' ); + } + if ( ! $columns ) { + $return_var = -1; + $output = array(); + exec( 'mode CON', $output, $return_var ); + if ( 0 === $return_var && $output ) { + // Look for second line ending in ": " (searching for "Columns:" will fail on non-English locales). + if ( preg_match( '/:\s*[0-9]+\n[^:]+:\s*([0-9]+)\n/', implode( "\n", $output ), $matches ) ) { + $columns = (int) $matches[1]; + } + } + } + } else { + $size = exec( '/usr/bin/env stty size 2>/dev/null' ); + if ( $size && preg_match( '/[0-9]+ ([0-9]+)/', $size, $matches ) ) { + $columns = (int) $matches[1]; + } + if ( ! $columns ) { + if ( getenv( 'TERM' ) ) { + $columns = (int) exec( '/usr/bin/env tput cols 2>/dev/null' ); + } + } + } + } + + if ( ! $columns ) { + $columns = 80; // default width of cmd window on Windows OS + } + } + + return $columns; } /** @@ -33,11 +75,51 @@ static public function columns() { * Returns true if STDOUT output is being redirected to a pipe or a file; false is * output is being sent directly to the terminal. * + * If an env variable SHELL_PIPE exists, returned result depends it's + * value. Strings like 1, 0, yes, no, that validate to booleans are accepted. + * + * To enable ASCII formatting even when shell is piped, use the + * ENV variable SHELL_PIPE=0 + * * @return bool */ static public function isPiped() { - return (function_exists('posix_isatty') && !posix_isatty(STDOUT)); + $shellPipe = getenv('SHELL_PIPE'); + + if ($shellPipe !== false) { + return filter_var($shellPipe, FILTER_VALIDATE_BOOLEAN); + } else { + if ( function_exists('stream_isatty') ) { + return !stream_isatty(STDOUT); + } else { + return (function_exists('posix_isatty') && !posix_isatty(STDOUT)); + } + } } + + /** + * Uses `stty` to hide input/output completely. + * + * @param boolean $hidden Will hide/show the next data. Defaults to true. + * @return void + */ + static public function hide($hidden = true) { + system( 'stty ' . ( $hidden? '-echo' : 'echo' ) ); + } + + /** + * Is this shell in Windows? + * + * @return bool + */ + static public function is_windows() { + $test_is_windows = getenv( 'WP_CLI_TEST_IS_WINDOWS' ); + if ( false !== $test_is_windows && '' !== $test_is_windows ) { + return (bool) $test_is_windows; + } + return strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN'; + } + } ?> diff --git a/lib/cli/Streams.php b/lib/cli/Streams.php old mode 100644 new mode 100755 index 294f21e..22a22e5 --- a/lib/cli/Streams.php +++ b/lib/cli/Streams.php @@ -4,40 +4,78 @@ class Streams { + /** @var resource */ protected static $out = STDOUT; + /** @var resource */ protected static $in = STDIN; + /** @var resource */ protected static $err = STDERR; + /** + * Call a method on this class. + * + * @param string $func The method name. + * @param array $args The arguments. + * @return mixed + */ + static function _call( $func, $args ) { + $method = array( __CLASS__, $func ); + assert( is_callable( $method ) ); + return call_user_func_array( $method, $args ); + } + + /** + * Check if the stream is a TTY. + * + * @return bool + */ + public static function isTty() { + if ( function_exists( 'stream_isatty' ) ) { + return stream_isatty( static::$out ); + } else { + return ( function_exists( 'posix_isatty' ) && posix_isatty( static::$out ) ); + } + } + /** * Handles rendering strings. If extra scalar arguments are given after the `$msg` * the string will be rendered with `sprintf`. If the second argument is an `array` * then each key in the array will be the placeholder name. Placeholders are of the * format {:key}. * - * @param string $msg The message to render. - * @param mixed ... Either scalar arguments or a single array argument. + * @param string $msg The message to render. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return string The rendered string. */ - public static function render( $msg ) { - $args = func_get_args(); - + public static function render( $msg, ...$args ) { // No string replacement is needed - if( count( $args ) == 1 ) { - return Colors::colorize( $msg ); + if ( empty( $args ) || ( is_string( $args[0] ) && '' === $args[0] ) ) { + return Colors::shouldColorize() ? Colors::colorize( $msg ) : $msg; } // If the first argument is not an array just pass to sprintf - if( !is_array( $args[1] ) ) { - // Colorize the message first so sprintf doesn't bitch at us - $args[0] = Colors::colorize( $args[0] ); - return call_user_func_array( 'sprintf', $args ); + if ( ! is_array( $args[0] ) ) { + // Normalize color tokens before sprintf: colorize or strip them so no raw %tokens reach sprintf + if ( Colors::shouldColorize() ) { + $msg = Colors::colorize( $msg ); + } else { + $msg = Colors::decolorize( $msg ); + } + + // Escape percent characters for sprintf + $msg = (string) preg_replace( '/(%([^\w]|$))/', '%$1', $msg ); + + $sprintf_args = array_merge( array( $msg ), $args ); + /** @var string $rendered */ + $rendered = call_user_func_array( 'sprintf', $sprintf_args ); + return $rendered; } // Here we do named replacement so formatting strings are more understandable - foreach( $args[1] as $key => $value ) { - $msg = str_replace( '{:' . $key . '}', $value, $msg ); + foreach ( $args[0] as $key => $value ) { + $msg = str_replace( '{:' . $key . '}', is_scalar( $value ) ? (string) $value : '', $msg ); } - return Colors::colorize( $msg ); + return Colors::shouldColorize() ? Colors::colorize( $msg ) : $msg; } /** @@ -45,40 +83,43 @@ public static function render( $msg ) { * through `sprintf` before output. * * @param string $msg The message to output in `printf` format. - * @param mixed ... Either scalar arguments or a single array argument. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return void * @see \cli\render() */ - public static function out( $msg ) { - $args = func_get_args(); - fwrite( static::$out, call_user_func_array( array( '\\cli\\Streams', 'render' ), $args ) ); + public static function out( $msg, ...$args ) { + $rendered = self::_call( 'render', func_get_args() ); + fwrite( static::$out, is_scalar( $rendered ) ? (string) $rendered : '' ); } /** * Pads `$msg` to the width of the shell before passing to `cli\out`. * * @param string $msg The message to pad and pass on. - * @param mixed ... Either scalar arguments or a single array argument. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return void * @see cli\out() */ - public static function out_padded( $msg ) { - $args = func_get_args(); - $msg = call_user_func_array( array( '\\cli\\Streams', 'render' ), $args ); - \cli\Streams::out( str_pad( $msg, \cli\Shell::columns() ) ); + public static function out_padded( $msg, ...$args ) { + $rendered = self::_call( 'render', func_get_args() ); + $msg = is_scalar( $rendered ) ? (string) $rendered : ''; + self::out( str_pad( $msg, \cli\Shell::columns() ) ); } /** * Prints a message to `STDOUT` with a newline appended. See `\cli\out` for * more documentation. * + * @param string $msg The message to print. + * @return void * @see cli\out() */ public static function line( $msg = '' ) { // func_get_args is empty if no args are passed even with the default above. - $args = array_merge( func_get_args(), array( '' ) ); - $args[0] .= "\n"; - call_user_func_array( array( '\\cli\\Streams', 'out' ), $args ); + $args = array_merge( func_get_args(), array( '' ) ); + $args[0] = ( is_scalar( $args[0] ) ? (string) $args[0] : '' ) . "\n"; + + self::_call( 'out', $args ); } /** @@ -87,14 +128,15 @@ public static function line( $msg = '' ) { * * @param string $msg The message to output in `printf` format. With no string, * a newline is printed. - * @param mixed ... Either scalar arguments or a single array argument. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return void */ - public static function err( $msg = '' ) { + public static function err( $msg = '', ...$args ) { // func_get_args is empty if no args are passed even with the default above. - $args = array_merge( func_get_args(), array( '' ) ); - $args[0] .= "\n"; - fwrite( static::$err, call_user_func_array( array( '\\cli\\Streams', 'render' ), $args ) ); + $args = array_merge( func_get_args(), array( '' ) ); + $args[0] = ( is_scalar( $args[0] ) ? (string) $args[0] : '' ) . "\n"; + $rendered = self::_call( 'render', $args ); + fwrite( static::$err, is_scalar( $rendered ) ? (string) $rendered : '' ); } /** @@ -104,47 +146,60 @@ public static function err( $msg = '' ) { * @param string $format A valid input format. See `fscanf` for documentation. * If none is given, all input up to the first newline * is accepted. + * @param boolean $hide If true will hide what the user types in. * @return string The input with whitespace trimmed. * @throws \Exception Thrown if ctrl-D (EOT) is sent as input. */ - public static function input( $format = null ) { - if( $format ) { + public static function input( $format = null, $hide = false ) { + if ( $hide ) { + Shell::hide(); + } + + if ( $format ) { fscanf( static::$in, $format . "\n", $line ); } else { $line = fgets( static::$in ); } - if( $line === false ) { + if ( $hide ) { + Shell::hide( false ); + echo "\n"; + } + + if ( $line === false ) { throw new \Exception( 'Caught ^D during input' ); } - return trim( $line ); + return trim( (string) $line ); } /** * Displays an input prompt. If no default value is provided the prompt will * continue displaying until input is received. * - * @param string $question The question to ask the user. - * @param string $default A default value if the user provides no input. - * @param string $marker A string to append to the question and default value - * on display. + * @param string $question The question to ask the user. + * @param bool|string $default A default value if the user provides no input. + * @param string $marker A string to append to the question and default value + * on display. + * @param boolean $hide Optionally hides what the user types in. * @return string The users input. * @see cli\input() */ - public static function prompt( $question, $default = false, $marker = ': ' ) { - if( $default && strpos( $question, '[' ) === false ) { + public static function prompt( $question, $default = false, $marker = ': ', $hide = false ) { + if ( $default && strpos( $question, '[' ) === false ) { $question .= ' [' . $default . ']'; } - while( true ) { - \cli\Streams::out( $question . $marker ); - $line = \cli\Streams::input(); + while ( true ) { + self::out( $question . $marker ); + $line = self::input( null, $hide ); - if( !empty( $line ) ) + if ( trim( $line ) !== '' ) { return $line; - if( $default !== false ) - return $default; + } + if ( $default !== false ) { + return (string) $default; + } } } @@ -153,29 +208,32 @@ public static function prompt( $question, $default = false, $marker = ': ' ) { * questions (which this public static function defaults too). * * @param string $question The question to ask the user. - * @param string $valid A string of characters allowed as a response. Case - * is ignored. - * @param string $default The default choice. NULL if a default is not allowed. + * @param string $choice A string of characters allowed as a response. Case is ignored. + * @param string|null $default The default choice. NULL if a default is not allowed. * @return string The users choice. * @see cli\prompt() */ public static function choose( $question, $choice = 'yn', $default = 'n' ) { - if( !is_string( $choice ) ) { + if ( ! is_string( $choice ) ) { $choice = join( '', $choice ); } // Make every choice character lowercase except the default - $choice = str_ireplace( $default, strtoupper( $default ), strtolower( $choice ) ); - // Seperate each choice with a forward-slash - $choices = trim( join( '/', preg_split( '//', $choice ) ), '/' ); + if ( null !== $default ) { + $choice = str_ireplace( $default, strtoupper( $default ), strtolower( $choice ) ); + } else { + $choice = strtolower( $choice ); + } + // Separate each choice with a forward-slash + $choices = trim( join( '/', str_split( $choice ) ), '/' ); - while( true ) { - $line = \cli\Streams::prompt( sprintf( '%s? [%s]', $question, $choices ), $default, '' ); + while ( true ) { + $line = self::prompt( sprintf( '%s? [%s]', $question, $choices ), $default ?? false, '' ); - if( stripos( $choice, $line ) !== false ) { + if ( stripos( $choice, $line ) !== false ) { return strtolower( $line ); } - if( !empty( $default ) ) { + if ( ! empty( $default ) ) { return strtolower( $default ); } } @@ -186,40 +244,53 @@ public static function choose( $question, $choice = 'yn', $default = 'n' ) { * choose an option. The array must be a single dimension with either strings * or objects with a `__toString()` method. * - * @param array $items The list of items the user can choose from. - * @param string $default The index of the default item. + * @param array $items The list of items the user can choose from. + * @param string|null $default The index of the default item. * @param string $title The message displayed to the user when prompted. * @return string The index of the chosen item. * @see cli\line() * @see cli\input() * @see cli\err() */ - public static function menu( $items, $default = false, $title = 'Choose an item' ) { + public static function menu( $items, $default = null, $title = 'Choose an item' ) { $map = array_values( $items ); - if( $default && strpos( $title, '[' ) === false && isset( $items[$default] ) ) { - $title .= ' [' . $items[$default] . ']'; + if ( $default && strpos( $title, '[' ) === false && isset( $items[ $default ] ) ) { + $default_item = $items[ $default ]; + $default_str = ''; + if ( is_scalar( $default_item ) ) { + $default_str = (string) $default_item; + } elseif ( is_object( $default_item ) && method_exists( $default_item, '__toString' ) ) { + $default_str = (string) $default_item; + } + $title .= ' [' . $default_str . ']'; } - foreach( $map as $idx => $item ) { - \cli\Streams::line( ' %d. %s', $idx + 1, (string)$item ); + foreach ( $map as $idx => $item ) { + $item_str = ''; + if ( is_scalar( $item ) ) { + $item_str = (string) $item; + } elseif ( is_object( $item ) && method_exists( $item, '__toString' ) ) { + $item_str = (string) $item; + } + self::line( ' %d. %s', $idx + 1, $item_str ); } - \cli\Streams::line(); + self::line(); - while( true ) { + while ( true ) { fwrite( static::$out, sprintf( '%s: ', $title ) ); - $line = \cli\Streams::input(); + $line = self::input(); - if( is_numeric( $line ) ) { - $line--; - if( isset( $map[$line] ) ) { - return array_search( $map[$line], $items ); + if ( is_numeric( $line ) ) { + --$line; + if ( isset( $map[ $line ] ) ) { + return (string) array_search( $map[ $line ], $items ); } - if( $line < 0 || $line >= count( $map ) ) { - \cli\Streams::err( 'Invalid menu selection: out of range' ); + if ( $line < 0 || $line >= count( $map ) ) { + self::err( 'Invalid menu selection: out of range' ); } - } else if( isset( $default ) ) { + } elseif ( isset( $default ) ) { return $default; } } @@ -242,15 +313,16 @@ public static function menu( $items, $default = false, $title = 'Choose an item' * @throws \Exception Thrown if $stream is not a resource of the 'stream' type. */ public static function setStream( $whichStream, $stream ) { - if( !is_resource( $stream ) || get_resource_type( $stream ) !== 'stream' ) { + if ( ! is_resource( $stream ) || get_resource_type( $stream ) !== 'stream' ) { throw new \Exception( 'Invalid resource type!' ); } - if( property_exists( __CLASS__, $whichStream ) ) { + if ( property_exists( __CLASS__, $whichStream ) ) { static::${$whichStream} = $stream; } - register_shutdown_function( function() use ($stream) { - fclose( $stream ); - } ); + register_shutdown_function( + function () use ( $stream ) { + fclose( $stream ); + } + ); } - } diff --git a/lib/cli/Table.php b/lib/cli/Table.php index 04d963c..c75f3fa 100644 --- a/lib/cli/Table.php +++ b/lib/cli/Table.php @@ -12,77 +12,154 @@ namespace cli; +use cli\Shell; +use cli\Streams; +use cli\table\Ascii; +use cli\table\Column; +use cli\table\Renderer; +use cli\table\Tabular; + /** * The `Table` class is used to display data in a tabular format. */ class Table { + /** @var \cli\table\Renderer */ protected $_renderer; + /** @var array */ protected $_headers = array(); + /** @var array */ + protected $_footers = array(); + /** @var array */ protected $_width = array(); + /** @var array> */ protected $_rows = array(); + /** @var array|array */ + protected $_alignments = array(); + + /** + * Cached map of valid alignment constants. + * + * @var array|null + */ + private static $_valid_alignments_map = null; /** * Initializes the `Table` class. * * There are 3 ways to instantiate this class: * - * 1. Pass an array of strings as the first paramater for the column headers + * 1. Pass an array of strings as the first parameter for the column headers * and a 2-dimensional array as the second parameter for the data rows. * 2. Pass an array of hash tables (string indexes instead of numerical) * where each hash table is a row and the indexes of the *first* hash * table are used as the header values. * 3. Pass nothing and use `setHeaders()` and `addRow()` or `setRows()`. * - * @param array $headers Headers used in this table. Optional. - * @param array $rows The rows of data for this table. Optional. + * @param array $headers Headers used in this table. Optional. + * @param array $rows The rows of data for this table. Optional. + * @param array $footers Footers used in this table. Optional. + * @param array $alignments Column alignments. Optional. */ - public function __construct(array $headers = null, array $rows = null) { - if (!empty($headers)) { + public function __construct( array $headers = array(), array $rows = array(), array $footers = array(), array $alignments = array() ) { + $safe_strval = function ( $v ) { + return ( is_scalar( $v ) || ( is_object( $v ) && method_exists( $v, '__toString' ) ) ) ? (string) $v : ''; + }; + + if ( ! empty( $headers ) ) { // If all the rows is given in $headers we use the keys from the // first row for the header values - if (empty($rows)) { - $rows = $headers; - $keys = array_keys(array_shift($headers)); + if ( $rows === array() ) { + $rows = $headers; + $first_row = array_shift( $headers ); + $keys = is_array( $first_row ) ? array_keys( $first_row ) : array(); + $headers = array(); + foreach ( $keys as $key ) { + $headers[ $key ] = $safe_strval( $key ); + } + } else { + $headers = array_map( $safe_strval, $headers ); + } - foreach ($keys as $header) { - $headers[$header] = $header; + $this->setHeaders( $headers ); + + $safe_rows = array(); + foreach ( $rows as $row ) { + if ( is_array( $row ) ) { + $normalized_row = array(); + foreach ( $headers as $key => $header_val ) { + $normalized_row[ $key ] = isset( $row[ $key ] ) ? $safe_strval( $row[ $key ] ) : ''; + } + $safe_rows[] = $normalized_row; } } + $this->setRows( $safe_rows ); + } - $this->setHeaders($headers); - $this->setRows($rows); + if ( ! empty( $footers ) ) { + $this->setFooters( array_map( $safe_strval, $footers ) ); } - if (\cli\Shell::isPiped()) { - $this->setRenderer(new \cli\table\Tabular()); + if ( ! empty( $alignments ) ) { + /** @var array|array $alignments */ + $this->setAlignments( $alignments ); + } + + if ( Shell::isPiped() ) { + $this->setRenderer( new Tabular() ); } else { - $this->setRenderer(new \cli\table\Ascii()); + $this->setRenderer( new Ascii() ); } } + /** + * Reset the table state. + * + * @return $this + */ + public function resetTable() { + $this->_headers = array(); + $this->_width = array(); + $this->_rows = array(); + $this->_footers = array(); + $this->_alignments = array(); + return $this; + } + + /** + * Resets only the rows in the table, keeping headers, footers, and width information. + * + * @return $this + */ + public function resetRows() { + $this->_rows = array(); + return $this; + } + /** * Sets the renderer used by this table. * - * @param cli\table\Renderer $renderer The renderer to use for output. - * @see cli\table\Renderer - * @see cli\table\Standard - * @see cli\table\Tabular + * @param table\Renderer $renderer The renderer to use for output. + * @see table\Renderer + * @see table\Ascii + * @see table\Tabular + * @return void */ - public function setRenderer(\cli\table\Renderer $renderer) { + public function setRenderer( Renderer $renderer ) { $this->_renderer = $renderer; } /** * Loops through the row and sets the maximum width for each column. * - * @param array $row The table row. + * @param array $row The table row. + * @return array $row */ - protected function checkRow(array $row) { - foreach ($row as $column => $str) { - $width = Colors::length($str); - if (!isset($this->_width[$column]) || $width > $this->_width[$column]) { - $this->_width[$column] = $width; + protected function checkRow( array $row ) { + foreach ( $row as $column => $str ) { + $width = Colors::width( $str, $this->isAsciiPreColorized( $column ) ); + if ( ! isset( $this->_width[ $column ] ) || $width > $this->_width[ $column ] ) { + $this->_width[ $column ] = $width; } } @@ -98,73 +175,222 @@ protected function checkRow(array $row) { * @uses cli\Shell::isPiped() Determine what format to output * * @see cli\Table::renderRow() + * @return void */ public function display() { - $this->_renderer->setWidths($this->_width); + foreach ( $this->getDisplayLines() as $line ) { + Streams::line( $line ); + } + } + + /** + * Display a single row without headers or top border. + * + * This method is useful for adding rows incrementally to an already-rendered table. + * It will display the row with side borders and a bottom border (if using Ascii renderer). + * + * @param array $row The row data to display. + * @return void + */ + public function displayRow( array $row ) { + // Update widths if this row has wider content + $row = $this->checkRow( $row ); + + // Recalculate widths for the renderer + $this->_renderer->setWidths( $this->_width, false ); + + $rendered_row = $this->_renderer->row( $row ); + $row_lines = explode( PHP_EOL, $rendered_row ); + foreach ( $row_lines as $line ) { + Streams::line( $line ); + } + + $border = $this->_renderer->border(); + if ( isset( $border ) ) { + Streams::line( $border ); + } + } + + /** + * Get the table lines to output. + * + * @see cli\Table::display() + * @see cli\Table::renderRow() + * + * @return array + */ + public function getDisplayLines() { + $this->_renderer->setWidths( $this->_width, $fallback = true ); + $this->_renderer->setHeaders( $this->_headers ); + $this->_renderer->setAlignments( $this->_alignments ); $border = $this->_renderer->border(); - if (isset($border)) { - \cli\line($border); + $out = array(); + if ( isset( $border ) ) { + $out[] = $border; + } + $out[] = $this->_renderer->row( $this->_headers ); + if ( isset( $border ) ) { + $out[] = $border; } - \cli\line($this->_renderer->row($this->_headers)); - if (isset($border)) { - \cli\line($border); + + foreach ( $this->_rows as $row ) { + $row = $this->_renderer->row( $row ); + $row = explode( PHP_EOL, $row ); + $out = array_merge( $out, $row ); } - foreach ($this->_rows as $row) { - \cli\line($this->_renderer->row($row)); + // Only add final border if there are rows + if ( ! empty( $this->_rows ) && isset( $border ) ) { + $out[] = $border; } - if (isset($border)) { - \cli\line($border); + if ( $this->_footers ) { + $out[] = $this->_renderer->row( $this->_footers ); + if ( isset( $border ) ) { + $out[] = $border; + } } + return $out; } /** * Sort the table by a column. Must be called before `cli\Table::display()`. * * @param int $column The index of the column to sort by. + * @return void */ - public function sort($column) { - if (!isset($this->_headers[$column])) { - trigger_error('No column with index ' . $column, E_USER_NOTICE); + public function sort( $column ) { + if ( ! isset( $this->_headers[ $column ] ) ) { + trigger_error( 'No column with index ' . $column, E_USER_NOTICE ); return; } - usort($this->_rows, function($a, $b) use ($column) { - return strcmp($a[$column], $b[$column]); - }); + usort( + $this->_rows, + function ( $a, $b ) use ( $column ) { + return strcmp( $a[ $column ], $b[ $column ] ); + } + ); } /** * Set the headers of the table. * - * @param array $headers An array of strings containing column header names. + * @param array $headers An array of strings containing column header names. + * @return void + */ + public function setHeaders( array $headers ) { + $this->_headers = $this->checkRow( $headers ); + } + + /** + * Set the footers of the table. + * + * @param array $footers An array of strings containing column footers names. + * @return void + */ + public function setFooters( array $footers ) { + $this->_footers = $this->checkRow( $footers ); + } + + /** + * Set the alignments of the table. + * + * @param array|array $alignments An array of alignment constants keyed by column name or index. + * @return void */ - public function setHeaders(array $headers) { - $this->_headers = $this->checkRow($headers); + public function setAlignments( array $alignments ) { + // Initialize the cached valid alignments map on first use + if ( null === self::$_valid_alignments_map ) { + self::$_valid_alignments_map = array_flip( array( Column::ALIGN_LEFT, Column::ALIGN_RIGHT, Column::ALIGN_CENTER ) ); + } + + $headers_map = ! empty( $this->_headers ) ? array_flip( $this->_headers ) : null; + foreach ( $alignments as $column => $alignment ) { + if ( ! isset( self::$_valid_alignments_map[ $alignment ] ) ) { + throw new \InvalidArgumentException( "Invalid alignment value '$alignment' for column '$column'." ); + } + // Only validate column names if headers are already set + if ( $headers_map !== null && ! isset( $headers_map[ $column ] ) ) { + throw new \InvalidArgumentException( "Column '$column' does not exist in table headers." ); + } + } + $this->_alignments = $alignments; } /** * Add a row to the table. * - * @param array $row The row data. + * @param array $row The row data. * @see cli\Table::checkRow() + * @return void */ - public function addRow(array $row) { - $this->_rows[] = $this->checkRow($row); + public function addRow( array $row ) { + $this->_rows[] = $this->checkRow( $row ); } /** * Clears all previous rows and adds the given rows. * - * @param array $rows A 2-dimensional array of row data. + * @param array> $rows A 2-dimensional array of row data. * @see cli\Table::addRow() + * @return void */ - public function setRows(array $rows) { + public function setRows( array $rows ) { $this->_rows = array(); - foreach ($rows as $row) { - $this->addRow($row); + foreach ( $rows as $row ) { + $this->addRow( $row ); + } + } + + /** + * Count the number of rows in the table. + * + * @return int + */ + public function countRows() { + return count( $this->_rows ); + } + + /** + * Set whether items in an Ascii table are pre-colorized. + * + * @param bool|array $pre_colorized A boolean to set all columns in the table as pre-colorized, or an array of booleans keyed by column index (number) to set individual columns as pre-colorized. + * @see cli\Ascii::setPreColorized() + * @return void + */ + public function setAsciiPreColorized( $pre_colorized ) { + if ( $this->_renderer instanceof Ascii ) { + $this->_renderer->setPreColorized( $pre_colorized ); + } + } + + /** + * Set the wrapping mode for table cells. + * + * @param string $mode One of: 'wrap' (default - wrap at character boundaries), + * 'word-wrap' (word boundaries), or 'truncate' (truncate with ellipsis). + * @see cli\Ascii::setWrappingMode() + * @return void + */ + public function setWrappingMode( $mode ) { + if ( $this->_renderer instanceof Ascii ) { + $this->_renderer->setWrappingMode( $mode ); + } + } + + /** + * Is a column in an Ascii table pre-colorized? + * + * @param int $column Column index to check. + * @return bool True if whole Ascii table is marked as pre-colorized, or if the individual column is pre-colorized; else false. + * @see cli\Ascii::isPreColorized() + */ + private function isAsciiPreColorized( $column ) { + if ( $this->_renderer instanceof Ascii ) { + return $this->_renderer->isPreColorized( $column ); } + return false; } } diff --git a/lib/cli/Tree.php b/lib/cli/Tree.php new file mode 100644 index 0000000..b1df849 --- /dev/null +++ b/lib/cli/Tree.php @@ -0,0 +1,75 @@ + + * @copyright 2010 James Logsdom (http://girsbrain.org) + * @license http://www.opensource.org/licenses/mit-license.php The MIT License + */ + +namespace cli; + +/** + * The `Tree` class is used to display data in a tree-like format. + */ +class Tree { + + /** @var \cli\tree\Renderer */ + protected $_renderer; + /** @var array */ + protected $_data = array(); + + /** + * Sets the renderer used by this tree. + * + * @param tree\Renderer $renderer The renderer to use for output. + * @see tree\Renderer + * @see tree\Ascii + * @see tree\Markdown + * @return void + */ + public function setRenderer(tree\Renderer $renderer) { + $this->_renderer = $renderer; + } + + /** + * Set the data. + * Format: + * [ + * 'Label' => [ + * 'Thing' => ['Thing'], + * ], + * 'Thing', + * ] + * @param array $data + * @return void + */ + public function setData(array $data) + { + $this->_data = $data; + } + + /** + * Render the tree and return it as a string. + * + * @return string|null + */ + public function render() + { + return $this->_renderer->render($this->_data); + } + + /** + * Display the rendered tree + * + * @return void + */ + public function display() + { + echo $this->render(); + } + +} diff --git a/lib/cli/arguments/Argument.php b/lib/cli/arguments/Argument.php index 737da3f..7ab070b 100644 --- a/lib/cli/arguments/Argument.php +++ b/lib/cli/arguments/Argument.php @@ -12,18 +12,30 @@ namespace cli\arguments; +use cli\Memoize; + /** * Represents an Argument or a value and provides several helpers related to parsing an argument list. + * + * @property-read bool $isLong + * @property-read bool $isShort + * @property-read bool $isArgument + * @property-read bool $canExplode + * @property-read array $exploded + * @property-read string $raw + * @property-read bool $isValue */ -class Argument extends \cli\Memoize { +class Argument extends Memoize { /** * The canonical name of this argument, used for aliasing. * - * @param string + * @var string */ public $key; + /** @var string */ private $_argument; + /** @var string */ private $_raw; /** @@ -75,7 +87,7 @@ public function raw() { * @return bool */ public function isLong() { - return (0 == strncmp($this->_raw, '--', 2)); + return (0 == strncmp((string)$this->_raw, '--', 2)); } /** @@ -84,7 +96,7 @@ public function isLong() { * @return bool */ public function isShort() { - return !$this->isLong && (0 == strncmp($this->_raw, '-', 1)); + return !$this->isLong && (0 == strncmp((string)$this->_raw, '-', 1)); } /** @@ -119,7 +131,7 @@ public function canExplode() { * Returns all but the first character of the argument, removing them from the * objects representation at the same time. * - * @return array + * @return array */ public function exploded() { $exploded = array(); @@ -128,7 +140,7 @@ public function exploded() { array_push($exploded, $this->_argument[$i - 1]); } - $this->_argument = array_pop($exploded); + $this->_argument = (string) array_pop($exploded); $this->_raw = '-' . $this->_argument; return $exploded; } diff --git a/lib/cli/arguments/HelpScreen.php b/lib/cli/arguments/HelpScreen.php new file mode 100644 index 0000000..27e6bc8 --- /dev/null +++ b/lib/cli/arguments/HelpScreen.php @@ -0,0 +1,182 @@ + + * @copyright 2010 James Logsdom (http://girsbrain.org) + * @license http://www.opensource.org/licenses/mit-license.php The MIT License + */ + +namespace cli\arguments; + +use cli\Arguments; + +/** + * Arguments help screen renderer + */ +class HelpScreen { + /** @var array> */ + protected $_flags = array(); + /** @var int */ + protected $_flagMax = 0; + /** @var array> */ + protected $_options = array(); + /** @var int */ + protected $_optionMax = 0; + + /** + * @param Arguments $arguments + */ + public function __construct( Arguments $arguments ) { + $this->setArguments( $arguments ); + } + + /** + * @return string + */ + public function __toString() { + return $this->render(); + } + + /** + * @param Arguments $arguments + * @return void + */ + public function setArguments( Arguments $arguments ) { + $this->consumeArgumentFlags( $arguments ); + $this->consumeArgumentOptions( $arguments ); + } + + /** + * @param Arguments $arguments + * @return void + */ + public function consumeArgumentFlags( Arguments $arguments ) { + $data = $this->_consume( $arguments->getFlags() ); + + $this->_flags = $data[0]; + $this->_flagMax = $data[1]; + } + + /** + * @param Arguments $arguments + * @return void + */ + public function consumeArgumentOptions( Arguments $arguments ) { + $data = $this->_consume( $arguments->getOptions() ); + + $this->_options = $data[0]; + $this->_optionMax = $data[1]; + } + + /** + * @return string + */ + public function render() { + $help = array(); + + array_push( $help, $this->_renderFlags() ); + array_push( $help, $this->_renderOptions() ); + + $help = array_filter( $help, function ( $v ) { + return $v !== null && $v !== ''; + } ); + + return join( "\n\n", $help ); + } + + /** + * @return string|null + */ + private function _renderFlags() { + if ( empty( $this->_flags ) ) { + return null; + } + + return "Flags\n" . $this->_renderScreen( $this->_flags, $this->_flagMax ); + } + + /** + * @return string|null + */ + private function _renderOptions() { + if ( empty( $this->_options ) ) { + return null; + } + + return "Options\n" . $this->_renderScreen( $this->_options, $this->_optionMax ); + } + + /** + * @param array> $options + * @param int $max + * @return string + */ + private function _renderScreen( $options, $max ) { + $help = array(); + foreach ( $options as $option => $settings ) { + $formatted = ' ' . str_pad( $option, $max ); + + $dlen = max( 1, 80 - 4 - $max ); + $settings_desc = $settings['description']; + $desc_str = ( is_scalar( $settings_desc ) || ( is_object( $settings_desc ) && method_exists( $settings_desc, '__toString' ) ) ) ? (string) $settings_desc : ''; + + $description = array(); + if ( '' !== $desc_str ) { + $description = str_split( $desc_str, $dlen ); + } + + if ( empty( $description ) ) { + $description = array( '' ); + } + + $formatted .= ' ' . array_shift( $description ); + + if ( ! empty( $settings['default'] ) ) { + $default_val = $settings['default']; + $default_str = ( is_scalar( $default_val ) || ( is_object( $default_val ) && method_exists( $default_val, '__toString' ) ) ) ? (string) $default_val : ''; + if ( '' !== $default_str ) { + $formatted .= ' [default: ' . $default_str . ']'; + } + } + + $pad = str_repeat( ' ', $max + 3 ); + while ( $desc = array_shift( $description ) ) { + $formatted .= "\n{$pad}{$desc}"; + } + + array_push( $help, $formatted ); + } + + return join( "\n", $help ); + } + + /** + * @param array> $options + * @return array{0: array>, 1: int} + */ + private function _consume( $options ) { + $max = 0; + $out = array(); + + foreach ( $options as $option => $settings ) { + $names = array( '--' . $option ); + + $aliases = $settings['aliases']; + if ( is_array( $aliases ) ) { + foreach ( $aliases as $alias ) { + array_push( $names, '-' . ( is_scalar( $alias ) ? (string) $alias : '' ) ); + } + } + + $names = join( ', ', $names ); + $max = max( strlen( $names ), $max ); + $out[ $names ] = $settings; + } + + return array( $out, $max ); + } +} diff --git a/lib/cli/arguments/InvalidArguments.php b/lib/cli/arguments/InvalidArguments.php index e5a2a69..5a0b315 100644 --- a/lib/cli/arguments/InvalidArguments.php +++ b/lib/cli/arguments/InvalidArguments.php @@ -16,10 +16,11 @@ * Thrown when undefined arguments are detected in strict mode. */ class InvalidArguments extends \InvalidArgumentException { + /** @var array */ protected $arguments; /** - * @param array $arguments A list of arguments that do not fit the profile. + * @param array $arguments A list of arguments that do not fit the profile. */ public function __construct(array $arguments) { $this->arguments = $arguments; @@ -29,15 +30,18 @@ public function __construct(array $arguments) { /** * Get the arguments that caused the exception. * - * @return array + * @return array */ public function getArguments() { return $this->arguments; } + /** + * @return string + */ private function _generateMessage() { return 'unknown argument' . (count($this->arguments) > 1 ? 's' : '') . - ': ' . join($this->arguments, ', '); + ': ' . join(', ', $this->arguments); } } diff --git a/lib/cli/arguments/Lexer.php b/lib/cli/arguments/Lexer.php index 99a9061..381e3a6 100644 --- a/lib/cli/arguments/Lexer.php +++ b/lib/cli/arguments/Lexer.php @@ -12,13 +12,27 @@ namespace cli\arguments; -class Lexer extends \cli\Memoize implements \Iterator { +use cli\Memoize; + +/** + * @property-read Argument $peek + * + * @implements \Iterator + */ +class Lexer extends Memoize implements \Iterator { + /** @var Argument|null */ + private $_item; + /** @var array */ private $_items = array(); + /** @var int */ private $_index = 0; + /** @var int */ private $_length = 0; + /** @var bool */ + private $_first = true; /** - * @param array $items A list of strings to process as tokens. + * @param array $items A list of strings to process as tokens. */ public function __construct(array $items) { $this->_items = $items; @@ -28,8 +42,9 @@ public function __construct(array $items) { /** * The current token. * - * @return string + * @return Argument|null */ + #[\ReturnTypeWillChange] public function current() { return $this->_item; } @@ -37,15 +52,16 @@ public function current() { /** * Peek ahead to the next token without moving the cursor. * - * @return cli\arguments\Argument + * @return Argument */ public function peek() { - return new \cli\arguments\Argument($this->_items[0]); + return new Argument($this->_items[0]); } /** * Move the cursor forward 1 element if it is valid. */ + #[\ReturnTypeWillChange] public function next() { if ($this->valid()) { $this->_shift(); @@ -57,6 +73,7 @@ public function next() { * * @return int */ + #[\ReturnTypeWillChange] public function key() { return $this->_index; } @@ -65,12 +82,12 @@ public function key() { * Move forward 1 element and, if the method hasn't been called before, reset * the cursor's position to 0. */ + #[\ReturnTypeWillChange] public function rewind() { - static $first = true; $this->_shift(); - if ($first) { + if ($this->_first) { $this->_index = 0; - $first = false; + $this->_first = false; } } @@ -79,6 +96,7 @@ public function rewind() { * * @return bool */ + #[\ReturnTypeWillChange] public function valid() { return ($this->_index < $this->_length); } @@ -87,9 +105,11 @@ public function valid() { * Push an element to the front of the stack. * * @param mixed $item The value to set + * @return void */ public function unshift($item) { - array_unshift($this->_items, $item); + $item_str = (is_scalar($item) || (is_object($item) && method_exists($item, '__toString'))) ? (string)$item : ''; + array_unshift($this->_items, $item_str); $this->_length += 1; } @@ -102,16 +122,23 @@ public function end() { return ($this->_index + 1) == $this->_length; } + /** + * @return void + */ private function _shift() { - $this->_item = new \cli\arguments\Argument(array_shift($this->_items)); + $shifted = array_shift($this->_items); + $this->_item = null !== $shifted ? new Argument($shifted) : null; $this->_index += 1; $this->_explode(); $this->_unmemo('peek'); } + /** + * @return void + */ private function _explode() { - if (!$this->_item->canExplode) { - return false; + if (null === $this->_item || !$this->_item->canExplode) { + return; } foreach ($this->_item->exploded as $piece) { diff --git a/lib/cli/cli.php b/lib/cli/cli.php old mode 100644 new mode 100755 index 87e4326..d412b96 --- a/lib/cli/cli.php +++ b/lib/cli/cli.php @@ -13,37 +13,18 @@ namespace cli; -/** - * Registers a basic auto loader for the `cli` namespace. - */ -function register_autoload() { - spl_autoload_register( function($class) { - // Only attempt to load classes in our namespace - if( substr( $class, 0, 4 ) !== 'cli\\' ) { - return; - } - - $base = dirname( __DIR__ ) . DIRECTORY_SEPARATOR; - $path = $base . str_replace( '\\', DIRECTORY_SEPARATOR, $class ) . '.php'; - if( is_file( $path ) ) { - require_once $path; - } - } ); -} - /** * Handles rendering strings. If extra scalar arguments are given after the `$msg` * the string will be rendered with `sprintf`. If the second argument is an `array` * then each key in the array will be the placeholder name. Placeholders are of the * format {:key}. * - * @param string $msg The message to render. - * @param mixed ... Either scalar arguments or a single array argument. + * @param string $msg The message to render. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return string The rendered string. */ -function render( $msg ) { - $args = func_get_args(); - return call_user_func_array( array( '\\cli\\Streams', 'render' ), $args ); +function render( $msg, ...$args ) { + return Streams::render( $msg, ...$args ); } /** @@ -51,42 +32,37 @@ function render( $msg ) { * through `sprintf` before output. * * @param string $msg The message to output in `printf` format. - * @param mixed ... Either scalar arguments or a single array argument. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return void * @see \cli\render() */ -function out( $msg ) { - $args = func_get_args(); - call_user_func_array( array( '\\cli\\Streams', 'out' ), $args ); +function out( $msg, ...$args ) { + Streams::_call( 'out', func_get_args() ); } /** * Pads `$msg` to the width of the shell before passing to `cli\out`. * * @param string $msg The message to pad and pass on. - * @param mixed ... Either scalar arguments or a single array argument. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return void * @see cli\out() */ -function out_padded( $msg ) { - $args = func_get_args(); - call_user_func_array( array( '\\cli\\Streams', 'out_padded' ), $args ); +function out_padded( $msg, ...$args ) { + Streams::_call( 'out_padded', func_get_args() ); } /** * Prints a message to `STDOUT` with a newline appended. See `\cli\out` for * more documentation. * + * @param string $msg Message to print. + * @param mixed ...$args Either scalar arguments or a single array argument. + * @return void * @see cli\out() */ -function line( $msg = '' ) { - // func_get_args is empty if no args are passed even with the default above. - $args = func_get_args(); - if( $args ) { - call_user_func_array( array( '\\cli\\Streams', 'line' ), $args ); - } else { - \cli\Streams::line(); - } +function line( $msg = '', ...$args ) { + Streams::_call( 'line', func_get_args() ); } /** @@ -95,17 +71,11 @@ function line( $msg = '' ) { * * @param string $msg The message to output in `printf` format. With no string, * a newline is printed. - * @param mixed ... Either scalar arguments or a single array argument. + * @param mixed ...$args Either scalar arguments or a single array argument. * @return void */ -function err( $msg = '' ) { - // func_get_args is empty if no args are passed even with the default above. - $args = func_get_args(); - if( $args ) { - call_user_func_array( array( '\\cli\\Streams', 'err' ), $args ); - } else { - \cli\Streams::err(); - } +function err( $msg = '', ...$args ) { + Streams::_call( 'err', func_get_args() ); } /** @@ -119,37 +89,53 @@ function err( $msg = '' ) { * @throws \Exception Thrown if ctrl-D (EOT) is sent as input. */ function input( $format = null ) { - return \cli\Streams::input( $format ); + return Streams::input( $format ); } /** * Displays an input prompt. If no default value is provided the prompt will * continue displaying until input is received. * - * @param string $question The question to ask the user. - * @param string $default A default value if the user provides no input. - * @param string $marker A string to append to the question and default value - * on display. + * @param string $question The question to ask the user. + * @param string|false $default A default value if the user provides no input. Default false. + * @param string $marker A string to append to the question and default value on display. + * @param boolean $hide If the user input should be hidden * @return string The users input. * @see cli\input() */ -function prompt( $question, $default = false, $marker = ': ' ) { - return \cli\Streams::prompt( $question, $default, $marker ); +function prompt( $question, $default = false, $marker = ': ', $hide = false ) { + return Streams::prompt( $question, $default, $marker, $hide ); } /** * Presents a user with a multiple choice question, useful for 'yes/no' type * questions (which this function defaults too). * - * @param string $question The question to ask the user. - * @param string $valid A string of characters allowed as a response. Case - * is ignored. - * @param string $default The default choice. NULL if a default is not allowed. + * @param string $question The question to ask the user. + * @param string $choice + * @param string|null $default The default choice. NULL if a default is not allowed. + * @internal param string $valid A string of characters allowed as a response. Case + * is ignored. * @return string The users choice. - * @see cli\prompt() + * @see cli\prompt() */ function choose( $question, $choice = 'yn', $default = 'n' ) { - return \cli\Streams::choose( $question, $choice, $default ); + return Streams::choose( $question, $choice, $default ); +} + +/** + * Does the same as {@see choose()}, but always asks yes/no and returns a boolean + * + * @param string $question The question to ask the user. + * @param bool|null $default The default choice, in a boolean format. + * @return bool + */ +function confirm( $question, $default = false ) { + if ( is_bool( $default ) ) { + $default = $default? 'y' : 'n'; + } + $result = choose( $question, 'yn', $default ); + return $result == 'y'; } /** @@ -157,14 +143,290 @@ function choose( $question, $choice = 'yn', $default = 'n' ) { * choose an option. The array must be a single dimension with either strings * or objects with a `__toString()` method. * - * @param array $items The list of items the user can choose from. - * @param string $default The index of the default item. - * @param string $title The message displayed to the user when prompted. + * @param array $items The list of items the user can choose from. + * @param string $default The index of the default item. + * @param string $title The message displayed to the user when prompted. * @return string The index of the chosen item. * @see cli\line() * @see cli\input() * @see cli\err() */ -function menu( $items, $default = false, $title = 'Choose an item' ) { - return \cli\Streams::menu( $items, $default, $title ); +function menu( $items, $default = null, $title = 'Choose an item' ) { + return Streams::menu( $items, $default, $title ); +} + +/** + * Attempts an encoding-safe way of getting string length. If intl extension or PCRE with '\X' or mb_string extension aren't + * available, falls back to basic strlen. + * + * @param string $str The string to check. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return int Numeric value that represents the string's length + */ +function safe_strlen( $str, $encoding = false ) { + // Allow for selective testings - "1" bit set tests grapheme_strlen(), "2" preg_match_all( '/\X/u' ), "4" mb_strlen(), "other" strlen(). + $test_safe_strlen = (int) getenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); + + // Assume UTF-8 if no encoding given - `grapheme_strlen()` will return false on failure. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_icu() && is_int( $length = grapheme_strlen( $str ) ) ) { + if ( ! $test_safe_strlen || ( $test_safe_strlen & 1 ) ) { + return $length; + } + } + // Assume UTF-8 if no encoding given - `preg_match_all()` will return false if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_pcre_x() && false !== ( $length = preg_match_all( '/\X/u', $str, $dummy /*needed for PHP 5.3*/ ) ) ) { + if ( ! $test_safe_strlen || ( $test_safe_strlen & 2 ) ) { + return $length; + } + } + // Legacy encodings and old PHPs will reach here. + if ( function_exists( 'mb_strlen' ) && ( $encoding || function_exists( 'mb_detect_encoding' ) ) ) { + if ( ! $encoding ) { + $encoding = mb_detect_encoding( $str, null, true /*strict*/ ); + } + $length = is_string( $encoding ) ? mb_strlen( $str, $encoding ) : mb_strlen( $str ); // mbstring funcs can fail if given `$encoding` arg that evals to false. + if ( 'UTF-8' === $encoding ) { + // Subtract combining characters. + $m_regex = get_unicode_regexs( 'm' ); + assert( is_string( $m_regex ) ); + $length -= preg_match_all( $m_regex, $str, $dummy /*needed for PHP 5.3*/ ); + } + if ( ! $test_safe_strlen || ( $test_safe_strlen & 4 ) ) { + return $length; + } + } + return strlen( $str ); +} + +/** + * Attempts an encoding-safe way of getting a substring. If intl extension or PCRE with '\X' or mb_string extension aren't + * available, falls back to substr(). + * + * @param string $str The input string. + * @param int $start The starting position of the substring. + * @param int|bool|null $length Optional, unless $is_width is set. Maximum length of the substring. Default false. Negative not supported. + * @param int|bool $is_width Optional. If set and encoding is UTF-8, $length (which must be specified) is interpreted as spacing width. Default false. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return bool|string False if given unsupported args, otherwise substring of string specified by start and length parameters + */ +function safe_substr( $str, $start, $length = false, $is_width = false, $encoding = false ) { + // Negative $length or $is_width and $length not specified not supported. + if ( $length < 0 || ( $is_width && ( null === $length || false === $length ) ) ) { + return false; + } + // Need this for normalization below and other uses. + $safe_strlen = safe_strlen( $str, $encoding ); + + // Normalize `$length` when not specified - PHP 5.3 substr takes false as full length, PHP > 5.3 takes null. + if ( null === $length || false === $length ) { + $length = $safe_strlen; + } else { + $length = (int) $length; + } + // Normalize `$start` - various methods treat this differently. + if ( $start > $safe_strlen ) { + return ''; + } + if ( $start < 0 && -$start > $safe_strlen ) { + $start = 0; + } + + // Allow for selective testings - "1" bit set tests grapheme_substr(), "2" preg_split( '/\X/' ), "4" mb_substr(), "8" substr(). + $test_safe_substr = (int) getenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); + + // Assume UTF-8 if no encoding given - `grapheme_substr()` will return false (not null like `grapheme_strlen()`) if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_icu() && false !== ( $try = grapheme_substr( $str, $start, $length ) ) ) { + if ( ! $test_safe_substr || ( $test_safe_substr & 1 ) ) { + return $is_width ? _safe_substr_eaw( $try, $length ) : $try; + } + } + // Assume UTF-8 if no encoding given - `preg_split()` returns a one element array if given non-UTF-8 string (PHP bug) so need to check `preg_last_error()`. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_pcre_x() ) { + if ( false !== ( $try = preg_split( '/(\X)/u', $str, $safe_strlen + 1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ) ) && ! preg_last_error() ) { + $try = implode( '', array_slice( $try, $start, $length ) ); + if ( ! $test_safe_substr || ( $test_safe_substr & 2 ) ) { + return $is_width ? _safe_substr_eaw( $try, $length ) : $try; + } + } + } + // Legacy encodings and old PHPs will reach here. + if ( function_exists( 'mb_substr' ) && ( $encoding || function_exists( 'mb_detect_encoding' ) ) ) { + if ( ! $encoding ) { + $encoding = mb_detect_encoding( $str, null, true /*strict*/ ); + } + // Bug: not adjusting for combining chars. + $try = is_string( $encoding ) ? mb_substr( $str, $start, $length, $encoding ) : mb_substr( $str, $start, $length ); // mbstring funcs can fail if given `$encoding` arg that evals to false. + if ( 'UTF-8' === $encoding && $is_width ) { + $try = _safe_substr_eaw( $try, $length ); + } + if ( ! $test_safe_substr || ( $test_safe_substr & 4 ) ) { + return $try; + } + } + return substr( $str, $start, $length ); +} + +/** + * Internal function used by `safe_substr()` to adjust for East Asian double-width chars. + * + * @param string $str + * @param int $length + * @return string + */ +function _safe_substr_eaw( $str, $length ) { + // Set the East Asian Width regex. + $eaw_regex = get_unicode_regexs( 'eaw' ); + assert( is_string( $eaw_regex ) ); + + // If there's any East Asian double-width chars... + if ( preg_match( $eaw_regex, $str ) ) { + // Note that if the length ends in the middle of a double-width char, the char is excluded, not included. + + // See if it's all EAW. + if ( function_exists( 'mb_substr' ) && preg_match_all( $eaw_regex, $str, $dummy /*needed for PHP 5.3*/ ) === $length ) { + // Just halve the length so (rounded down to a minimum of 1). + $str = mb_substr( $str, 0, max( (int) ( $length / 2 ), 1 ), 'UTF-8' ); + } else { + // Explode string into an array of UTF-8 chars. Based on core `_mb_substr()` in "wp-includes/compat.php". + $chars = preg_split( '/([\x00-\x7f\xc2-\xf4][^\x00-\x7f\xc2-\xf4]*)/', $str, $length + 1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); + if ( false === $chars ) { + $chars = array( $str ); + } + $cnt = min( count( $chars ), $length ); + $width = $length; + + for ( $length = 0; $length < $cnt && $width > 0; $length++ ) { + $width -= preg_match( $eaw_regex, $chars[ $length ] ) ? 2 : 1; + } + // Round down to a minimum of 1. + if ( $width < 0 && $length > 1 ) { + $length--; + } + return join( '', array_slice( $chars, 0, $length ) ); + } + } + return $str; +} + +/** + * An encoding-safe way of padding string length for display + * + * @param string $string The string to pad. + * @param int $length The length to pad it to. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return string + */ +function safe_str_pad( $string, $length, $encoding = false ) { + $real_length = strwidth( $string, $encoding ); + $diff = strlen( $string ) - $real_length; + $length += $diff; + + return str_pad( $string, $length ); +} + +/** + * Get width of string, ie length in characters, taking into account multi-byte and mark characters for UTF-8, and multi-byte for non-UTF-8. + * + * @param string $string The string to check. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return int The string's width. + */ +function strwidth( $string, $encoding = false ) { + $string = (string) $string; + + // Set the East Asian Width and Mark regexs. + $regexs = get_unicode_regexs(); + assert( is_array( $regexs ) ); + list( $eaw_regex, $m_regex ) = $regexs; + + // Allow for selective testings - "1" bit set tests grapheme_strlen(), "2" preg_match_all( '/\X/u' ), "4" mb_strwidth(), "other" safe_strlen(). + $test_strwidth = (int) getenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); + + // Assume UTF-8 if no encoding given - `grapheme_strlen()` will return null if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_icu() && null !== ( $width = grapheme_strlen( $string ) ) ) { + if ( ! $test_strwidth || ( $test_strwidth & 1 ) ) { + return $width + preg_match_all( $eaw_regex, $string, $dummy /*needed for PHP 5.3*/ ); + } + } + // Assume UTF-8 if no encoding given - `preg_match_all()` will return false if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_pcre_x() && false !== ( $width = preg_match_all( '/\X/u', $string, $dummy /*needed for PHP 5.3*/ ) ) ) { + if ( ! $test_strwidth || ( $test_strwidth & 2 ) ) { + return $width + preg_match_all( $eaw_regex, $string, $dummy /*needed for PHP 5.3*/ ); + } + } + // Legacy encodings and old PHPs will reach here. + if ( function_exists( 'mb_strwidth' ) && ( $encoding || function_exists( 'mb_detect_encoding' ) ) ) { + if ( ! $encoding ) { + $encoding = mb_detect_encoding( $string, null, true /*strict*/ ); + } + $width = is_string( $encoding ) ? mb_strwidth( $string, $encoding ) : mb_strwidth( $string ); // mbstring funcs can fail if given `$encoding` arg that evals to false. + if ( 'UTF-8' === $encoding ) { + // Subtract combining characters. + $width -= preg_match_all( $m_regex, $string, $dummy /*needed for PHP 5.3*/ ); + } + if ( ! $test_strwidth || ( $test_strwidth & 4 ) ) { + return $width; + } + } + return safe_strlen( $string, $encoding ); +} + +/** + * Returns whether ICU is modern enough not to flake out. + * + * @return bool + */ +function can_use_icu() { + static $can_use_icu = null; + + if ( null === $can_use_icu ) { + // Choosing ICU 54, Unicode 7.0. + $can_use_icu = defined( 'INTL_ICU_VERSION' ) && version_compare( INTL_ICU_VERSION, '54.1', '>=' ) && function_exists( 'grapheme_strlen' ) && function_exists( 'grapheme_substr' ); + } + + return $can_use_icu; +} + +/** + * Returns whether PCRE Unicode extended grapheme cluster '\X' is available for use. + * + * @return bool + */ +function can_use_pcre_x() { + static $can_use_pcre_x = null; + + if ( null === $can_use_pcre_x ) { + // '\X' introduced (as Unicode extended grapheme cluster) in PCRE 8.32 - see https://vcs.pcre.org/pcre/code/tags/pcre-8.32/ChangeLog?view=markup line 53. + // Older versions of PCRE were bundled with PHP <= 5.3.23 & <= 5.4.13. + $pcre_version = substr( PCRE_VERSION, 0, strspn( PCRE_VERSION, '0123456789.' ) ); // Remove any trailing date stuff. + $can_use_pcre_x = version_compare( $pcre_version, '8.32', '>=' ) && false !== @preg_match( '/\X/u', '' ); + } + + return $can_use_pcre_x; +} + +/** + * Get the regexs generated from Unicode data. + * + * @param string|null $idx Optional. Return a specific regex only. Default null. + * @return array|string Returns keyed array if not given $idx or $idx doesn't exist, otherwise the specific regex string. + */ +function get_unicode_regexs( $idx = null ) { + static $eaw_regex; // East Asian Width regex. Characters that count as 2 characters as they're "wide" or "fullwidth". See http://www.unicode.org/reports/tr11/tr11-19.html + static $m_regex; // Mark characters regex (Unicode property "M") - mark combining "Mc", mark enclosing "Me" and mark non-spacing "Mn" chars that should be ignored for spacing purposes. + if ( null === $eaw_regex ) { + // Load both regexs generated from Unicode data. + require __DIR__ . '/unicode/regex.php'; + } + + if ( null !== $idx ) { + if ( 'eaw' === $idx ) { + return $eaw_regex; + } + if ( 'm' === $idx ) { + return $m_regex; + } + } + + return array( $eaw_regex, $m_regex, ); } diff --git a/lib/cli/notify/Dots.php b/lib/cli/notify/Dots.php index da9e355..2d00de2 100644 --- a/lib/cli/notify/Dots.php +++ b/lib/cli/notify/Dots.php @@ -12,20 +12,27 @@ namespace cli\notify; +use cli\Notify; +use cli\Streams; + /** - * A Notifer that displays a string of periods. + * A Notifier that displays a string of periods. */ -class Dots extends \cli\Notify { +class Dots extends Notify { + /** @var int */ protected $_dots; + /** @var string */ protected $_format = '{:msg}{:dots} ({:elapsed}, {:speed}/s)'; - protected $_iteration; + /** @var int */ + protected $_iteration = 0; /** - * Instatiates a Notification object. + * Instantiates a Notification object. * * @param string $msg The text to display next to the Notifier. * @param int $dots The number of dots to iterate through. * @param int $interval The interval in milliseconds between updates. + * @throws \InvalidArgumentException */ public function __construct($msg, $dots = 3, $interval = 100) { parent::__construct($msg, $interval); @@ -42,6 +49,7 @@ public function __construct($msg, $dots = 3, $interval = 100) { * * @param boolean $finish `true` if this was called from * `cli\Notify::finish()`, `false` otherwise. + * @return void * @see cli\out_padded() * @see cli\Notify::formatTime() * @see cli\Notify::speed() @@ -57,6 +65,6 @@ public function display($finish = false) { $speed = number_format(round($this->speed())); $elapsed = $this->formatTime($this->elapsed()); - \cli\out_padded($this->_format, compact('msg', 'dots', 'speed', 'elapsed')); + Streams::out_padded($this->_format, compact('msg', 'dots', 'speed', 'elapsed')); } } diff --git a/lib/cli/notify/Spinner.php b/lib/cli/notify/Spinner.php index 5e72a7a..80f5faf 100644 --- a/lib/cli/notify/Spinner.php +++ b/lib/cli/notify/Spinner.php @@ -12,12 +12,18 @@ namespace cli\notify; +use cli\Notify; +use cli\Streams; + /** * The `Spinner` Notifier displays an ASCII spinner. */ -class Spinner extends \cli\Notify { +class Spinner extends Notify { + /** @var string */ protected $_chars = '-\|/'; + /** @var string */ protected $_format = '{:msg} {:char} ({:elapsed}, {:speed}/s)'; + /** @var int */ protected $_iteration = 0; /** @@ -26,6 +32,7 @@ class Spinner extends \cli\Notify { * * @param boolean $finish `true` if this was called from * `cli\Notify::finish()`, `false` otherwise. + * @return void * @see cli\out_padded() * @see cli\Notify::formatTime() * @see cli\Notify::speed() @@ -37,6 +44,6 @@ public function display($finish = false) { $speed = number_format(round($this->speed())); $elapsed = $this->formatTime($this->elapsed()); - \cli\out_padded($this->_format, compact('msg', 'char', 'elapsed', 'speed')); + Streams::out_padded($this->_format, compact('msg', 'char', 'elapsed', 'speed')); } } diff --git a/lib/cli/progress/Bar.php b/lib/cli/progress/Bar.php index 68a2a40..8f32fff 100644 --- a/lib/cli/progress/Bar.php +++ b/lib/cli/progress/Bar.php @@ -12,6 +12,12 @@ namespace cli\progress; +use cli; +use cli\Notify; +use cli\Progress; +use cli\Shell; +use cli\Streams; + /** * Displays a progress bar spanning the entire shell. * @@ -19,18 +25,47 @@ * * ^MSG PER% [======================= ] 00:00 / 00:00$ */ -class Bar extends \cli\Progress { +class Bar extends Progress { + /** @var string */ protected $_bars = '=>'; + /** @var string */ protected $_formatMessage = '{:msg} {:percent}% ['; + /** @var string */ protected $_formatTiming = '] {:elapsed} / {:estimated}'; + /** @var string */ protected $_format = '{:msg}{:bar}{:timing}'; + /** + * Instantiates a Progress Bar. + * + * @param string $msg The text to display next to the Notifier. + * @param int $total The total number of ticks we will be performing. + * @param int $interval The interval in milliseconds between updates. + * @param string|null $formatMessage Optional format string for the message portion. + * @param string|null $formatTiming Optional format string for the timing portion. + * @param string|null $format Optional format string for the overall display. + */ + public function __construct($msg, $total, $interval = 100, $formatMessage = null, $formatTiming = null, $format = null) { + parent::__construct($msg, $total, $interval); + + if ($formatMessage !== null) { + $this->_formatMessage = $formatMessage; + } + if ($formatTiming !== null) { + $this->_formatTiming = $formatTiming; + } + if ($format !== null) { + $this->_format = $format; + } + } + /** * Prints the progress bar to the screen with percent complete, elapsed time * and estimated total time. * * @param boolean $finish `true` if this was called from * `cli\Notify::finish()`, `false` otherwise. + * @return void * @see cli\out() * @see cli\Notify::formatTime() * @see cli\Notify::elapsed() @@ -41,21 +76,46 @@ class Bar extends \cli\Progress { public function display($finish = false) { $_percent = $this->percent(); - $percent = str_pad(floor($_percent * 100), 3);; + $percent = str_pad((string)(int)floor($_percent * 100), 3); $msg = $this->_message; - $msg = \cli\render($this->_formatMessage, compact('msg', 'percent')); + $current = $this->current(); + $total = $this->total(); + $msg = Streams::render($this->_formatMessage, compact('msg', 'percent', 'current', 'total')); - $estimated = $this->formatTime($this->estimated()); + $estimated = $this->formatTime((int)$this->estimated()); $elapsed = str_pad($this->formatTime($this->elapsed()), strlen($estimated)); - $timing = \cli\render($this->_formatTiming, compact('elapsed', 'estimated')); + $timing = Streams::render($this->_formatTiming, compact('elapsed', 'estimated', 'current', 'total', 'percent')); - $size = \cli\Shell::columns(); + $size = Shell::columns(); + // On Windows, the cursor wraps to the next line if the output fills the entire width. + if ( Shell::is_windows() ) { + $size -= 1; + } $size -= strlen($msg . $timing); + if ( $size < 0 ) { + $size = 0; + } - $bar = str_repeat($this->_bars[0], floor($_percent * $size)) . $this->_bars[1]; + $bar = str_repeat($this->_bars[0], (int)floor($_percent * $size)) . $this->_bars[1]; // substr is needed to trim off the bar cap at 100% $bar = substr(str_pad($bar, $size, ' '), 0, $size); - \cli\out($this->_format, compact('msg', 'bar', 'timing')); + Streams::out($this->_format, compact('msg', 'bar', 'timing', 'current', 'total', 'percent')); + } + + /** + * This method augments the base definition from cli\Notify to optionally + * allow passing a new message. + * + * @param int $increment The amount to increment by. + * @param string $msg The text to display next to the Notifier. (optional) + * @return void + * @see cli\Notify::tick() + */ + public function tick($increment = 1, $msg = null) { + if ($msg) { + $this->_message = $msg; + } + Notify::tick($increment); } } diff --git a/lib/cli/table/Ascii.php b/lib/cli/table/Ascii.php index a55fda4..b71d5d4 100644 --- a/lib/cli/table/Ascii.php +++ b/lib/cli/table/Ascii.php @@ -12,26 +12,150 @@ namespace cli\table; +use cli\Colors; +use cli\Shell; + /** * The ASCII renderer renders tables with ASCII borders. */ class Ascii extends Renderer { + /** + * Valid wrapping modes. + */ + private const VALID_WRAPPING_MODES = array( 'wrap', 'word-wrap', 'truncate' ); + + /** + * Ellipsis character(s) used for truncation. + */ + private const ELLIPSIS = '...'; + + /** + * Width of the ellipsis in characters. + */ + private const ELLIPSIS_WIDTH = 3; + + /** + * @var array + */ protected $_characters = array( - 'corner' => '+', - 'line' => '-', - 'border' => '|' + 'corner' => '+', + 'line' => '-', + 'border' => '|', + 'padding' => ' ', ); + + /** + * @var string|null + */ protected $_border = null; + /** + * @var int|null + */ + protected $_constraintWidth = null; + + /** + * @var bool|array + */ + protected $_pre_colorized = false; + + /** + * @var string + */ + protected $_wrapping_mode = 'wrap'; // 'wrap', 'word-wrap', or 'truncate' + + /** + * Set the widths of each column in the table. + * + * @param array $widths The widths of the columns. + * @param bool $fallback Whether to use these values as fallback only. + * @return void + */ + public function setWidths( array $widths, $fallback = false ) { + if ( $fallback ) { + foreach ( $this->_widths as $index => $value ) { + $widths[ $index ] = $value; + } + } + $this->_widths = $widths; + + if ( is_null( $this->_constraintWidth ) ) { + $this->_constraintWidth = (int) Shell::columns(); + } + $col_count = count( $widths ); + $col_borders_count = $col_count ? ( ( $col_count - 1 ) * strlen( $this->_characters['border'] ) ) : 0; + $table_borders_count = strlen( $this->_characters['border'] ) * 2; + $col_padding_count = $col_count * strlen( $this->_characters['padding'] ) * 2; + $max_width = $this->_constraintWidth - $col_borders_count - $table_borders_count - $col_padding_count; + + if ( $widths && $max_width && array_sum( $widths ) > $max_width ) { + + $avg = (int) floor( $max_width / count( $widths ) ); + $resize_widths = array(); + $extra_width = 0; + foreach ( $widths as $width ) { + if ( $width > $avg ) { + $resize_widths[] = $width; + } else { + $extra_width = $extra_width + ( $avg - $width ); + } + } + + if ( ! empty( $resize_widths ) && $extra_width ) { + $avg_extra_width = (int) floor( $extra_width / count( $resize_widths ) ); + foreach ( $widths as &$width ) { + if ( in_array( $width, $resize_widths ) ) { + $width = $avg + $avg_extra_width; + array_shift( $resize_widths ); + // Last item gets the cake + if ( empty( $resize_widths ) ) { + $width = 0; // Zero it so not in sum. + $width = $max_width - array_sum( $widths ); + } + } + } + } + } + + $this->_widths = $widths; + // Reset border cache when widths change + $this->_border = null; + } + + /** + * Set the constraint width for the table + * + * @param int $constraintWidth + * @return void + */ + public function setConstraintWidth( $constraintWidth ) { + $this->_constraintWidth = $constraintWidth; + } + + /** + * Set the wrapping mode for table cells. + * + * @param string $mode One of: 'wrap' (default - wrap at character boundaries), + * 'word-wrap' (wrap at word boundaries), or 'truncate' (truncate with ellipsis). + * @return void + */ + public function setWrappingMode( $mode ) { + if ( ! in_array( $mode, self::VALID_WRAPPING_MODES, true ) ) { + throw new \InvalidArgumentException( "Invalid wrapping mode '$mode'. Must be one of: " . implode( ', ', self::VALID_WRAPPING_MODES ) ); + } + $this->_wrapping_mode = $mode; + } + /** * Set the characters used for rendering the Ascii table. * * The keys `corner`, `line` and `border` are used in rendering. * - * @param $characters array Characters used in rendering. + * @param array $characters Characters used in rendering. + * @return void */ - public function setCharacters(array $characters) { - $this->_characters = array_merge($this->_characters, $characters); + public function setCharacters( array $characters ) { + $this->_characters = array_merge( $this->_characters, $characters ); } /** @@ -41,10 +165,10 @@ public function setCharacters(array $characters) { * @return string The table border. */ public function border() { - if (!isset($this->_border)) { + if ( ! isset( $this->_border ) ) { $this->_border = $this->_characters['corner']; - foreach ($this->_widths as $width) { - $this->_border .= str_repeat($this->_characters['line'], $width + 2); + foreach ( $this->_widths as $width ) { + $this->_border .= str_repeat( $this->_characters['line'], $width + 2 ); $this->_border .= $this->_characters['corner']; } } @@ -55,18 +179,248 @@ public function border() { /** * Renders a row for output. * - * @param array $row The table row. - * @return string The formatted table row. + * @param array $row The table row. + * @return string The formatted table row. */ - public function row(array $row) { - $row = array_map(array($this, 'padColumn'), $row, array_keys($row)); - array_unshift($row, ''); // First border - array_push($row, ''); // Last border + public function row( array $row ) { + + $extra_row_count = 0; + $extra_rows = []; + + if ( count( $row ) > 0 ) { + $extra_rows = array_fill( 0, count( $row ), array() ); + + foreach ( $row as $col => $value ) { + $value = ( is_scalar( $value ) || ( is_object( $value ) && method_exists( $value, '__toString' ) ) ) ? (string) $value : ''; + $col_width = $this->_widths[ $col ]; + $encoding = function_exists( 'mb_detect_encoding' ) ? mb_detect_encoding( $value, null, true /*strict*/ ) : false; + $original_val_width = Colors::width( $value, self::isPreColorized( $col ), $encoding ); + if ( $col_width && ( $original_val_width > $col_width || strpos( $value, "\n" ) !== false ) ) { + $split_lines = preg_split( '/\r\n|\n/', $value ); + if ( false === $split_lines ) { + $split_lines = array( $value ); + } + + $wrapped_lines = []; + foreach ( $split_lines as $line ) { + $line_wrapped = $this->wrapText( $line, $col_width, $encoding, self::isPreColorized( $col ) ); + $wrapped_lines = array_merge( $wrapped_lines, $line_wrapped ); + } + + $row[ $col ] = array_shift( $wrapped_lines ); + foreach ( $wrapped_lines as $wrapped_line ) { + $extra_rows[ $col ][] = $wrapped_line; + ++$extra_row_count; + } + } + } + } + + $row = array_map( array( $this, 'padColumn' ), $row, array_keys( $row ) ); + array_unshift( $row, '' ); // First border + array_push( $row, '' ); // Last border + + $ret = join( $this->_characters['border'], $row ); + if ( $extra_row_count ) { + foreach ( $extra_rows as $col => $col_values ) { + while ( count( $col_values ) < $extra_row_count ) { + $col_values[] = ''; + } + } - return join($this->_characters['border'], $row); + do { + $row_values = array(); + $has_more = false; + foreach ( $extra_rows as $col => &$col_values ) { + $row_values[ $col ] = ! empty( $col_values ) ? array_shift( $col_values ) : ''; + if ( count( $col_values ) ) { + $has_more = true; + } + } + + $row_values = array_map( array( $this, 'padColumn' ), $row_values, array_keys( $row_values ) ); + array_unshift( $row_values, '' ); // First border + array_push( $row_values, '' ); // Last border + + $ret .= PHP_EOL . join( $this->_characters['border'], $row_values ); + } while ( $has_more ); + } + return $ret; + } + + /** + * Get the alignment for a column. + * + * @param int $column Column index. + * @return int Alignment constant (STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH). + */ + private function getColumnAlignment( $column ) { + $column_name = isset( $this->_headers[ $column ] ) ? $this->_headers[ $column ] : ''; + if ( $column_name !== '' && array_key_exists( $column_name, $this->_alignments ) ) { + return $this->_alignments[ $column_name ]; + } + return Column::ALIGN_LEFT; + } + + /** + * Pad a column value. + * + * @param string $content The column content. + * @param int $column The column index. + * @return string The padded column. + */ + private function padColumn( $content, $column ) { + $alignment = $this->getColumnAlignment( $column ); + $content = str_replace( "\t", ' ', (string) $content ); + return $this->_characters['padding'] . Colors::pad( $content, $this->_widths[ $column ], $this->isPreColorized( $column ), false, $alignment ) . $this->_characters['padding']; + } + + /** + * Set whether items are pre-colorized. + * + * @param bool|array $pre_colorized A boolean to set all columns in the table as pre-colorized, or an array of booleans keyed by column index (number) to set individual columns as pre-colorized. + * @return void + */ + public function setPreColorized( $pre_colorized ) { + $this->_pre_colorized = $pre_colorized; + } + + /** + * Wrap text based on the configured wrapping mode. + * + * @param string $text The text to wrap. + * @param int $width The maximum width. + * @param string|bool $encoding The text encoding. + * @param bool $is_precolorized Whether the text is pre-colorized. + * @return array Array of wrapped lines. + */ + protected function wrapText( $text, $width, $encoding, $is_precolorized ) { + if ( ! $width ) { + return array( $text ); + } + + $text_width = Colors::width( $text, $is_precolorized, $encoding ); + + // If text fits, no wrapping needed + if ( $text_width <= $width ) { + return array( $text ); + } + + // Handle truncate mode + if ( 'truncate' === $this->_wrapping_mode ) { + if ( $width <= self::ELLIPSIS_WIDTH ) { + // Not enough space for ellipsis, just truncate + return array( (string) \cli\safe_substr( $text, 0, $width, true /*is_width*/, $encoding ) ); + } + + // Truncate and add ellipsis + $truncated = (string) \cli\safe_substr( $text, 0, $width - self::ELLIPSIS_WIDTH, true /*is_width*/, $encoding ); + return array( $truncated . self::ELLIPSIS ); + } + + // Handle word-wrap mode + if ( 'word-wrap' === $this->_wrapping_mode ) { + return $this->wordWrap( $text, $width, $encoding, $is_precolorized ); + } + + // Default: character-boundary wrapping + $wrapped_lines = array(); + $line = $text; + + // Use the new color-aware wrapping for pre-colorized content + if ( $is_precolorized ) { + $wrapped_lines = Colors::wrapPreColorized( $line, $width, $encoding ); + } else { + // For non-colorized content, use character-boundary wrapping + do { + $wrapped_value = (string) \cli\safe_substr( $line, 0, $width, true /*is_width*/, $encoding ); + $val_width = Colors::width( $wrapped_value, $is_precolorized, $encoding ); + if ( $val_width ) { + $wrapped_lines[] = $wrapped_value; + $line = (string) \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding ); + } + } while ( $line ); + } + + return $wrapped_lines; + } + + /** + * Wrap text at word boundaries. + * + * @param string $text The text to wrap. + * @param int $width The maximum width. + * @param string|bool $encoding The text encoding. + * @param bool $is_precolorized Whether the text is pre-colorized. + * @return array Array of wrapped lines. + */ + protected function wordWrap( $text, $width, $encoding, $is_precolorized ) { + $wrapped_lines = array(); + $current_line = ''; + $current_line_width = 0; + + // Split by spaces and hyphens while keeping the delimiters + $words = preg_split( '/(\s+|-)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); + if ( false === $words ) { + $words = array( $text ); + } + + foreach ( $words as $word ) { + $word_width = Colors::width( $word, $is_precolorized, $encoding ); + + // If this word alone exceeds the width, we need to split it + if ( $word_width > $width ) { + // Flush current line if not empty + if ( $current_line !== '' ) { + $wrapped_lines[] = $current_line; + $current_line = ''; + $current_line_width = 0; + } + + // Split the long word at character boundaries + $remaining_word = $word; + while ( $remaining_word ) { + $chunk = (string) \cli\safe_substr( $remaining_word, 0, $width, true /*is_width*/, $encoding ); + $wrapped_lines[] = $chunk; + $remaining_word = (string) \cli\safe_substr( $remaining_word, \cli\safe_strlen( $chunk, $encoding ), null /*length*/, false /*is_width*/, $encoding ); + } + continue; + } + + // Check if adding this word would exceed the width + if ( $current_line !== '' && $current_line_width + $word_width > $width ) { + // Start a new line + $wrapped_lines[] = $current_line; + $current_line = $word; + $current_line_width = $word_width; + } else { + // Add to current line + $current_line .= $word; + $current_line_width += $word_width; + } + } + + // Add any remaining content + if ( $current_line !== '' ) { + $wrapped_lines[] = $current_line; + } + + return $wrapped_lines ?: array( '' ); } - private function padColumn($content, $column) { - return ' ' . \cli\Colors::pad($content, $this->_widths[$column]) . ' '; + /** + * Is a column pre-colorized? + * + * @param int $column Column index to check. + * @return bool True if whole table is marked as pre-colorized, or if the individual column is pre-colorized; else false. + */ + public function isPreColorized( $column ) { + if ( is_bool( $this->_pre_colorized ) ) { + return $this->_pre_colorized; + } + if ( is_array( $this->_pre_colorized ) && isset( $this->_pre_colorized[ $column ] ) ) { + return $this->_pre_colorized[ $column ]; + } + return false; } } diff --git a/lib/cli/table/Column.php b/lib/cli/table/Column.php new file mode 100644 index 0000000..5c1f733 --- /dev/null +++ b/lib/cli/table/Column.php @@ -0,0 +1,22 @@ + + * @copyright 2010 James Logsdom (http://girsbrain.org) + * @license http://www.opensource.org/licenses/mit-license.php The MIT License + */ + +namespace cli\table; + +/** + * Column alignment constants for table rendering. + */ +interface Column { + const ALIGN_LEFT = STR_PAD_RIGHT; + const ALIGN_RIGHT = STR_PAD_LEFT; + const ALIGN_CENTER = STR_PAD_BOTH; +} diff --git a/lib/cli/table/Renderer.php b/lib/cli/table/Renderer.php index 14a70a1..10aa85a 100644 --- a/lib/cli/table/Renderer.php +++ b/lib/cli/table/Renderer.php @@ -16,18 +16,65 @@ * Table renderers are used to change how a table is displayed. */ abstract class Renderer { + /** + * @var array + */ protected $_widths = array(); - public function __construct(array $widths = array()) { + /** + * @var array + */ + protected $_alignments = array(); + + /** + * @var array + */ + protected $_headers = array(); + + /** + * Constructor. + * + * @param array $widths Column widths. + * @param array $alignments Column alignments. + */ + public function __construct(array $widths = array(), array $alignments = array()) { $this->setWidths($widths); + $this->setAlignments($alignments); + } + + /** + * Set the alignments of each column in the table. + * + * @param array $alignments The alignments of the columns. + * @return void + */ + public function setAlignments(array $alignments) { + $this->_alignments = $alignments; + } + + /** + * Set the headers of the table. + * + * @param array $headers The headers of the table. + * @return void + */ + public function setHeaders(array $headers) { + $this->_headers = $headers; } /** * Set the widths of each column in the table. * - * @param array $widths The widths of the columns. + * @param array $widths The widths of the columns. + * @param bool $fallback Whether to use these values as fallback only. + * @return void */ - public function setWidths(array $widths) { + public function setWidths(array $widths, $fallback = false) { + if ($fallback) { + foreach ( $this->_widths as $index => $value ) { + $widths[$index] = $value; + } + } $this->_widths = $widths; } @@ -35,7 +82,7 @@ public function setWidths(array $widths) { * Render a border for the top and bottom and separating the headers from the * table rows. * - * @return string The table border. + * @return string|null The table border. */ public function border() { return null; @@ -44,8 +91,8 @@ public function border() { /** * Renders a row for output. * - * @param array $row The table row. - * @return string The formatted table row. + * @param array $row The table row. + * @return string The formatted table row. */ - abstract public function row(array $row); + abstract public function row( array $row ); } diff --git a/lib/cli/table/Tabular.php b/lib/cli/table/Tabular.php index 6e7c502..f373799 100644 --- a/lib/cli/table/Tabular.php +++ b/lib/cli/table/Tabular.php @@ -19,10 +19,41 @@ class Tabular extends Renderer { /** * Renders a row for output. * - * @param array $row The table row. - * @return string The formatted table row. + * @param array $row The table row. + * @return string The formatted table row. */ - public function row(array $row) { - return implode("\t", array_values($row)); + public function row( array $row ) { + /** @var array> $rows */ + $rows = []; + $output = ''; + $split_lines = []; + $col = null; + + foreach ( $row as $col => $value ) { + $value = ( isset( $value ) && ( is_scalar( $value ) || ( is_object( $value ) && method_exists( $value, '__toString' ) ) ) ) ? (string) $value : ''; + $value = str_replace( "\t", ' ', $value ); + $split_lines = preg_split( '/\r\n|\n/', $value ); + if ( false === $split_lines ) { + $split_lines = array( $value ); + } + // Keep anything before the first line break on the original line + $row[ $col ] = array_shift( $split_lines ); + } + + $rows[] = $row; + + if ( null !== $col ) { + foreach ( $split_lines as $i => $line ) { + if ( ! isset( $rows[ $i + 1 ] ) ) { + $rows[ $i + 1 ] = array_fill_keys( array_keys( $row ), '' ); + } + $rows[ $i + 1 ][ $col ] = $line; + } + } + + foreach ( $rows as $r ) { + $output .= implode( "\t", $r ) . PHP_EOL; + } + return rtrim( $output, PHP_EOL ); } } diff --git a/lib/cli/tree/Ascii.php b/lib/cli/tree/Ascii.php new file mode 100644 index 0000000..a94a8ef --- /dev/null +++ b/lib/cli/tree/Ascii.php @@ -0,0 +1,41 @@ + + * @copyright 2010 James Logsdom (http://girsbrain.org) + * @license http://www.opensource.org/licenses/mit-license.php The MIT License + */ + +namespace cli\tree; + +/** + * The ASCII renderer renders trees with ASCII lines. + */ +class Ascii extends Renderer { + + /** + * @param array $tree + * @return string + */ + public function render(array $tree) + { + $output = ''; + + $treeIterator = new \RecursiveTreeIterator( + new \RecursiveArrayIterator($tree), + \RecursiveTreeIterator::SELF_FIRST + ); + + foreach ($treeIterator as $val) + { + $output .= $val . "\n"; + } + + return $output; + } + +} diff --git a/lib/cli/tree/Markdown.php b/lib/cli/tree/Markdown.php new file mode 100644 index 0000000..7f718f7 --- /dev/null +++ b/lib/cli/tree/Markdown.php @@ -0,0 +1,70 @@ + + * @copyright 2010 James Logsdom (http://girsbrain.org) + * @license http://www.opensource.org/licenses/mit-license.php The MIT License + */ + +namespace cli\tree; + +/** + * The ASCII renderer renders trees with ASCII lines. + */ +class Markdown extends Renderer { + + /** + * How many spaces to indent by + * @var int + */ + protected $_padding = 2; + + /** + * @param int $padding Optional. Default 2. + */ + function __construct($padding = null) + { + if ($padding) + { + $this->_padding = $padding; + } + } + + /** + * Renders the tree + * + * @param array $tree + * @param int $level Optional + * @return string + */ + public function render(array $tree, $level = 0) + { + $output = ''; + + foreach ($tree as $label => $next) + { + + if (is_string($next)) + { + $label = $next; + } + + // Output the label + $output .= sprintf("%s- %s\n", str_repeat(' ', $level * $this->_padding), $label); + + // Next level + if (is_array($next)) + { + $output .= $this->render($next, $level + 1); + } + + } + + return $output; + } + +} diff --git a/lib/cli/tree/Renderer.php b/lib/cli/tree/Renderer.php new file mode 100644 index 0000000..8348ffe --- /dev/null +++ b/lib/cli/tree/Renderer.php @@ -0,0 +1,26 @@ + + * @copyright 2010 James Logsdom (http://girsbrain.org) + * @license http://www.opensource.org/licenses/mit-license.php The MIT License + */ + +namespace cli\tree; + +/** + * Tree renderers are used to change how a tree is displayed. + */ +abstract class Renderer { + + /** + * @param array $tree + * @return string|null + */ + abstract public function render(array $tree); + +} diff --git a/lib/cli/unicode/regex.php b/lib/cli/unicode/regex.php new file mode 100644 index 0000000..6b822db --- /dev/null +++ b/lib/cli/unicode/regex.php @@ -0,0 +1,6 @@ + + + tests/ + + + + + lib/ + + + diff --git a/test.php b/test.php index e222d41..f8f8d03 100644 --- a/test.php +++ b/test.php @@ -1,9 +1,10 @@ array( 'verbose' => array( 'description' => 'Turn on verbose mode', @@ -25,10 +26,8 @@ try { $args->parse(); -} catch (\cli\InvalidArguments $e) { +} catch (cli\InvalidArguments $e) { echo $e->getMessage() . "\n\n"; } print_r($args->getArguments()); - -?> diff --git a/tests/Test_Arguments.php b/tests/Test_Arguments.php new file mode 100644 index 0000000..33ff728 --- /dev/null +++ b/tests/Test_Arguments.php @@ -0,0 +1,322 @@ +flags = array( + 'flag1' => array( + 'aliases' => 'f', + 'description' => 'Test flag 1' + ), + 'flag2' => array( + 'description' => 'Test flag 2' + ) + ); + + $this->options = array( + 'option1' => array( + 'aliases' => 'o', + 'description' => 'Test option 1' + ), + 'option2' => array( + 'aliases' => array('x', 'y'), + 'description' => 'Test option 2 with default', + 'default' => 'some default value' + ) + ); + + $this->settings = array( + 'strict' => true, + 'flags' => $this->flags, + 'options' => $this->options + ); + + set_error_handler( + static function ( $errno, $errstr ) { + throw new \Exception( $errstr, $errno ); + }, + E_ALL + ); + } + + /** + * Tear down fixtures + */ + public function tear_down() + { + $this->flags = null; + $this->options = null; + $this->settings = null; + self::clearArgv(); + restore_error_handler(); + } + + /** + * Test adding a flag, getting a flag and getting all flags + */ + public function testAddFlags() + { + $args = new cli\Arguments($this->settings); + + $expectedFlags = $this->flags; + $expectedFlags['flag1']['default'] = false; + $expectedFlags['flag1']['stackable'] = false; + $expectedFlags['flag2']['default'] = false; + $expectedFlags['flag2']['stackable'] = false; + $expectedFlags['flag2']['aliases'] = array(); + + $this->assertSame($expectedFlags, $args->getFlags()); + + $this->assertSame($expectedFlags['flag1'], $args->getFlag('flag1')); + $this->assertSame($expectedFlags['flag1'], $args->getFlag('f')); + + $expectedFlag1Argument = new cli\arguments\Argument('-f'); + $this->assertSame($expectedFlags['flag1'], $args->getFlag($expectedFlag1Argument)); + } + + /** + * Test adding a option, getting a option and getting all options + */ + public function testAddOptions() + { + $args = new cli\Arguments($this->settings); + + $expectedOptions = $this->options; + $expectedOptions['option1']['default'] = null; + + $this->assertSame($expectedOptions, $args->getOptions()); + + $this->assertSame($expectedOptions['option1'], $args->getOption('option1')); + $this->assertSame($expectedOptions['option1'], $args->getOption('o')); + + $expectedOption1Argument = new cli\arguments\Argument('-o'); + $this->assertSame($expectedOptions['option1'], $args->getOption($expectedOption1Argument)); + } + + /** + * Data provider with valid args and options + * + * @return array set of args and expected parsed values + */ + public static function settingsWithValidOptions() + { + return array( + array( + array('-o', 'option_value', '-f'), + array('option1' => 'option_value', 'flag1' => true) + ), + array( + array('--option1', 'option_value', '--flag1'), + array('option1' => 'option_value', 'flag1' => true) + ), + array( + array('-f', '--option1', 'option_value'), + array('flag1' => true, 'option1' => 'option_value') + ) + ); + } + + /** + * Data provider with missing options + * + * @return array set of args and expected parsed values + */ + public static function settingsWithMissingOptions() + { + return array( + array( + array('-f', '--option1'), + array('flag1' => true, 'option1' => 'Error should be triggered') + ), + array( + array('--option1', '-f'), + array('option1' => 'Error should be triggered', 'flag1' => true) + ) + ); + } + + /** + * Data provider with missing options. The default value should be populated + * + * @return array set of args and expected parsed values + */ + public static function settingsWithMissingOptionsWithDefault() + { + return array( + array( + array('-f', '--option2'), + array('flag1' => true, 'option2' => 'some default value') + ), + array( + array('--option2', '-f'), + array('option2' => 'some default value', 'flag1' => true) + ) + ); + } + + public static function settingsWithNoOptionsWithDefault() + { + return array( + array( + array(), + array('flag1' => false, 'flag2' => false, 'option2' => 'some default value') + ) + ); + } + + /** + * Generic private testParse method. + * + * @param array $args arguments as they appear in the cli + * @param array $expectedValues expected values after parsing + */ + private function _testParse($cliParams, $expectedValues) + { + self::pushToArgv($cliParams); + + $args = new cli\Arguments($this->settings); + $args->parse(); + + foreach ($expectedValues as $name => $value) { + if ($args->isFlag($name)) { + $this->assertEquals($value, $args[$name]); + } + + if ($args->isOption($name)) { + $this->assertEquals($value, $args[$name]); + } + } + } + + /** + * @param array $args arguments as they appear in the cli + * @param array $expectedValues expected values after parsing + * + * @dataProvider settingsWithValidOptions + */ + #[DataProvider( 'settingsWithValidOptions' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function testParseWithValidOptions($cliParams, $expectedValues) + { + $this->_testParse($cliParams, $expectedValues); + } + + /** + * @param array $args arguments as they appear in the cli + * @param array $expectedValues expected values after parsing + * @dataProvider settingsWithMissingOptions + */ + #[DataProvider( 'settingsWithMissingOptions' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function testParseWithMissingOptions($cliParams, $expectedValues) + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('no value given for --option1'); + $this->_testParse($cliParams, $expectedValues); + } + + /** + * @param array $args arguments as they appear in the cli + * @param array $expectedValues expected values after parsing + * @dataProvider settingsWithMissingOptionsWithDefault + */ + #[DataProvider( 'settingsWithMissingOptionsWithDefault' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function testParseWithMissingOptionsWithDefault($cliParams, $expectedValues) + { + $this->_testParse($cliParams, $expectedValues); + } + + /** + * @param array $args arguments as they appear in the cli + * @param array $expectedValues expected values after parsing + * @dataProvider settingsWithNoOptionsWithDefault + */ + #[DataProvider( 'settingsWithNoOptionsWithDefault' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function testParseWithNoOptionsWithDefault($cliParams, $expectedValues) { + $this->_testParse($cliParams, $expectedValues); + } + + public function testHelpScreenRender() { + $args = new \cli\Arguments( $this->settings ); + $help = $args->getHelpScreen(); + + $output = $help->render(); + + // It should contain Flags and Options sections + $this->assertStringContainsString( 'Flags', $output ); + $this->assertStringContainsString( 'Options', $output ); + + // Now test with ONLY flags + $settings = array( + 'flags' => $this->flags, + ); + $args = new \cli\Arguments( $settings ); + $help = $args->getHelpScreen(); + $output = $help->render(); + + $this->assertStringContainsString( 'Flags', $output ); + $this->assertStringNotContainsString( 'Options', $output ); + + // It should NOT have leading/trailing newlines or empty sections + $this->assertSame( trim( $output ), $output, 'Output should not have leading/trailing whitespace' ); + } +} diff --git a/tests/Test_Cli.php b/tests/Test_Cli.php new file mode 100644 index 0000000..bd4476d --- /dev/null +++ b/tests/Test_Cli.php @@ -0,0 +1,572 @@ +assertEquals( \cli\Colors::length( 'x' ), 1 ); + $this->assertEquals( \cli\Colors::length( '日' ), 1 ); + } + + function test_string_width() { + $this->assertEquals( \cli\Colors::width( 'x' ), 1 ); + $this->assertEquals( \cli\Colors::width( '日' ), 2 ); // Double-width char. + } + + function test_encoded_string_length() { + + $this->assertEquals( \cli\Colors::length( 'hello' ), 5 ); + $this->assertEquals( \cli\Colors::length( 'óra' ), 3 ); + $this->assertEquals( \cli\Colors::length( '日本語' ), 3 ); + + } + + function test_encoded_string_width() { + + $this->assertEquals( \cli\Colors::width( 'hello' ), 5 ); + $this->assertEquals( \cli\Colors::width( 'óra' ), 3 ); + $this->assertEquals( \cli\Colors::width( '日本語' ), 6 ); // 3 double-width chars. + + } + + function test_encoded_string_pad() { + + $this->assertEquals( 6, strlen( \cli\Colors::pad( 'hello', 6 ) ) ); + $this->assertEquals( 7, strlen( \cli\Colors::pad( 'óra', 6 ) ) ); // special characters take one byte + $this->assertEquals( 9, strlen( \cli\Colors::pad( '日本語', 6 ) ) ); // each character takes two bytes + $this->assertEquals( 17, strlen( \cli\Colors::pad( 'עִבְרִית', 6 ) ) ); // process Hebrew vowels + $this->assertEquals( 6, strlen( \cli\Colors::pad( 'hello', 6, false, false, STR_PAD_RIGHT ) ) ); + $this->assertEquals( 7, strlen( \cli\Colors::pad( 'óra', 6, false, false, STR_PAD_LEFT ) ) ); // special characters take one byte + $this->assertEquals( 9, strlen( \cli\Colors::pad( '日本語', 6, false, false, STR_PAD_BOTH ) ) ); // each character takes two bytes + $this->assertSame( 4, strpos( \cli\Colors::pad( 'hello', 10, false, false, STR_PAD_RIGHT ), 'o' ) ); + $this->assertSame( 9, strpos( \cli\Colors::pad( 'hello', 10, false, false, STR_PAD_LEFT ), 'o' ) ); + $this->assertSame( 6, strpos( \cli\Colors::pad( 'hello', 10, false, false, STR_PAD_BOTH ), 'o' ) ); + $this->assertSame( 1, strpos( \cli\Colors::pad( 'hello', 10, false, false, STR_PAD_RIGHT ), 'e' ) ); + $this->assertSame( 6, strpos( \cli\Colors::pad( 'hello', 10, false, false, STR_PAD_LEFT ), 'e' ) ); + $this->assertSame( 3, strpos( \cli\Colors::pad( 'hello', 10, false, false, STR_PAD_BOTH ), 'e' ) ); + } + + function test_colorized_string_pad() { + // Colors enabled. + Colors::enable( true ); + + $x = Colors::colorize( '%Gx%n', true ); // colorized `x` string + $ora = Colors::colorize( "%Góra%n", true ); // colorized `óra` string + + $this->assertSame( 22, strlen( Colors::pad( $x, 11 ) ) ); + $this->assertSame( 22, strlen( Colors::pad( $x, 11, false /*pre_colorized*/ ) ) ); + $this->assertSame( 22, strlen( Colors::pad( $x, 11, true /*pre_colorized*/ ) ) ); + + $this->assertSame( 23, strlen( Colors::pad( $ora, 11 ) ) ); // +1 for two-byte "ó". + $this->assertSame( 23, strlen( Colors::pad( $ora, 11, false /*pre_colorized*/ ) ) ); + $this->assertSame( 23, strlen( Colors::pad( $ora, 11, true /*pre_colorized*/ ) ) ); + + // Colors disabled. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + $this->assertSame( 20, strlen( Colors::pad( $x, 20 ) ) ); + $this->assertSame( 20, strlen( Colors::pad( $x, 20, false /*pre_colorized*/ ) ) ); + $this->assertSame( 31, strlen( Colors::pad( $x, 20, true /*pre_colorized*/ ) ) ); + + $this->assertSame( 21, strlen( Colors::pad( $ora, 20 ) ) ); // +1 for two-byte "ó". + $this->assertSame( 21, strlen( Colors::pad( $ora, 20, false /*pre_colorized*/ ) ) ); + $this->assertSame( 32, strlen( Colors::pad( $ora, 20, true /*pre_colorized*/ ) ) ); + } + + function test_encoded_substr() { + + $this->assertEquals( \cli\safe_substr( \cli\Colors::pad( 'hello', 6), 0, 2 ), 'he' ); + $this->assertEquals( \cli\safe_substr( \cli\Colors::pad( 'óra', 6), 0, 2 ), 'ór' ); + $this->assertEquals( \cli\safe_substr( \cli\Colors::pad( '日本語', 6), 0, 2 ), '日本' ); + + $this->assertSame( 'el', \cli\safe_substr( Colors::pad( 'hello', 6 ), 1, 2 ) ); + + $this->assertSame( 'a ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 2, 2 ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 5, 2 ) ); + + $this->assertSame( '本語', \cli\safe_substr( Colors::pad( '日本語', 8 ), 1, 2 ) ); + $this->assertSame( '語 ', \cli\safe_substr( Colors::pad( '日本語', 8 ), 2, 2 ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( '日本語', 8 ), -1 ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( '日本語', 8 ), -1, 2 ) ); + $this->assertSame( '語 ', \cli\safe_substr( Colors::pad( '日本語', 8 ), -3, 3 ) ); + } + + function test_various_substr() { + // Save. + $test_safe_substr = getenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); + if ( function_exists( 'mb_detect_order' ) ) { + $mb_detect_order = mb_detect_order(); + } + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); + + // Latin, kana, Latin, Latin combining, Thai combining, Hangul. + $str = 'lムnöม้p를'; // 18 bytes. + + // Large string. + $large_str_str_start = 65536 * 2; + $large_str = str_repeat( 'a', $large_str_str_start ) . $str; + $large_str_len = strlen( $large_str ); // 128K + 18 bytes. + + if ( \cli\can_use_icu() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=1' ); // Tests grapheme_substr(). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( 'lムn', \cli\safe_substr( $str, 0, 3 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, 0, 4 ) ); + $this->assertSame( 'lムnöม้', \cli\safe_substr( $str, 0, 5 ) ); + $this->assertSame( 'lムnöม้p', \cli\safe_substr( $str, 0, 6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 7 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 8 ) ); + $this->assertSame( '', \cli\safe_substr( $str, 19 ) ); // Start too large. + $this->assertSame( '', \cli\safe_substr( $str, 19, 7 ) ); // Start too large, with length. + $this->assertSame( '', \cli\safe_substr( $str, 8 ) ); // Start same as length. + $this->assertSame( '', \cli\safe_substr( $str, 8, 0 ) ); // Start same as length, with zero length. + $this->assertSame( '를', \cli\safe_substr( $str, -1 ) ); + $this->assertSame( 'p를', \cli\safe_substr( $str, -2 ) ); + $this->assertSame( 'ม้p를', \cli\safe_substr( $str, -3 ) ); + $this->assertSame( 'öม้p를', \cli\safe_substr( $str, -4 ) ); + $this->assertSame( 'öม้p', \cli\safe_substr( $str, -4, 3 ) ); + $this->assertSame( 'nö', \cli\safe_substr( $str, -5, 2 ) ); + $this->assertSame( 'ム', \cli\safe_substr( $str, -6, 1 ) ); + $this->assertSame( 'ムnöม้p를', \cli\safe_substr( $str, -6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -7 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, -7, 4 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -8 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -9 ) ); // Negative start too large. + + $this->assertSame( $large_str, \cli\safe_substr( $large_str, 0 ) ); + $this->assertSame( '', \cli\safe_substr( $large_str, $large_str_str_start, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $large_str, $large_str_str_start, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $large_str, $large_str_str_start, 2 ) ); + $this->assertSame( 'p를', \cli\safe_substr( $large_str, -2 ) ); + } + + if ( \cli\can_use_pcre_x() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=2' ); // Tests preg_split( '/\X/u' ). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( 'lムn', \cli\safe_substr( $str, 0, 3 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, 0, 4 ) ); + $this->assertSame( 'lムnöม้', \cli\safe_substr( $str, 0, 5 ) ); + $this->assertSame( 'lムnöม้p', \cli\safe_substr( $str, 0, 6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 7 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 8 ) ); + $this->assertSame( '', \cli\safe_substr( $str, 19 ) ); // Start too large. + $this->assertSame( '', \cli\safe_substr( $str, 19, 7 ) ); // Start too large, with length. + $this->assertSame( '', \cli\safe_substr( $str, 8 ) ); // Start same as length. + $this->assertSame( '', \cli\safe_substr( $str, 8, 0 ) ); // Start same as length, with zero length. + $this->assertSame( '를', \cli\safe_substr( $str, -1 ) ); + $this->assertSame( 'p를', \cli\safe_substr( $str, -2 ) ); + $this->assertSame( 'ม้p를', \cli\safe_substr( $str, -3 ) ); + $this->assertSame( 'öม้p를', \cli\safe_substr( $str, -4 ) ); + $this->assertSame( 'öม้p', \cli\safe_substr( $str, -4, 3 ) ); + $this->assertSame( 'nö', \cli\safe_substr( $str, -5, 2 ) ); + $this->assertSame( 'ム', \cli\safe_substr( $str, -6, 1 ) ); + $this->assertSame( 'ムnöม้p를', \cli\safe_substr( $str, -6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -7 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, -7, 4 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -8 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -9 ) ); // Negative start too large. + + $this->assertSame( $large_str, \cli\safe_substr( $large_str, 0 ) ); + $this->assertSame( '', \cli\safe_substr( $large_str, $large_str_str_start, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $large_str, $large_str_str_start, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $large_str, $large_str_str_start, 2 ) ); + $this->assertSame( 'p를', \cli\safe_substr( $large_str, -2 ) ); + } + + if ( function_exists( 'mb_substr' ) ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=4' ); // Tests mb_substr(). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( 'lムn', \cli\safe_substr( $str, 0, 3 ) ); + $this->assertSame( 'lムno', \cli\safe_substr( $str, 0, 4 ) ); // Wrong. + } + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=8' ); // Tests substr(). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( "l\xe3", \cli\safe_substr( $str, 0, 2 ) ); // Corrupt. + $this->assertSame( '', \cli\safe_substr( $str, strlen( $str ) + 1 ) ); // Return '' not false to match behavior of other methods when `$start` > strlen. + + // Non-UTF-8 - both grapheme_substr() and preg_split( '/\X/u' ) will fail. + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); + + if ( function_exists( 'mb_substr' ) && function_exists( 'mb_detect_order' ) ) { + // Latin-1 + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $str = "\xe0b\xe7"; // "àbç" in ISO-8859-1 + $this->assertSame( "\xe0b", \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( "\xe0b", mb_substr( $str, 0, 2, 'ISO-8859-1' ) ); + } + + // Restore. + putenv( false == $test_safe_substr ? 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' : "PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=$test_safe_substr" ); + if ( function_exists( 'mb_detect_order' ) ) { + mb_detect_order( $mb_detect_order ); + } + } + + function test_is_width_encoded_substr() { + + $this->assertSame( 'he', \cli\safe_substr( Colors::pad( 'hello', 6 ), 0, 2, true /*is_width*/ ) ); + $this->assertSame( 'ór', \cli\safe_substr( Colors::pad( 'óra', 6 ), 0, 2, true /*is_width*/ ) ); + $this->assertSame( '日', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 2, true /*is_width*/ ) ); + $this->assertSame( '日', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 3, true /*is_width*/ ) ); + $this->assertSame( '日本', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 4, true /*is_width*/ ) ); + $this->assertSame( '日本語', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 6, true /*is_width*/ ) ); + $this->assertSame( '日本語 ', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 7, true /*is_width*/ ) ); + + $this->assertSame( 'el', \cli\safe_substr( Colors::pad( 'hello', 6 ), 1, 2, true /*is_width*/ ) ); + + $this->assertSame( 'a ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 2, 2, true /*is_width*/ ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 5, 2, true /*is_width*/ ) ); + + $this->assertSame( '', \cli\safe_substr( '1日4本語90', 0, 0, true /*is_width*/ ) ); + $this->assertSame( '1', \cli\safe_substr( '1日4本語90', 0, 1, true /*is_width*/ ) ); + $this->assertSame( '1', \cli\safe_substr( '1日4本語90', 0, 2, true /*is_width*/ ) ); + $this->assertSame( '1日', \cli\safe_substr( '1日4本語90', 0, 3, true /*is_width*/ ) ); + $this->assertSame( '1日4', \cli\safe_substr( '1日4本語90', 0, 4, true /*is_width*/ ) ); + $this->assertSame( '1日4', \cli\safe_substr( '1日4本語90', 0, 5, true /*is_width*/ ) ); + $this->assertSame( '1日4本', \cli\safe_substr( '1日4本語90', 0, 6, true /*is_width*/ ) ); + $this->assertSame( '1日4本', \cli\safe_substr( '1日4本語90', 0, 7, true /*is_width*/ ) ); + $this->assertSame( '1日4本語', \cli\safe_substr( '1日4本語90', 0, 8, true /*is_width*/ ) ); + $this->assertSame( '1日4本語9', \cli\safe_substr( '1日4本語90', 0, 9, true /*is_width*/ ) ); + $this->assertSame( '1日4本語90', \cli\safe_substr( '1日4本語90', 0, 10, true /*is_width*/ ) ); + $this->assertSame( '1日4本語90', \cli\safe_substr( '1日4本語90', 0, 11, true /*is_width*/ ) ); + + $this->assertSame( '日', \cli\safe_substr( '1日4本語90', 1, 2, true /*is_width*/ ) ); + $this->assertSame( '日4', \cli\safe_substr( '1日4本語90', 1, 3, true /*is_width*/ ) ); + $this->assertSame( '4本語9', \cli\safe_substr( '1日4本語90', 2, 6, true /*is_width*/ ) ); + + $this->assertSame( '本', \cli\safe_substr( '1日4本語90', 3, 1, true /*is_width*/ ) ); + $this->assertSame( '本', \cli\safe_substr( '1日4本語90', 3, 2, true /*is_width*/ ) ); + $this->assertSame( '本', \cli\safe_substr( '1日4本語90', 3, 3, true /*is_width*/ ) ); + $this->assertSame( '本語', \cli\safe_substr( '1日4本語90', 3, 4, true /*is_width*/ ) ); + $this->assertSame( '本語9', \cli\safe_substr( '1日4本語90', 3, 5, true /*is_width*/ ) ); + + $this->assertSame( '0', \cli\safe_substr( '1日4本語90', 6, 1, true /*is_width*/ ) ); + $this->assertSame( '', \cli\safe_substr( '1日4本語90', 7, 1, true /*is_width*/ ) ); + $this->assertSame( '', \cli\safe_substr( '1日4本語90', 6, 0, true /*is_width*/ ) ); + + $this->assertSame( '0', \cli\safe_substr( '1日4本語90', -1, 3, true /*is_width*/ ) ); + $this->assertSame( '90', \cli\safe_substr( '1日4本語90', -2, 3, true /*is_width*/ ) ); + $this->assertSame( '語9', \cli\safe_substr( '1日4本語90', -3, 3, true /*is_width*/ ) ); + $this->assertSame( '本語9', \cli\safe_substr( '1日4本語90', -4, 5, true /*is_width*/ ) ); + } + + function test_colorized_string_length() { + $this->assertEquals( \cli\Colors::length( \cli\Colors::colorize( '%Gx%n', true ) ), 1 ); + $this->assertEquals( \cli\Colors::length( \cli\Colors::colorize( '%G日%n', true ) ), 1 ); + } + + function test_colorized_string_width() { + // Colors enabled. + Colors::enable( true ); + + $x = Colors::colorize( '%Gx%n', true ); + $dw = Colors::colorize( '%G日%n', true ); // Double-width char. + + $this->assertSame( 1, Colors::width( $x ) ); + $this->assertSame( 1, Colors::width( $x, false /*pre_colorized*/ ) ); + $this->assertSame( 1, Colors::width( $x, true /*pre_colorized*/ ) ); + + $this->assertSame( 2, Colors::width( $dw ) ); + $this->assertSame( 2, Colors::width( $dw, false /*pre_colorized*/ ) ); + $this->assertSame( 2, Colors::width( $dw, true /*pre_colorized*/ ) ); + + // Colors disabled. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + $this->assertSame( 12, Colors::width( $x ) ); + $this->assertSame( 12, Colors::width( $x, false /*pre_colorized*/ ) ); + $this->assertSame( 1, Colors::width( $x, true /*pre_colorized*/ ) ); + + $this->assertSame( 13, Colors::width( $dw ) ); + $this->assertSame( 13, Colors::width( $dw, false /*pre_colorized*/ ) ); + $this->assertSame( 2, Colors::width( $dw, true /*pre_colorized*/ ) ); + } + + function test_colorize_string_is_colored() { + $original = '%Gx'; + $colorized = "\033[32;1mx"; + + $this->assertEquals( \cli\Colors::colorize( $original, true ), $colorized ); + } + + function test_colorize_when_colorize_is_forced() { + $original = '%gx%n'; + + $this->assertEquals( \cli\Colors::colorize( $original, false ), 'x' ); + } + + function test_binary_string_is_converted_back_to_original_string() { + $string = 'x'; + $string_with_color = '%b' . $string; + $colorized_string = "\033[34m$string"; + + // Ensure colorization is applied correctly + $this->assertEquals( \cli\Colors::colorize( $string_with_color, true ), $colorized_string ); + + // Ensure that the colorization is reverted + $this->assertEquals( \cli\Colors::decolorize( $colorized_string ), $string ); + } + + function test_string_cache() { + $string = 'x'; + $string_with_color = '%k' . $string; + $colorized_string = "\033[30m$string"; + + // Ensure colorization works + $this->assertEquals( \cli\Colors::colorize( $string_with_color, true ), $colorized_string ); + + // Test that the value was cached appropriately + $test_cache = array( + 'passed' => $string_with_color, + 'colorized' => $colorized_string, + 'decolorized' => $string, + ); + + $real_cache = \cli\Colors::getStringCache(); + + // Test that the cache value exists + $this->assertTrue( isset( $real_cache[ md5( $string_with_color ) ] ) ); + + // Test that the cache value is correctly set + $this->assertEquals( $test_cache, $real_cache[ md5( $string_with_color ) ] ); + } + + function test_string_cache_colorize() { + $string = 'x'; + $string_with_color = '%k' . $string; + $colorized_string = "\033[30m$string"; + + // Colors enabled. + Colors::enable( true ); + + // Ensure colorization works + $this->assertSame( $colorized_string, Colors::colorize( $string_with_color ) ); + $this->assertSame( $colorized_string, Colors::colorize( $string_with_color ) ); + + // Colors disabled. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + // Ensure it doesn't come from the cache. + $this->assertSame( $string, Colors::colorize( $string_with_color ) ); + $this->assertSame( $string, Colors::colorize( $string_with_color ) ); + + // Check that escaped % isn't stripped on putting into cache. + $string = 'x%%n'; + $string_with_color = '%k' . $string; + $this->assertSame( 'x%n', Colors::colorize( $string_with_color ) ); + $this->assertSame( 'x%n', Colors::colorize( $string_with_color ) ); + } + + function test_decolorize() { + // Colors enabled. + Colors::enable( true ); + + $string = '%kx%%n%n'; + $colorized_string = Colors::colorize( $string ); + $both_string = '%gfoo' . $colorized_string . 'bar%%%n'; + + $this->assertSame( 'x%n', Colors::decolorize( $string ) ); + $this->assertSame( 'x', Colors::decolorize( $colorized_string ) ); + $this->assertSame( 'fooxbar%', Colors::decolorize( $both_string ) ); + + $this->assertSame( $string, Colors::decolorize( $string, 1 /*keep_tokens*/ ) ); + $this->assertSame( 'x%n', Colors::decolorize( $colorized_string, 1 /*keep_tokens*/ ) ); + $this->assertSame( '%gfoox%nbar%%%n', Colors::decolorize( $both_string, 1 /*keep_tokens*/ ) ); + + $this->assertSame( 'x%n', Colors::decolorize( $string, 2 /*keep_encodings*/ ) ); + $this->assertSame( 'x', Colors::decolorize( $colorized_string, 2 /*keep_encodings*/ ) ); + $this->assertSame( 'fooxbar%', Colors::decolorize( $both_string, 2 /*keep_encodings*/ ) ); + + $this->assertSame( $string, Colors::decolorize( $string, 3 /*noop*/ ) ); + $this->assertSame( $colorized_string, Colors::decolorize( $colorized_string, 3 /*noop*/ ) ); + $this->assertSame( $both_string, Colors::decolorize( $both_string, 3 /*noop*/ ) ); + } + + function test_strwidth() { + // Save. + $test_strwidth = getenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); + if ( function_exists( 'mb_detect_order' ) ) { + $mb_detect_order = mb_detect_order(); + } + + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); + + // UTF-8. + + // 4 characters, one a double-width Han = 5 spacing chars, with 2 combining chars. Adapted from http://unicode.org/faq/char_combmark.html#7 (combining acute accent added after "a"). + $str = "a\xCC\x81\xE0\xA4\xA8\xE0\xA4\xBF\xE4\xBA\x9C\xF0\x90\x82\x83"; + + if ( \cli\can_use_icu() ) { + $this->assertSame( 5, \cli\strwidth( $str ) ); // Tests grapheme_strlen(). + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=2' ); // Test preg_split( '/\X/u' ). + $this->assertSame( 5, \cli\strwidth( $str ) ); + } else { + $this->assertSame( 5, \cli\strwidth( $str ) ); // Tests preg_split( '/\X/u' ). + } + + if ( function_exists( 'mb_strwidth' ) && function_exists( 'mb_detect_order' ) ) { + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=4' ); // Test mb_strwidth(). + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $this->assertSame( 5, \cli\strwidth( $str ) ); + } + + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=8' ); // Test safe_strlen(). + if ( \cli\can_use_icu() || \cli\can_use_pcre_x() ) { + $this->assertSame( 4, \cli\strwidth( $str ) ); // safe_strlen() (correctly) does not account for double-width Han so out by 1. + } elseif ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { + $this->assertSame( 4, \cli\strwidth( $str ) ); // safe_strlen() (correctly) does not account for double-width Han so out by 1. + $this->assertSame( 6, mb_strlen( $str, 'UTF-8' ) ); + } else { + $this->assertSame( 16, \cli\strwidth( $str ) ); // strlen() - no. of bytes. + $this->assertSame( 16, strlen( $str ) ); + } + + // Nepali जस्ट ट॓स्ट गर्दै - 1st word: 3 spacing + 1 combining, 2nd word: 3 spacing + 2 combining, 3rd word: 3 spacing + 2 combining = 9 spacing chars + 2 spaces = 11 chars. + // Note: ICU's grapheme_strlen() treats Devanagari conjuncts (consonant + virama + consonant) as single graphemes. + // Modern ICU versions (54.1+) return 8 for this string, while PCRE \X returns 11. + $str = "\xe0\xa4\x9c\xe0\xa4\xb8\xe0\xa5\x8d\xe0\xa4\x9f \xe0\xa4\x9f\xe0\xa5\x93\xe0\xa4\xb8\xe0\xa5\x8d\xe0\xa4\x9f \xe0\xa4\x97\xe0\xa4\xb0\xe0\xa5\x8d\xe0\xa4\xa6\xe0\xa5\x88"; + + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); + + if ( \cli\can_use_icu() ) { + $this->assertSame( 8, \cli\strwidth( $str ) ); // Tests grapheme_strlen() - ICU treats conjuncts as single graphemes. + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=2' ); // Test preg_split( '/\X/u' ). + $this->assertSame( 11, \cli\strwidth( $str ) ); + } else { + $this->assertSame( 11, \cli\strwidth( $str ) ); // Tests preg_split( '/\X/u' ). + } + + if ( function_exists( 'mb_strwidth' ) && function_exists( 'mb_detect_order' ) ) { + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=4' ); // Test mb_strwidth(). + mb_detect_order( array( 'UTF-8' ) ); + $this->assertSame( 11, \cli\strwidth( $str ) ); + } + + // Non-UTF-8 - both grapheme_strlen() and preg_split( '/\X/u' ) will fail. + + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); + + if ( function_exists( 'mb_strwidth' ) && function_exists( 'mb_detect_order' ) ) { + // Latin-1 + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $str = "\xe0b\xe7"; // "àbç" in ISO-8859-1 + $this->assertSame( 3, \cli\strwidth( $str ) ); // Test mb_strwidth(). + $this->assertSame( 3, mb_strwidth( $str, 'ISO-8859-1' ) ); + + // Shift JIS. + mb_detect_order( array( 'UTF-8', 'SJIS' ) ); + $str = "\x82\xb1\x82\xf1\x82\xc9\x82\xbf\x82\xcd\x90\xa2\x8a\x45!"; // "こャにちは世界!" ("Hello world!") in Shift JIS - 7 double-width chars plus Latin exclamation mark. + $this->assertSame( 15, \cli\strwidth( $str ) ); // Test mb_strwidth(). + $this->assertSame( 15, mb_strwidth( $str, 'SJIS' ) ); + + putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=8' ); // Test safe_strlen(). + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { + $this->assertSame( 8, \cli\strwidth( $str ) ); // mb_strlen() - doesn't allow for double-width. + $this->assertSame( 8, mb_strlen( $str, 'SJIS' ) ); + } else { + $this->assertSame( 15, \cli\strwidth( $str ) ); // strlen() - no. of bytes. + $this->assertSame( 15, strlen( $str ) ); + } + } + + // Restore. + putenv( false == $test_strwidth ? 'PHP_CLI_TOOLS_TEST_STRWIDTH' : "PHP_CLI_TOOLS_TEST_STRWIDTH=$test_strwidth" ); + if ( function_exists( 'mb_detect_order' ) ) { + mb_detect_order( $mb_detect_order ); + } + } + + function test_safe_strlen() { + // Save. + $test_safe_strlen = getenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); + if ( function_exists( 'mb_detect_order' ) ) { + $mb_detect_order = mb_detect_order(); + } + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); + + // UTF-8. + + // ASCII l, 3-byte kana, ASCII n, ASCII o + 2-byte combining umlaut, 6-byte Thai combining, ASCII, 3-byte Hangul. grapheme length 7, bytes 18. + $str = 'lムnöม้p를'; + + if ( \cli\can_use_icu() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); // Test grapheme_strlen(). + $this->assertSame( 7, \cli\safe_strlen( $str ) ); + if ( \cli\can_use_pcre_x() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN=2' ); // Test preg_split( '/\X/u' ). + $this->assertSame( 7, \cli\safe_strlen( $str ) ); + } + } elseif ( \cli\can_use_pcre_x() ) { + $this->assertSame( 7, \cli\safe_strlen( $str ) ); // Tests preg_split( '/\X/u' ). + } else { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN=8' ); // Test strlen(). + $this->assertSame( 18, \cli\safe_strlen( $str ) ); // strlen() - no. of bytes. + $this->assertSame( 18, strlen( $str ) ); + } + + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN=4' ); // Test mb_strlen(). + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $this->assertSame( 7, \cli\safe_strlen( $str ) ); + $this->assertSame( 9, mb_strlen( $str, 'UTF-8' ) ); // mb_strlen() - counts the 2 combining chars. + } + + // Non-UTF-8 - both grapheme_strlen() and preg_split( '/\X/u' ) will fail. + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); + + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { + // Latin-1 + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $str = "\xe0b\xe7"; // "àbç" in ISO-8859-1 + $this->assertSame( 3, \cli\safe_strlen( $str ) ); // Test mb_strlen(). + $this->assertSame( 3, mb_strlen( $str, 'ISO-8859-1' ) ); + } + + // Restore. + putenv( false == $test_safe_strlen ? 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' : "PHP_CLI_TOOLS_TEST_SAFE_STRLEN=$test_safe_strlen" ); + if ( function_exists( 'mb_detect_order' ) ) { + mb_detect_order( $mb_detect_order ); + } + } + + function test_render_with_color_tokens_and_sprintf_args_colors_disabled() { + Colors::disable( true ); + + // Color tokens in format string must not cause "sprintf(): Too few arguments". + $result = \cli\render( '[%C%k%s%N] Starting!', '2024-01-01 12:00:00' ); + $this->assertSame( '[2024-01-01 12:00:00] Starting!', $result ); + } + + function test_render_with_color_tokens_and_sprintf_args_colors_enabled() { + Colors::enable( true ); + + $result = \cli\render( '[%C%k%s%N] Starting!', '2024-01-01 12:00:00' ); + $this->assertStringContainsString( '2024-01-01 12:00:00', $result ); + $this->assertStringContainsString( 'Starting!', $result ); + } +} diff --git a/tests/Test_Colors.php b/tests/Test_Colors.php new file mode 100644 index 0000000..b7d28ff --- /dev/null +++ b/tests/Test_Colors.php @@ -0,0 +1,33 @@ +assertSame( Colors::colorize( $str ), Colors::color( $color ) ); + if ( in_array( 'reset', $color ) ) { + $this->assertTrue( false !== strpos( $colored, '[0m' ) ); + } else { + $this->assertTrue( false === strpos( $colored, '[0m' ) ); + } + } + + public static function dataColors() { + $ret = array(); + foreach ( Colors::getColors() as $str => $color ) { + $ret[] = array( $str, $color ); + } + return $ret; + } +} diff --git a/tests/Test_Shell.php b/tests/Test_Shell.php new file mode 100644 index 0000000..f793027 --- /dev/null +++ b/tests/Test_Shell.php @@ -0,0 +1,63 @@ +assertSame( 80, $columns ); + + putenv( 'WP_CLI_TEST_IS_WINDOWS=1' ); + $columns = cli\Shell::columns(); + $this->assertSame( 80, $columns ); + + // TERM and COLUMNS should result in whatever COLUMNS is. + + putenv( 'TERM=vt100' ); + putenv( 'COLUMNS=100' ); + + putenv( 'WP_CLI_TEST_IS_WINDOWS=0' ); + $columns = cli\Shell::columns(); + $this->assertSame( 100, $columns ); + + putenv( 'WP_CLI_TEST_IS_WINDOWS=1' ); + $columns = cli\Shell::columns(); + $this->assertSame( 100, $columns ); + + // Restore. + putenv( false === $env_term ? 'TERM' : "TERM=$env_term" ); + putenv( false === $env_columns ? 'COLUMNS' : "COLUMNS=$env_columns" ); + if ( false === $env_is_windows ) { + if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) { + putenv( 'WP_CLI_TEST_IS_WINDOWS=' ); + } else { + putenv( 'WP_CLI_TEST_IS_WINDOWS' ); + } + } else { + putenv( "WP_CLI_TEST_IS_WINDOWS=$env_is_windows" ); + } + putenv( false === $env_shell_columns_reset ? 'PHP_CLI_TOOLS_TEST_SHELL_COLUMNS_RESET' : "PHP_CLI_TOOLS_TEST_SHELL_COLUMNS_RESET=$env_shell_columns_reset" ); + } +} diff --git a/tests/Test_Table.php b/tests/Test_Table.php new file mode 100644 index 0000000..345a756 --- /dev/null +++ b/tests/Test_Table.php @@ -0,0 +1,501 @@ +setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + $table->setHeaders( array( 'Field', 'Value' ) ); + $table->addRow( array( 'description', 'The 2012 theme for WordPress is a fully responsive theme that looks great on any device. Features include a front page template with its own widgets, an optional display font, styling for post formats on both index and single views, and an optional no-sidebar page template. Make it yours with a custom menu, header image, and background.' ) ); + $table->addRow( array( 'author', 'the WordPress team' ) ); + + $out = $table->getDisplayLines(); + $this->assertCount( 12, $out ); + $this->assertEquals( $constraint_width, strlen( $out[0] ) ); + $this->assertEquals( $constraint_width, strlen( $out[1] ) ); + $this->assertEquals( $constraint_width, strlen( $out[2] ) ); + $this->assertEquals( $constraint_width, strlen( $out[3] ) ); + $this->assertEquals( $constraint_width, strlen( $out[4] ) ); + $this->assertEquals( $constraint_width, strlen( $out[5] ) ); + $this->assertEquals( $constraint_width, strlen( $out[6] ) ); + $this->assertEquals( $constraint_width, strlen( $out[7] ) ); + $this->assertEquals( $constraint_width, strlen( $out[8] ) ); + $this->assertEquals( $constraint_width, strlen( $out[9] ) ); + $this->assertEquals( $constraint_width, strlen( $out[10] ) ); + $this->assertEquals( $constraint_width, strlen( $out[11] ) ); + + $constraint_width = 81; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, strlen( $out[ $i ] ) ); + } + } + + public function test_column_value_too_long_with_multibytes() { + + $constraint_width = 80; + + $table = new cli\Table; + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + $table->setHeaders( array( 'Field', 'Value' ) ); + $table->addRow( array( '1この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。2この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。', 'こんにちは' ) ); + $table->addRow( array( 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.', 'Hello' ) ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + + $constraint_width = 81; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + } + + public function test_column_odd_single_width_with_double_width() { + + $dummy = new cli\Table; + $renderer = new cli\Table\Ascii; + + $strip_borders = function ( $a ) { + return array_map( function ( $v ) { + return substr( rtrim( $v, "\r" ), 2, -2 ); + }, $a ); + }; + + $renderer->setWidths( array( 10 ) ); + + // 1 single-width, 6 double-width, 1 single-width, 2 double-width, 1 half-width, 2 double-width. + $out = $renderer->row( array( '1あいうえおか2きくカけこ' ) ); + $result = $strip_borders( explode( "\n", $out ) ); + + $this->assertSame( 3, count( $result ) ); + $this->assertSame( '1あいうえ ', $result[0] ); // 1 single-width, 4 double-width, space = 10. + $this->assertSame( 'おか2きくカ', $result[1] ); // 2 double-width, 1 single-width, 2 double-width, 1 half-width = 10. + $this->assertSame( 'けこ ', $result[2] ); // 2 double-width, 8 spaces = 10. + + // Minimum width 1. + + $renderer->setWidths( array( 1 ) ); + + $out = $renderer->row( array( '1あいうえおか2きくカけこ' ) ); + $result = $strip_borders( explode( "\n", $out ) ); + + $this->assertSame( 13, count( $result ) ); + // Uneven rows. + $this->assertSame( '1', $result[0] ); + $this->assertSame( 'あ', $result[1] ); + + // Zero width does no wrapping. + + $renderer->setWidths( array( 0 ) ); + + $out = $renderer->row( array( '1あいうえおか2きくカけこ' ) ); + $result = $strip_borders( explode( "\n", $out ) ); + + $this->assertSame( 1, count( $result ) ); + } + + public function test_column_fullwidth_and_combining() { + + $constraint_width = 80; + + $table = new cli\Table; + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + $table->setHeaders( array( 'Field', 'Value' ) ); + $table->addRow( array( 'ID', 2151 ) ); + $table->addRow( array( 'post_author', 1 ) ); + $table->addRow( array( 'post_title', 'only-english-lorem-ipsum-dolor-sit-amet-consectetur-adipisicing-elit-sed-do-eiusmod-tempor-incididunt-ut-labore' ) ); + $table->addRow( array( 'post_content', + //'ให้รู้จัก ให้หาหนทางใหม่' . + '♫ มีอีกหลายต่อหลายคน เขาอดทนก็เพื่อรัก' . "\n" . + 'รักผลักดันให้รู้จัก ให้หาหนทางใหม่' . "\r\n" . + 'ฉันจะล้มตั้งหลายที ดีที่รักมาฉุดไว้' . "\r\n" . + 'รักสร้างสรรค์สิ่งมากมาย และหลอมละลายทุกหัวใจ' . "\r\n" . + 'จะมาร้ายดียังไง แต่ใจก็ยังต้องการ' . "\r\n" . + 'ในทุกๆ วัน โลกหมุนด้วยความรัก ♫' . "\n" . + 'ขอแสดงความยินดี งานแต่งพี่ Earn & Menn' ."\r\n" . + 'เที่ยวปายหน้าร้อน ก็เที่ยวได้เหมือนกันน่ะ' . "\r\n" . + ' ジョバンニはまっ赤になってうなずきました。けれどもいつかジョバンニの眼のなかには涙がいっぱいになりました。そうだ僕は知っていたのだ、もちろんカムパネルラも知っている。' ."\r\n" . + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore' . "\n" . + '' + ) ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + + $constraint_width = 81; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + + $constraint_width = 200; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + } + + public function test_ascii_pre_colorized_widths() { + + Colors::enable( true ); + + $headers = array( 'package', 'version', 'result' ); + $items = array( + array( Colors::colorize( '%ygaa/gaa-kabes%n' ), 'dev-master', Colors::colorize( "%rx%n" ) ), + array( Colors::colorize( '%ygaa/gaa-log%n' ), '*', Colors::colorize( "%gok%n" ) ), + array( Colors::colorize( '%ygaa/gaa-nonsense%n' ), 'v3.0.11', Colors::colorize( "%rx%n" ) ), + array( Colors::colorize( '%ygaa/gaa-100%%new%n' ), 'v100%new', Colors::colorize( "%gok%n" ) ), + ); + + // Disable colorization, as `\WP_CLI\Formatter::show_table()` does for Ascii tables. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + // Account for colorization of columns 0 & 2. + + $table = new Table; + $renderer = new Ascii; + $table->setRenderer( $renderer ); + $table->setAsciiPreColorized( array( true, false, true ) ); + $table->setHeaders( $headers ); + $table->setRows( $items ); + + $out = $table->getDisplayLines(); + + // "+ 4" accommodates 3 borders and header. + $this->assertSame( 4 + 4, count( $out ) ); + + // Borders & header. + $this->assertSame( 42, strlen( $out[0] ) ); + $this->assertSame( 42, strlen( $out[1] ) ); + $this->assertSame( 42, strlen( $out[2] ) ); + $this->assertSame( 42, strlen( $out[7] ) ); + + // Data. + $this->assertSame( 60, strlen( $out[3] ) ); + $this->assertSame( 60, strlen( $out[4] ) ); + $this->assertSame( 60, strlen( $out[5] ) ); + $this->assertSame( 60, strlen( $out[6] ) ); + + // Don't account for colorization of columns 0 & 2. + + $table = new Table; + $renderer = new Ascii; + $table->setRenderer( $renderer ); + $table->setHeaders( $headers ); + $table->setRows( $items ); + + $out = $table->getDisplayLines(); + + // "+ 4" accommodates 3 borders and header. + $this->assertSame( 4 + 4, count( $out ) ); + + // Borders & header. + $this->assertSame( 56, strlen( $out[0] ) ); + $this->assertSame( 56, strlen( $out[1] ) ); + $this->assertSame( 56, strlen( $out[2] ) ); + $this->assertSame( 56, strlen( $out[7] ) ); + + // Data. + $this->assertSame( 56, strlen( $out[3] ) ); + $this->assertSame( 56, strlen( $out[4] ) ); + $this->assertSame( 56, strlen( $out[5] ) ); + $this->assertSame( 56, strlen( $out[6] ) ); + } + + public function test_preserve_trailing_tabs() { + $table = new cli\Table(); + $renderer = new cli\Table\Tabular(); + $table->setRenderer( $renderer ); + + $table->setHeaders( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ) ); + + // Add row with missing values at the end + $table->addRow( array( 'date', 'date', 'NO', 'PRI', '', '' ) ); + $table->addRow( array( 'awesome_stuff', 'text', 'YES', '', '', '' ) ); + + $out = $table->getDisplayLines(); + + $expected = [ + "Field\tType\tNull\tKey\tDefault\tExtra", + "date\tdate\tNO\tPRI\t\t", + "awesome_stuff\ttext\tYES\t\t\t", + ]; + + $this->assertSame( $expected, $out, 'Trailing tabs should be preserved in table output.' ); + } + + public function test_null_values_are_handled() { + $table = new cli\Table(); + $renderer = new cli\Table\Tabular(); + $table->setRenderer( $renderer ); + + $table->setHeaders( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ) ); + + // Add row with a null value in the middle + $table->addRow( array( 'id', 'int', 'NO', 'PRI', null, 'auto_increment' ) ); + + // Add row with a null value at the end + $table->addRow( array( 'name', 'varchar(255)', 'YES', '', 'NULL', null ) ); + + $out = $table->getDisplayLines(); + + $expected = [ + "Field\tType\tNull\tKey\tDefault\tExtra", + "id\tint\tNO\tPRI\t\tauto_increment", + "name\tvarchar(255)\tYES\t\tNULL\t", + ]; + $this->assertSame( $expected, $out, 'Null values should be safely converted to empty strings in table output.' ); + } + + public function test_default_alignment() { + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Ascii() ); + $table->setHeaders( array( 'Header1', 'Header2' ) ); + $table->addRow( array( 'Row1Col1', 'Row1Col2' ) ); + + $out = $table->getDisplayLines(); + + // By default, columns should be left-aligned. + $this->assertStringContainsString( '| Header1 | Header2 |', $out[1] ); + $this->assertStringContainsString( '| Row1Col1 | Row1Col2 |', $out[3] ); + } + + public function test_right_alignment() { + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Ascii() ); + $table->setHeaders( array( 'Name', 'Size' ) ); + $table->setAlignments( array( 'Name' => \cli\table\Column::ALIGN_RIGHT, 'Size' => \cli\table\Column::ALIGN_RIGHT ) ); + $table->addRow( array( 'file.txt', '1024 B' ) ); + + $out = $table->getDisplayLines(); + + // Headers should be right-aligned in their columns + $this->assertStringContainsString( '| Name | Size |', $out[1] ); + // Data should be right-aligned + $this->assertStringContainsString( '| file.txt | 1024 B |', $out[3] ); + } + + public function test_center_alignment() { + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Ascii() ); + $table->setHeaders( array( 'A', 'B' ) ); + $table->setAlignments( array( 'A' => \cli\table\Column::ALIGN_CENTER, 'B' => \cli\table\Column::ALIGN_CENTER ) ); + $table->addRow( array( 'test', 'data' ) ); + + $out = $table->getDisplayLines(); + + // Headers should be center-aligned + $this->assertStringContainsString( '| A | B |', $out[1] ); + // Data should be center-aligned + $this->assertStringContainsString( '| test | data |', $out[3] ); + } + + public function test_mixed_alignments() { + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Ascii() ); + $table->setHeaders( array( 'Name', 'Count', 'Status' ) ); + $table->setAlignments( array( + 'Name' => \cli\table\Column::ALIGN_LEFT, + 'Count' => \cli\table\Column::ALIGN_RIGHT, + 'Status' => \cli\table\Column::ALIGN_CENTER, + ) ); + $table->addRow( array( 'Item', '42', 'OK' ) ); + + $out = $table->getDisplayLines(); + + // Headers line should show all three with proper alignment + $this->assertStringContainsString( '| Name | Count | Status |', $out[1] ); + // Data line: Name left, Count right, Status center + $this->assertStringContainsString( '| Item | 42 | OK |', $out[3] ); + } + + public function test_invalid_alignment_value() { + $this->expectException( \InvalidArgumentException::class ); + $table = new cli\Table(); + $table->setHeaders( array( 'Header1' ) ); + $table->setAlignments( array( 'Header1' => 'invalid-alignment' ) ); + } + + public function test_invalid_alignment_column() { + $this->expectException( \InvalidArgumentException::class ); + $table = new cli\Table(); + $table->setHeaders( array( 'Header1' ) ); + $table->setAlignments( array( 'NonExistent' => \cli\table\Column::ALIGN_LEFT ) ); + } + + public function test_alignment_before_headers() { + // Test that alignments can be set before headers without throwing an error + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Ascii() ); + $table->setAlignments( array( 'Name' => \cli\table\Column::ALIGN_RIGHT ) ); + $table->setHeaders( array( 'Name' ) ); + $table->addRow( array( 'LongName' ) ); + + $out = $table->getDisplayLines(); + + // Should be right-aligned - "Name" is 4 chars, "LongName" is 8 chars, so column width is 8 + $this->assertStringContainsString( '| Name |', $out[1] ); + $this->assertStringContainsString( '| LongName |', $out[3] ); + } + + public function test_resetRows() { + $table = new cli\Table(); + $table->setHeaders( array( 'Name', 'Age' ) ); + $table->addRow( array( 'Alice', '30' ) ); + $table->addRow( array( 'Bob', '25' ) ); + + $this->assertEquals( 2, $table->countRows() ); + + $table->resetRows(); + + $this->assertEquals( 0, $table->countRows() ); + + // Headers should still be intact + $out = $table->getDisplayLines(); + $this->assertGreaterThan( 0, count( $out ) ); + } + + public function test_shortcut_constructor_tabular() { + $headers = array( + array( 'Name' => 'Alice', 'Age' => '30' ), + array( 'Name' => 'Bob', 'Age' => '25' ), + ); + + $table = new cli\Table( $headers ); + $table->setRenderer( new cli\Table\Tabular() ); + + $out = $table->getDisplayLines(); + + $expected = [ + "Name\tAge", + "Alice\t30", + "Bob\t25", + ]; + + $this->assertSame( $expected, $out ); + } + + public function test_shortcut_constructor_normalization() { + $headers = array( + array( 'Name' => 'Alice', 'Age' => '30' ), + array( 'Age' => '25', 'Name' => 'Bob' ), // Different order + array( 'Name' => 'Charlie' ), // Missing Age + ); + + $table = new cli\Table( $headers ); + $table->setRenderer( new cli\Table\Tabular() ); + + $out = $table->getDisplayLines(); + + $expected = [ + "Name\tAge", + "Alice\t30", + "Bob\t25", // Order should be normalized to match headers! + "Charlie\t", // Missing value should be empty string + ]; + + $this->assertSame( $expected, $out ); + } + + public function test_displayRow_ascii() { + $mockFile = tempnam( sys_get_temp_dir(), 'temp' ); + $resource = fopen( $mockFile, 'wb' ); + + try { + \cli\Streams::setStream( 'out', $resource ); + + $table = new cli\Table(); + $renderer = new cli\Table\Ascii(); + $table->setRenderer( $renderer ); + $table->setHeaders( array( 'Name', 'Age' ) ); + + // Display a single row + $table->displayRow( array( 'Alice', '30' ) ); + + $output = file_get_contents( $mockFile ); + + // Should contain the row data + $this->assertStringContainsString( 'Alice', $output ); + $this->assertStringContainsString( '30', $output ); + + // Should contain borders + $this->assertStringContainsString( '|', $output ); + $this->assertStringContainsString( '+', $output ); + } finally { + if ( $mockFile && file_exists( $mockFile ) ) { + unlink( $mockFile ); + } + } + } + + public function test_displayRow_tabular() { + $mockFile = tempnam( sys_get_temp_dir(), 'temp' ); + $resource = fopen( $mockFile, 'wb' ); + + try { + \cli\Streams::setStream( 'out', $resource ); + + $table = new cli\Table(); + $renderer = new cli\Table\Tabular(); + $table->setRenderer( $renderer ); + $table->setHeaders( array( 'Name', 'Age' ) ); + + // Display a single row + $table->displayRow( array( 'Alice', '30' ) ); + + $output = file_get_contents( $mockFile ); + + // Should contain the row data with tabs + $this->assertStringContainsString( 'Alice', $output ); + $this->assertStringContainsString( '30', $output ); + } finally { + if ( $mockFile && file_exists( $mockFile ) ) { + unlink( $mockFile ); + } + } + } +} diff --git a/tests/Test_Table_Ascii.php b/tests/Test_Table_Ascii.php new file mode 100644 index 0000000..39cc99f --- /dev/null +++ b/tests/Test_Table_Ascii.php @@ -0,0 +1,394 @@ +_mockFile = tempnam(sys_get_temp_dir(), 'temp'); + $resource = fopen($this->_mockFile, 'wb'); + Streams::setStream('out', $resource); + + $this->_instance = new Table(); + $this->_instance->setRenderer(new Ascii()); + } + + /** + * Cleans temporary file + */ + public function tear_down() { + if (file_exists($this->_mockFile)) { + unlink($this->_mockFile); + } + } + + /** + * Draw simple One column table + */ + public function testDrawOneColumnTable() { + $headers = array('Test Header'); + $rows = array( + array('x'), + ); + $output = <<<'OUT' ++-------------+ +| Test Header | ++-------------+ +| x | ++-------------+ + +OUT; + $this->assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Draw simple One column table with colored string + * Output should look like: + * +-------------+ + * | Test Header | + * +-------------+ + * | x | + * +-------------+ + * + * where `x` character has green color. + * At the same time it checks that `green` defined in `cli\Colors` really looks as `green`. + */ + public function testDrawOneColumnColoredTable() { + Colors::enable( true ); + $headers = array('Test Header'); + $rows = array( + array(Colors::colorize('%Gx%n', true)), + ); + // green `x` + $x = "\x1B\x5B\x33\x32\x3B\x31\x6Dx\x1B\x5B\x30\x6D"; + $output = <<assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Check it works with colors disabled. + */ + public function testDrawOneColumnColorDisabledTable() { + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + $headers = array('Test Header'); + $rows = array( + array('%Gx%n'), + ); + $output = <<assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Test that colorized text wraps correctly while maintaining color codes. + */ + public function testWrappedColorizedText() { + Colors::enable( true ); + $headers = array('Column 1', 'Column 2'); + $green_code = "\x1b\x5b\x33\x32\x3b\x31\x6d"; // Green + bright + $reset_code = "\x1b\x5b\x30\x6d"; // Reset + + // Create a long colorized string that will wrap + $long_text = Colors::colorize('%GThis is a long green text%n', true); + + $rows = array( + array('Short', $long_text), + ); + + // Expected output with wrapped text maintaining colors + // The color codes are preserved across wrapped lines + $output = <<_instance->setHeaders($headers); + $this->_instance->setRows($rows); + $renderer = new Ascii([10, 12]); + $renderer->setConstraintWidth(30); + $this->_instance->setRenderer($renderer); + $this->_instance->setAsciiPreColorized(true); + $this->_instance->display(); + $this->assertOutFileEqualsWith($output); + } + + /** + * Test word-wrapping mode keeps words together. + */ + public function testWordWrappingMode() { + $headers = array('name', 'status'); + $rows = array( + array('all-in-one-wp-migration-multisite-extension', 'inactive'), + ); + + // With word-wrap, the hyphenated words should wrap at hyphens + $output = <<<'OUT' ++----------------------+----------+ +| name | status | ++----------------------+----------+ +| all-in-one-wp- | inactive | +| migration-multisite- | | +| extension | | ++----------------------+----------+ + +OUT; + + $this->_instance->setHeaders($headers); + $this->_instance->setRows($rows); + $renderer = new Ascii([20, 8]); + $renderer->setConstraintWidth(36); + $this->_instance->setRenderer($renderer); + $this->_instance->setWrappingMode('word-wrap'); + $this->_instance->display(); + $this->assertOutFileEqualsWith($output); + } + + /** + * Test truncate mode with ellipsis. + */ + public function testTruncateMode() { + $headers = array('name', 'status'); + $rows = array( + array('all-in-one-wp-migration-multisite-extension', 'inactive'), + array('short', 'active'), + ); + + // With truncate, long names should be truncated with ellipsis + $output = <<<'OUT' ++----------------------+----------+ +| name | status | ++----------------------+----------+ +| all-in-one-wp-mig... | inactive | +| short | active | ++----------------------+----------+ + +OUT; + + $this->_instance->setHeaders($headers); + $this->_instance->setRows($rows); + $renderer = new Ascii([20, 8]); + $renderer->setConstraintWidth(36); + $this->_instance->setRenderer($renderer); + $this->_instance->setWrappingMode('truncate'); + $this->_instance->display(); + $this->assertOutFileEqualsWith($output); + } + + /** + * Test that wrapping mode setter validates input. + */ + public function testWrappingModeValidation() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid wrapping mode 'invalid'"); + + $renderer = new Ascii(); + $renderer->setWrappingMode('invalid'); + } + + /** + * Checks that spacing and borders are handled correctly in table + */ + public function testSpacingInTable() { + $headers = array('A', ' ', 'C', ''); + $rows = array( + array(' ', 'B1', '', 'D1'), + array('A2', '', ' C2', null), + ); + $output = <<<'OUT' ++-------+------+-----+----+ +| A | | C | | ++-------+------+-----+----+ +| | B1 | | D1 | +| A2 | | C2 | | ++-------+------+-----+----+ + +OUT; + $this->assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Test correct table indentation and border positions for multibyte strings + */ + public function testTableWithMultibyteStrings() { + $headers = array('German', 'French', 'Russian', 'Chinese'); + $rows = array( + array('Schätzen', 'Apprécier', 'Оценить', '欣賞'), + ); + $output = <<<'OUT' ++----------+-----------+---------+---------+ +| German | French | Russian | Chinese | ++----------+-----------+---------+---------+ +| Schätzen | Apprécier | Оценить | 欣賞 | ++----------+-----------+---------+---------+ + +OUT; + $this->assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Test that % gets escaped correctly. + */ + public function testTableWithPercentCharacters() { + $headers = array( 'Heading', 'Heading2', 'Heading3' ); + $rows = array( + array( '% at start', 'at end %', 'in % middle' ) + ); + $output = <<<'OUT' ++------------+----------+-------------+ +| Heading | Heading2 | Heading3 | ++------------+----------+-------------+ +| % at start | at end % | in % middle | ++------------+----------+-------------+ + +OUT; + $this->assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Test that a % is appropriately padded in the table + */ + public function testTablePaddingWithPercentCharacters() { + $headers = array( 'ID', 'post_title', 'post_name' ); + $rows = array( + array( + 3, + '10%', + '' + ), + array( + 1, + 'Hello world!', + 'hello-world' + ), + ); + $output = <<<'OUT' ++----+--------------+-------------+ +| ID | post_title | post_name | ++----+--------------+-------------+ +| 3 | 10% | | +| 1 | Hello world! | hello-world | ++----+--------------+-------------+ + +OUT; + $this->assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Draw wide multiplication Table. + * Example with many columns, many rows + */ + public function testDrawMultiplicationTable() { + $maxFactor = 16; + $headers = array_merge(array('x'), range(1, $maxFactor)); + for ($i = 1, $rows = array(); $i <= $maxFactor; ++$i) { + $rows[] = array_merge(array($i), range($i, $i * $maxFactor, $i)); + } + + $output = <<<'OUT' ++----+----+----+----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| x | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ++----+----+----+----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 1 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | +| 2 | 2 | 4 | 6 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | +| 3 | 3 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 33 | 36 | 39 | 42 | 45 | 48 | +| 4 | 4 | 8 | 12 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | +| 5 | 5 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | +| 6 | 6 | 12 | 18 | 24 | 30 | 36 | 42 | 48 | 54 | 60 | 66 | 72 | 78 | 84 | 90 | 96 | +| 7 | 7 | 14 | 21 | 28 | 35 | 42 | 49 | 56 | 63 | 70 | 77 | 84 | 91 | 98 | 105 | 112 | +| 8 | 8 | 16 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 88 | 96 | 104 | 112 | 120 | 128 | +| 9 | 9 | 18 | 27 | 36 | 45 | 54 | 63 | 72 | 81 | 90 | 99 | 108 | 117 | 126 | 135 | 144 | +| 10 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 | 110 | 120 | 130 | 140 | 150 | 160 | +| 11 | 11 | 22 | 33 | 44 | 55 | 66 | 77 | 88 | 99 | 110 | 121 | 132 | 143 | 154 | 165 | 176 | +| 12 | 12 | 24 | 36 | 48 | 60 | 72 | 84 | 96 | 108 | 120 | 132 | 144 | 156 | 168 | 180 | 192 | +| 13 | 13 | 26 | 39 | 52 | 65 | 78 | 91 | 104 | 117 | 130 | 143 | 156 | 169 | 182 | 195 | 208 | +| 14 | 14 | 28 | 42 | 56 | 70 | 84 | 98 | 112 | 126 | 140 | 154 | 168 | 182 | 196 | 210 | 224 | +| 15 | 15 | 30 | 45 | 60 | 75 | 90 | 105 | 120 | 135 | 150 | 165 | 180 | 195 | 210 | 225 | 240 | +| 16 | 16 | 32 | 48 | 64 | 80 | 96 | 112 | 128 | 144 | 160 | 176 | 192 | 208 | 224 | 240 | 256 | ++----+----+----+----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ + +OUT; + $this->assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Draw a table with headers but no data + */ + public function testDrawWithHeadersNoData() { + $headers = array('header 1', 'header 2'); + $rows = array(); + $output = <<<'OUT' ++----------+----------+ +| header 1 | header 2 | ++----------+----------+ + +OUT; + $this->assertInOutEquals(array($headers, $rows), $output); + } + + /** + * Verifies that Input and Output equals, + * Sugar method for fast access from tests + * + * @param array $input First element is header array, second element is rows array + * @param mixed $output Expected output + */ + private function assertInOutEquals(array $input, $output) { + $this->_instance->setHeaders($input[0]); + $this->_instance->setRows($input[1]); + $this->_instance->display(); + $this->assertOutFileEqualsWith($output); + } + + /** + * Checks that contents of input string and temporary file match + * + * @param mixed $expected Expected output + */ + private function assertOutFileEqualsWith($expected) { + $actual = file_get_contents($this->_mockFile); + $actual = str_replace("\r\n", "\n", $actual); + $expected = str_replace("\r\n", "\n", $expected); + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..1770859 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,22 @@ +