diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f648e9..70935d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `list` (aliases `commands`, `enumerate`), listing the available commands. [#1](https://github.com/phalcon/crest/issues/1) - Added the `bootstrap` key to `crest.php`, naming the project front controller so commands that need a running application can boot one: `'bootstrap' => App\Front\AppFront::class`. Requires a `boot()` returning a container. [#1](https://github.com/phalcon/crest/issues/1) - Added `Crest\Console\Input::argumentString()`, `optionString()` and `optionStringOrNull()`, narrowing the common string case so commands do not each repeat a type guard. +- Added `make:command`, generating a crest command for a package that contributes its own. Prints the `extra.crest.commands` block to declare it with, since that is the only way the registry finds a command. [#5](https://github.com/phalcon/crest/issues/5) +- Added `make:middleware`, generating an ADR middleware and printing the router middleware-map entry that activates it. [#5](https://github.com/phalcon/crest/issues/5) +- Added `make:provider`, generating a service provider for `Phalcon\Container` and printing the `registerProviders()` override that calls it. [#5](https://github.com/phalcon/crest/issues/5) +- Added `make:responder`, generating an ADR responder that implements the `Responder` contract directly. [#5](https://github.com/phalcon/crest/issues/5) +- Added `stub:publish`, copying packaged stubs into `resources/stubs//` so a project can edit them. The override chain already worked; nothing made it discoverable. [#5](https://github.com/phalcon/crest/issues/5) +- Added `--stub` to `make:action`, rendering any named stub instead of the `--responder` default. Passing both is rejected rather than silently resolved. [#5](https://github.com/phalcon/crest/issues/5) +- Added `command`, `middleware`, `provider` and `responder` to the default `paths` in `crest.php`, alongside `action`. Each is overridable per project as before. [#5](https://github.com/phalcon/crest/issues/5) +- Added `Crest\Command\ProjectCommand`, the base for commands that read the project being run against. Contributed commands can extend it for `--directory` and `--config` handling instead of resolving those options themselves. [#5](https://github.com/phalcon/crest/issues/5) +- Added `Crest\Generator\ClassName::suffixed()`, which appends an artifact suffix idempotently, so `make:middleware Cors` and `make:middleware CorsMiddleware` both produce `CorsMiddleware`. [#5](https://github.com/phalcon/crest/issues/5) ### Changed @@ -21,10 +30,20 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Renamed `Crest\Adr` to `Crest\ADR`, and `Flavor::Adr`, `Flavor::Cli` and `Flavor::Mvc` to `Flavor::ADR`, `Flavor::CLI` and `Flavor::MVC`, matching `Phalcon\ADR`. Backed values are unchanged. - Renamed `Crest\ADR\CandidateSource` to `ActionResolver` and `PhalconRouterCandidates` to `PhalconRouterResolver`. One path now names exactly one Action, so there are no candidates to choose between. - Dependencies now resolve against the PHP 8.1 floor via `config.platform`, so the lock matches the declared minimum. +- Default `paths` are now per flavor rather than shared. Only `adr` is populated, so a `cli` or `mvc` project is no longer offered directories for artifacts it has no command to generate. [#5](https://github.com/phalcon/crest/issues/5) +- `crest`, `crest list` and `crest --version` now open with a chevron mark before the name and version. Only the color is dropped from piped output and when `NO_COLOR` is set; the glyph stays. [#5](https://github.com/phalcon/crest/issues/5) + +### Fixed + +- `make:middleware`, `make:provider` and `make:responder` no longer generate a class that cannot be parsed when the name given is already the suffix. `make:middleware Middleware` produced `final class Middleware implements Middleware` beside `use ...\Middleware;`. The contract is now imported under an alias. [#5](https://github.com/phalcon/crest/issues/5) +- Generators now fail instead of reporting a file they did not write. A target that could not be created produced two PHP warnings, `Created ` and exit 0; it now reports `could not create ` and exits 1. [#5](https://github.com/phalcon/crest/issues/5) +- `stub:publish` now rejects a name that is a path. `stub:publish ../../elsewhere/thing` resolved and copied a file from outside the package. [#5](https://github.com/phalcon/crest/issues/5) +- `ClassName::suffixed()` now accepts non-Latin class names, matching PHP's own rule for an identifier. [#5](https://github.com/phalcon/crest/issues/5) ### Removed - Removed the shadowed-action warning from `make:action`. One path names exactly one Action, so nothing can be shadowed. +- Removed the `phalcon/cli-options-parser` requirement. Crest never linked against it: the schema-aware definition layer stays in `Crest\Console\Parsing`, since `Cop\Parser` is schema-less by design. [keep_a_changelog]: https://keepachangelog.com/en/1.0.0/ [semantic_versioning]: https://semver.org/spec/v2.0.0.html diff --git a/README.md b/README.md index cb5e970..9e589d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,20 @@ # Phalcon Crest +[![Latest Version][packagist-version-badge]][packagist-version-link] +[![PHP Version][php-version-badge]][packagist-version-link] +[![Total Downloads][packagist-downloads-badge]][packagist-downloads-link] +[![License][license-badge]][license-link] + +[![Crest CI][crest-ci-badge]][crest-ci-link] +[![Quality Gate Status][sonar-quality-badge]][sonar-link] +[![Coverage][sonar-coverage-badge]][sonar-link] +[![PDS Skeleton][pds-skeleton-badge]][pds-skeleton-link] + +[![Discord][discord-badge]][discord-link] +[![Contributors][contributors-badge]][contributors-link] +[![OpenCollective Backers][oc-backers-badge]][oc-backers-link] +[![OpenCollective Sponsors][oc-sponsors-badge]][oc-sponsors-link] + Command line application for Phalcon - generators, introspection and project tooling. ## Requirements @@ -53,6 +68,26 @@ return [ ]; ``` +## Booting the project + +`container:list` and `event:list` report services and listeners, which exist only once the +application has registered them, so those two start your front controller. Name it in +`crest.php`: + +```php +return [ + 'bootstrap' => App\Front\ApiFront::class, +]; +``` + +The class is constructed with the project root and has to declare `boot()`. There is no +base class and no interface - `boot()` is the whole contract, and what it returns has to +implement `Phalcon\Contracts\Container\Service\Collection`, which +`Phalcon\Container\Container` does. See [docs/index.md](docs/index.md) for the rest. + +Every other command - the generators, `about`, `config:show` and `route:list` - reads the +filesystem and keeps working on a project that does not currently run. + ## Custom stubs Copy a stub into `resources/stubs//` in your project and crest uses yours instead @@ -72,3 +107,27 @@ of the C extension. ## License BSD-3-Clause. See [LICENSE](LICENSE). + + +[packagist-version-badge]: https://img.shields.io/packagist/v/phalcon/crest?include_prereleases&style=flat-square&logo=packagist&logoColor=white +[packagist-version-link]: https://packagist.org/packages/phalcon/crest +[packagist-downloads-badge]: https://img.shields.io/packagist/dt/phalcon/crest?style=flat-square&logo=packagist&logoColor=white +[packagist-downloads-link]: https://packagist.org/packages/phalcon/crest/stats +[php-version-badge]: https://img.shields.io/packagist/php-v/phalcon/crest?style=flat-square&logo=php&logoColor=white +[license-badge]: https://img.shields.io/github/license/phalcon/crest?style=flat-square&logo=opensourceinitiative&logoColor=white +[license-link]: https://github.com/phalcon/crest/blob/master/LICENSE +[crest-ci-badge]: https://github.com/phalcon/crest/actions/workflows/main.yml/badge.svg?branch=master +[crest-ci-link]: https://github.com/phalcon/crest/actions/workflows/main.yml +[sonar-quality-badge]: https://sonarcloud.io/api/project_badges/measure?project=phalcon_crest&metric=alert_status +[sonar-coverage-badge]: https://sonarcloud.io/api/project_badges/measure?project=phalcon_crest&metric=coverage +[sonar-link]: https://sonarcloud.io/summary/new_code?id=phalcon_crest +[pds-skeleton-badge]: https://img.shields.io/badge/pds-skeleton-blue.svg?style=flat-square +[pds-skeleton-link]: https://github.com/php-pds/skeleton +[discord-badge]: https://img.shields.io/discord/310910488152375297?label=Discord&logo=discord&style=flat-square +[discord-link]: https://phalcon.io/discord +[contributors-badge]: https://img.shields.io/github/contributors/phalcon/crest?style=flat-square&logo=github&logoColor=white +[contributors-link]: https://github.com/phalcon/crest/graphs/contributors +[oc-backers-badge]: https://img.shields.io/opencollective/backers/phalcon?style=flat-square&logo=opencollective&logoColor=white +[oc-backers-link]: https://opencollective.com/phalcon +[oc-sponsors-badge]: https://img.shields.io/opencollective/sponsors/phalcon?style=flat-square&logo=opencollective&logoColor=white +[oc-sponsors-link]: https://opencollective.com/phalcon diff --git a/composer.json b/composer.json index f3b9337..92e93c0 100644 --- a/composer.json +++ b/composer.json @@ -5,8 +5,7 @@ "license": "BSD-3-Clause", "keywords": ["phalcon", "cli", "console", "generator", "scaffolding"], "require": { - "php": "^8.1", - "phalcon/cli-options-parser": "^2.0" + "php": "^8.1" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", @@ -14,7 +13,7 @@ "pds/composer-script-names": "^1", "pds/skeleton": "^1", "phalcon/phalcon": "v6.0.x-dev", - "phalcon/talon": "^0.8", + "phalcon/talon": "^0.9", "phpstan/phpstan": "^2", "phpunit/phpunit": "^10.5", "squizlabs/php_codesniffer": "^3 || ^4" diff --git a/composer.lock b/composer.lock index fd5f2be..1c6ed37 100644 --- a/composer.lock +++ b/composer.lock @@ -4,89 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "494a89480762e01e1244142655e7d09c", - "packages": [ - { - "name": "phalcon/cli-options-parser", - "version": "v2.0.0", - "source": { - "type": "git", - "url": "https://github.com/phalcon/cli-options-parser.git", - "reference": "ec4d4cd0b7e61046b88957a4028ad01dfa14f4e6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phalcon/cli-options-parser/zipball/ec4d4cd0b7e61046b88957a4028ad01dfa14f4e6", - "reference": "ec4d4cd0b7e61046b88957a4028ad01dfa14f4e6", - "shasum": "" - }, - "require": { - "php": ">=8.0" - }, - "require-dev": { - "pds/skeleton": "^1.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.6", - "squizlabs/php_codesniffer": "^3.7" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1-dev" - } - }, - "autoload": { - "psr-4": { - "Phalcon\\Cop\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Phalcon Team", - "email": "team@phalconphp.com", - "homepage": "https://phalconphp.com/en/team" - }, - { - "name": "Contributors", - "homepage": "https://github.com/phalcon/cli-options-parser/graphs/contributors" - } - ], - "description": "Command line arguments/options parser.", - "homepage": "https://phalconphp.com", - "keywords": [ - "argparse", - "cli", - "command", - "command-line", - "getopt", - "line", - "option", - "optparse", - "parser", - "terminal" - ], - "support": { - "discord": "https://phalcon.io/discord/", - "issues": "https://github.com/phalcon/cli-options-parser/issues", - "source": "https://github.com/phalcon/cli-options-parser" - }, - "funding": [ - { - "url": "https://github.com/phalcon", - "type": "github" - }, - { - "url": "https://opencollective.com/phalcon", - "type": "open_collective" - } - ], - "time": "2023-11-24T16:04:00+00:00" - } - ], + "content-hash": "040aa50e4ee670ccd9bc28c25651ea5b", + "packages": [], "packages-dev": [ { "name": "clue/ndjson-react", @@ -638,16 +557,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.17", + "version": "v3.95.18", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b" + "reference": "a8b4e4216faabf67f4e96110ee99a48c96e4e683" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/0ee88422118f3cc59c8c3def222ba7f1493b6d5b", - "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a8b4e4216faabf67f4e96110ee99a48c96e4e683", + "reference": "a8b4e4216faabf67f4e96110ee99a48c96e4e683", "shasum": "" }, "require": { @@ -731,7 +650,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.17" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.18" }, "funding": [ { @@ -739,7 +658,7 @@ "type": "github" } ], - "time": "2026-07-24T13:54:39+00:00" + "time": "2026-07-30T15:46:02+00:00" }, { "name": "infection/abstract-testframework-adapter", @@ -1612,18 +1531,98 @@ }, "time": "2017-01-25T23:30:41+00:00" }, + { + "name": "phalcon/cli-options-parser", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phalcon/cli-options-parser.git", + "reference": "ec4d4cd0b7e61046b88957a4028ad01dfa14f4e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phalcon/cli-options-parser/zipball/ec4d4cd0b7e61046b88957a4028ad01dfa14f4e6", + "reference": "ec4d4cd0b7e61046b88957a4028ad01dfa14f4e6", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "pds/skeleton": "^1.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Phalcon\\Cop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Phalcon Team", + "email": "team@phalconphp.com", + "homepage": "https://phalconphp.com/en/team" + }, + { + "name": "Contributors", + "homepage": "https://github.com/phalcon/cli-options-parser/graphs/contributors" + } + ], + "description": "Command line arguments/options parser.", + "homepage": "https://phalconphp.com", + "keywords": [ + "argparse", + "cli", + "command", + "command-line", + "getopt", + "line", + "option", + "optparse", + "parser", + "terminal" + ], + "support": { + "discord": "https://phalcon.io/discord/", + "issues": "https://github.com/phalcon/cli-options-parser/issues", + "source": "https://github.com/phalcon/cli-options-parser" + }, + "funding": [ + { + "url": "https://github.com/phalcon", + "type": "github" + }, + { + "url": "https://opencollective.com/phalcon", + "type": "open_collective" + } + ], + "time": "2023-11-24T16:04:00+00:00" + }, { "name": "phalcon/phalcon", "version": "v6.0.x-dev", "source": { "type": "git", "url": "https://github.com/phalcon/phalcon.git", - "reference": "7e87395f5e1d100fef4976ef9d0603370c6bbc57" + "reference": "3a3401563a6fd735aa88c225c51d2395ca29d3bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phalcon/phalcon/zipball/7e87395f5e1d100fef4976ef9d0603370c6bbc57", - "reference": "7e87395f5e1d100fef4976ef9d0603370c6bbc57", + "url": "https://api.github.com/repos/phalcon/phalcon/zipball/3a3401563a6fd735aa88c225c51d2395ca29d3bc", + "reference": "3a3401563a6fd735aa88c225c51d2395ca29d3bc", "shasum": "" }, "require": { @@ -1694,20 +1693,20 @@ "type": "open_collective" } ], - "time": "2026-07-29T00:19:31+00:00" + "time": "2026-07-30T15:10:17+00:00" }, { "name": "phalcon/talon", - "version": "v0.8.0", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/phalcon/talon.git", - "reference": "b8557e7056395df23ed2634d284b6b54362c4c25" + "reference": "ca1a825a4b3167802f0987d41122657dc817f259" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phalcon/talon/zipball/b8557e7056395df23ed2634d284b6b54362c4c25", - "reference": "b8557e7056395df23ed2634d284b6b54362c4c25", + "url": "https://api.github.com/repos/phalcon/talon/zipball/ca1a825a4b3167802f0987d41122657dc817f259", + "reference": "ca1a825a4b3167802f0987d41122657dc817f259", "shasum": "" }, "require": { @@ -1724,8 +1723,9 @@ "friendsofphp/php-cs-fixer": "^3", "pds/composer-script-names": "^1", "pds/skeleton": "^1", - "phalcon/phalcon": "^6.0@alpha", + "phalcon/phalcon": "^6.0@alpha || ^6.0 || ^6.0", "phpstan/phpstan": "^2", + "phpunit/phpcov": "^9", "phpunit/phpunit": "^10.5", "predis/predis": "^2 || ^3", "squizlabs/php_codesniffer": "^3 || ^4" @@ -1759,7 +1759,7 @@ ], "support": { "issues": "https://github.com/phalcon/talon/issues", - "source": "https://github.com/phalcon/talon/tree/v0.8.0" + "source": "https://github.com/phalcon/talon/tree/v0.9.0" }, "funding": [ { @@ -1771,7 +1771,7 @@ "type": "open_collective" } ], - "time": "2026-07-15T19:50:31+00:00" + "time": "2026-07-30T19:51:18+00:00" }, { "name": "phalcon/traits", @@ -1975,11 +1975,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.6", + "version": "2.2.7", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", - "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921", + "reference": "692db47b9dddb0487934e5236e77d48594aef921", "shasum": "" }, "require": { @@ -2035,7 +2035,7 @@ "type": "github" } ], - "time": "2026-07-26T21:22:49+00:00" + "time": "2026-07-29T17:39:32+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4365,16 +4365,16 @@ }, { "name": "symfony/console", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9ef84af84a7b66396da483634227650506428639" + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9ef84af84a7b66396da483634227650506428639", - "reference": "9ef84af84a7b66396da483634227650506428639", + "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d", + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d", "shasum": "" }, "require": { @@ -4439,7 +4439,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.42" + "source": "https://github.com/symfony/console/tree/v6.4.43" }, "funding": [ { @@ -4459,7 +4459,7 @@ "type": "tidelift" } ], - "time": "2026-06-15T05:35:29+00:00" + "time": "2026-07-26T14:44:19+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4605,16 +4605,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v6.4.37", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1" + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/2e3bf817ba9347341ab15926700fb6320367c0e1", - "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf", + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf", "shasum": "" }, "require": { @@ -4665,7 +4665,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.37" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43" }, "funding": [ { @@ -4685,7 +4685,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T14:11:12+00:00" + "time": "2026-07-21T14:00:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4769,16 +4769,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.39", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca" + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c507b077756b4e3e09adbbe7975fac81cd3722ca", - "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", "shasum": "" }, "require": { @@ -4815,7 +4815,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.39" + "source": "https://github.com/symfony/filesystem/tree/v6.4.43" }, "funding": [ { @@ -4835,7 +4835,7 @@ "type": "tidelift" } ], - "time": "2026-05-07T13:11:42+00:00" + "time": "2026-06-27T10:13:35+00:00" }, { "name": "symfony/finder", @@ -4907,16 +4907,16 @@ }, { "name": "symfony/http-client", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99" + "reference": "38540911e9e3c3ca12e562dcce758d9bfcfa7cdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/b71b312ca0f211fbb19a20a51bde50fbefb66a99", - "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99", + "url": "https://api.github.com/repos/symfony/http-client/zipball/38540911e9e3c3ca12e562dcce758d9bfcfa7cdd", + "reference": "38540911e9e3c3ca12e562dcce758d9bfcfa7cdd", "shasum": "" }, "require": { @@ -4981,7 +4981,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.42" + "source": "https://github.com/symfony/http-client/tree/v6.4.43" }, "funding": [ { @@ -5001,7 +5001,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T09:42:32+00:00" + "time": "2026-07-29T06:36:08+00:00" }, { "name": "symfony/http-client-contracts", @@ -5087,16 +5087,16 @@ }, { "name": "symfony/mime", - "version": "v6.4.41", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e" + "reference": "38421e10911725aaa5e44e1b4606d7a37f652b37" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/5575d37f8841e4e31d5df79ab3db078ae557ff8e", - "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e", + "url": "https://api.github.com/repos/symfony/mime/zipball/38421e10911725aaa5e44e1b4606d7a37f652b37", + "reference": "38421e10911725aaa5e44e1b4606d7a37f652b37", "shasum": "" }, "require": { @@ -5152,7 +5152,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.41" + "source": "https://github.com/symfony/mime/tree/v6.4.43" }, "funding": [ { @@ -5172,7 +5172,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T14:40:34+00:00" + "time": "2026-07-29T07:59:11+00:00" }, { "name": "symfony/options-resolver", @@ -5330,16 +5330,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -5388,7 +5388,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -5408,7 +5408,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -5833,16 +5833,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -5889,7 +5889,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -5909,7 +5909,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php84", @@ -6211,16 +6211,16 @@ }, { "name": "symfony/string", - "version": "v6.4.39", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "62e3c927de664edadb5bef260987eb047a17a113" + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/62e3c927de664edadb5bef260987eb047a17a113", - "reference": "62e3c927de664edadb5bef260987eb047a17a113", + "url": "https://api.github.com/repos/symfony/string/zipball/2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", "shasum": "" }, "require": { @@ -6276,7 +6276,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.39" + "source": "https://github.com/symfony/string/tree/v6.4.43" }, "funding": [ { @@ -6296,7 +6296,7 @@ "type": "tidelift" } ], - "time": "2026-05-12T11:44:19+00:00" + "time": "2026-07-28T07:28:15+00:00" }, { "name": "thecodingmachine/safe", diff --git a/docs/index.md b/docs/index.md index 14e7d20..6610a97 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,9 +4,102 @@ Command line application for Phalcon. See the [README](../README.md) for installation, usage and configuration. -## Slice 1 commands +## Commands + +Aliases are shown in brackets. Run `crest` with no arguments, or `crest list`, +for the same listing from the tool itself. | Command | Description | |---|---| | `about` (`info`, `i`) | environment and version report | +| `config:show` | the project configuration crest resolved, and where each value came from | +| `container:list` | services registered in the project container | +| `event:list` | listeners attached to the project events manager | +| `list` (`commands`, `enumerate`) | the available commands | | `make:action` | create an ADR action for a route | +| `make:command` | create a crest command | +| `make:middleware` | create an ADR middleware | +| `make:provider` | create a service provider | +| `make:responder` | create an ADR responder | +| `route:list` | every route the application answers | +| `stub:publish` | copy packaged stubs into the project for editing | + +Only the `adr` flavor has generators. A `cli` or `mvc` project can still run +`about`, `config:show` and `list`. + +## Commands that boot the project + +`container:list` and `event:list` read state that exists only once the +application has registered it, so they start your front controller. Name it in +`crest.php`: + +```php +return [ + 'bootstrap' => App\Front\ApiFront::class, +]; +``` + +The class is constructed with the project root - take it if you need it - and +has to declare `boot()`. There is no base class and no interface; `boot()` is +the whole contract: + +```php +use Phalcon\Container\Container; + +final class ApiFront +{ + public function __construct(private readonly string $root) + { + } + + public function boot(): Container + { + $container = new Container(); + + // register your services + + return $container; + } +} +``` + +`boot()` must return an object, and what that object has to satisfy depends on +the command: + +| Command | Requires the returned object to | +|---|---| +| `container:list` | implement `Phalcon\Contracts\Container\Service\Collection` and `Enumerable` | +| `event:list` | implement `Collection`, and hold a registered `Phalcon\Events\Manager` | + +`Phalcon\Container\Container` implements both contracts, so returning one is +enough for `container:list`. + +For `event:list`, "registered" means a definition or an existing instance. A +manager the container would merely autowire does not count - crest reports that +the bootstrap registers none, rather than listing zero listeners off a fresh +instance it created itself. + +Everything else - the generators, `about`, `config:show` and `route:list` - +reads the filesystem and keeps working on a project that does not currently run. + +## Generating actions + +`make:action` takes an HTTP method and a route path: + + vendor/bin/crest make:action GET /company/all + vendor/bin/crest make:action GET /company/{id} + +The class name comes from the framework's routing convention, so the file lands +where the router will look for it. Placeholders must come last: `/album/{id}/edit` +is rejected, with `/album/edit/{id}` suggested instead. + +| Option | Purpose | +|---|---| +| `--responder=json\|view` | which packaged shape to render; defaults to `json` | +| `--stub=` | render a named stub instead; cannot be combined with `--responder` | +| `--template=` | template the view responder renders; defaults to `/index` | +| `--force` | overwrite an existing action | + +A view action names a template but does not create one. `Renderer::render()` +takes a name rather than a path, so the directory and the extension belong to +your renderer, and crest prints the name instead of guessing at a file. diff --git a/resources/infection.json5 b/resources/infection.json5 index d1b1b15..804fc15 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -31,6 +31,9 @@ // cannot return false on any path reaching the cast. "Crest\\Generator\\Stub::render", "Crest\\Project\\Config::psr4Map", + // Same guarantee from further off: sources() only ever yields + // paths it has confirmed with is_file(), or glob() results. + "Crest\\Command\\Stub\\PublishCommand::handle", // getcwd() returns false only when the working directory is // unreadable, which the suite cannot produce. "Crest\\Project\\Config::discover", @@ -87,8 +90,9 @@ // whole story - so any value behaves identically. "Crest\\Project\\Bootstrap::container", // mkdir() mode bits are masked by umask, so the created - // directory is identical either way. - "Crest\\Command\\Make\\ActionCommand::handle", + // directory is identical either way. One entry, not one per + // generator: every command writes through ArtifactWriter. + "Crest\\Generator\\ArtifactWriter::write", // The max() seed only has to be <= the shortest cell; any // non-positive seed gives the same width. "Crest\\Console\\Output::table" @@ -96,9 +100,6 @@ }, "FalseValue": { "ignore": [ - // A flag's declared default is dead: resolveOptions() supplies - // false for OptionMode::None without consulting it. - "Crest\\Command\\Make\\ActionCommand::define", // class_exists()'s second argument only decides whether an // autoloader runs. Crest does not autoload the target // project's classes, so both values answer the same; false is @@ -122,7 +123,7 @@ "IncrementInteger": { "ignore": [ "Crest\\Project\\Bootstrap::container", - "Crest\\Command\\Make\\ActionCommand::handle", + "Crest\\Generator\\ArtifactWriter::write", // Widening the slice by one includes the '--' token itself, // which is never '--trace', '--help' or '-h'. "Crest\\Console\\Kernel::beforeLiteral" diff --git a/resources/php-cs-fixer.php b/resources/php-cs-fixer.php index fa68b27..2525059 100644 --- a/resources/php-cs-fixer.php +++ b/resources/php-cs-fixer.php @@ -11,20 +11,70 @@ declare(strict_types=1); -$finder = PhpCsFixer\Finder::create() - ->in([__DIR__ . '/../src', __DIR__ . '/../tests']) - ->name('*.php'); +/** + * Ordering rules: + * - use statements: alphabetical, class then function then const + * - class members: by visibility (public -> protected -> private), then + * alphabetical within each group + * + * Run from the project root: + * composer cs-fixer (dry-run, shows diff) + * composer cs-fixer-fix (applies the changes) + */ + +use PhpCsFixer\Config; +use PhpCsFixer\Finder; +use PhpCsFixer\Runner\Parallel\ParallelConfigFactory; + +$root = dirname(__DIR__); + +$finder = Finder::create() + ->in( + [ + $root . '/src', + $root . '/tests', + ] + ); -return (new PhpCsFixer\Config()) - ->setRiskyAllowed(true) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'binary_operator_spaces' => ['default' => 'align_single_space_minimal'], - 'declare_strict_types' => true, - 'no_unused_imports' => true, - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'single_quote' => true, - 'trailing_comma_in_multiline' => true, - ]) +return (new Config()) + ->setParallelConfig(ParallelConfigFactory::detect()) + ->setRiskyAllowed(false) + ->setUsingCache(true) + ->setCacheFile($root . '/tests/_output/.php-cs-fixer.cache') + ->setRules( + [ + '@PSR12' => true, + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'ordered_class_elements' => [ + 'sort_algorithm' => 'alpha', + 'order' => [ + 'use_trait', + 'case', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public_static', + 'property_protected_static', + 'property_private_static', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public_static', + 'method_protected_static', + 'method_private_static', + 'method_public', + 'method_protected', + 'method_private', + ], + ], + ] + ) ->setFinder($finder); diff --git a/resources/stubs/adr/command.stub b/resources/stubs/adr/command.stub new file mode 100644 index 0000000..25b3fd3 --- /dev/null +++ b/resources/stubs/adr/command.stub @@ -0,0 +1,25 @@ +success('{{ command }} ran'); + + return 0; + } +} diff --git a/resources/stubs/adr/middleware.stub b/resources/stubs/adr/middleware.stub new file mode 100644 index 0000000..65b8068 --- /dev/null +++ b/resources/stubs/adr/middleware.stub @@ -0,0 +1,18 @@ +set(Thing::class, Thing::class); + // $services->bind(ThingInterface::class, Thing::class); + // $services->setAlias(ThingInterface::class, 'thing'); + } +} diff --git a/resources/stubs/adr/responder.stub b/resources/stubs/adr/responder.stub new file mode 100644 index 0000000..545370e --- /dev/null +++ b/resources/stubs/adr/responder.stub @@ -0,0 +1,23 @@ +setJsonContent($payload->getResult()); + + return $response; + } +} diff --git a/src/ADR/ActionResolver.php b/src/ADR/ActionResolver.php index 7fb2be2..d0f91e8 100644 --- a/src/ADR/ActionResolver.php +++ b/src/ADR/ActionResolver.php @@ -30,6 +30,17 @@ interface ActionResolver */ public function classFor(string $baseNamespace, string $method, string $path): string; + /** + * The HTTP method the given Action class answers, uppercased, or null when + * the class is not one this convention would have produced. + * + * The counterpart to pathFor(). Crest asks rather than deriving it, because + * the verb's position in a class name is part of the framework's naming + * rule - reconstructing it here would be a second copy of half the + * convention, in a tool nobody would think to grep when the rule changes. + */ + public function methodFor(string $baseNamespace, string $class): ?string; + /** * The path the given Action class answers, or null when the class is not * one this convention would have produced. diff --git a/src/ADR/PhalconRouterResolver.php b/src/ADR/PhalconRouterResolver.php index 120b004..6d4dcbf 100644 --- a/src/ADR/PhalconRouterResolver.php +++ b/src/ADR/PhalconRouterResolver.php @@ -39,6 +39,11 @@ public function classFor(string $baseNamespace, string $method, string $path): s return $this->router($baseNamespace)->classFor($method, $path); } + public function methodFor(string $baseNamespace, string $class): ?string + { + return $this->router($baseNamespace)->methodFor($class); + } + public function pathFor(string $baseNamespace, string $class): ?string { return $this->router($baseNamespace)->pathFor($class); diff --git a/src/Command/Config/ShowCommand.php b/src/Command/Config/ShowCommand.php index 2376f73..d7a6109 100644 --- a/src/Command/Config/ShowCommand.php +++ b/src/Command/Config/ShowCommand.php @@ -13,7 +13,7 @@ namespace Crest\Command\Config; -use Crest\Console\Command\Command; +use Crest\Command\ProjectCommand; use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; @@ -29,7 +29,7 @@ * from would answer the easy half of the question: the useful part is knowing * which of them the project actually asked for. */ -final class ShowCommand extends Command +final class ShowCommand extends ProjectCommand { private const DECLARED = 'declared'; private const INFERRED = 'inferred'; @@ -41,11 +41,7 @@ public function define(): Definition public function handle(Input $input, Output $output): int { - $config = Config::discover( - $input->optionStringOrNull('directory'), - $input->optionStringOrNull('config') - ); - + $config = $this->config($input); $source = $config->source(); $output->line('Source: ' . ($source ?? 'inferred from composer.json')); diff --git a/src/Command/Container/ListCommand.php b/src/Command/Container/ListCommand.php index 3f9bf54..99eb6a0 100644 --- a/src/Command/Container/ListCommand.php +++ b/src/Command/Container/ListCommand.php @@ -13,14 +13,14 @@ namespace Crest\Command\Container; -use Crest\Console\Command\Command; +use Crest\Command\ProjectCommand; use Crest\Console\Exceptions\Exception; use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; use Crest\Project\Bootstrap; -use Crest\Project\Config; -use Phalcon\Container\Container; +use Phalcon\Contracts\Container\Service\Collection; +use Phalcon\Contracts\Container\Service\Enumerable; use function get_class; use function sort; @@ -31,7 +31,7 @@ * Names come from the container; everything else is looked up per name, which * is all the container exposes and all this needs. */ -final class ListCommand extends Command +final class ListCommand extends ProjectCommand { public function define(): Definition { @@ -40,12 +40,7 @@ public function define(): Definition public function handle(Input $input, Output $output): int { - $config = Config::discover( - $input->optionStringOrNull('directory'), - $input->optionStringOrNull('config') - ); - - $container = $this->container(Bootstrap::container($config)); + $container = $this->container(Bootstrap::container($this->config($input))); $names = $container->getServiceNames(); sort($names); @@ -73,7 +68,7 @@ public function handle(Input $input, Output $output): int /** * What the service builds, when the definition names a class. */ - private function concrete(Container $container, string $name): string + private function concrete(Collection $container, string $name): string { $definition = $container->getDefinition($name); @@ -83,14 +78,23 @@ private function concrete(Container $container, string $name): string } /** - * @throws Exception when the bootstrap returned something that is not a container + * @throws Exception when the bootstrap returned something that is not a + * container, or one that cannot report what it holds */ - private function container(object $container): Container + private function container(object $container): Collection&Enumerable { - if (false === $container instanceof Container) { + if (false === $container instanceof Collection) { throw new Exception(get_class($container) . ' is not a Phalcon container'); } + // Two contracts because the command needs both halves: Collection for + // the per-name lookups, Enumerable for the names themselves. Enumeration + // is an optional capability, so a container can satisfy Collection and + // still be unable to say what it holds. + if (false === $container instanceof Enumerable) { + throw new Exception(get_class($container) . ' cannot list its services'); + } + return $container; } } diff --git a/src/Command/Event/ListCommand.php b/src/Command/Event/ListCommand.php index 2494a77..2ab5a4d 100644 --- a/src/Command/Event/ListCommand.php +++ b/src/Command/Event/ListCommand.php @@ -13,30 +13,29 @@ namespace Crest\Command\Event; -use Crest\Console\Command\Command; +use Crest\Command\ProjectCommand; use Crest\Console\Exceptions\Exception; use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; use Crest\Project\Bootstrap; -use Crest\Project\Config; -use Phalcon\Container\Container; +use Phalcon\Contracts\Container\Service\Collection; +use Phalcon\Contracts\Events\Enumerable; use Phalcon\Events\Manager; use function get_class; use function is_object; -use function method_exists; -use function sort; +use function ksort; /** * The listeners attached to the project's events manager. * * Event types are mixed granularity by design: a listener may be attached to a * whole component (`dispatch`) or to one event (`dispatch:beforeDispatch`), and - * both are real. Normalising them would hide the difference between listening + * both are real. Normalizing them would hide the difference between listening * to everything a component fires and listening to one moment. */ -final class ListCommand extends Command +final class ListCommand extends ProjectCommand { public function define(): Definition { @@ -45,26 +44,23 @@ public function define(): Definition public function handle(Input $input, Output $output): int { - $config = Config::discover( - $input->optionStringOrNull('directory'), - $input->optionStringOrNull('config') - ); + $manager = $this->manager(Bootstrap::container($this->config($input))); + $listeners = $manager->getListenerMap(); - $manager = $this->manager(Bootstrap::container($config)); - - /** @var list $types */ - $types = $manager->getEventTypes(); - sort($types); - - if ([] === $types) { + if ([] === $listeners) { $output->line('no listeners attached'); return 0; } + // One call answers both halves of the listing, so the types arrive in + // attach order rather than sorted - which is not the order anyone wants + // to read listeners in. + ksort($listeners); + $rows = []; - foreach ($types as $type) { - foreach ($manager->getListeners($type) as $listener) { + foreach ($listeners as $type => $attached) { + foreach ($attached as $listener) { $rows[] = [$type, $this->describe($listener)]; } } @@ -91,15 +87,10 @@ private function describe(mixed $listener): string * Whether the project registered this service, as opposed to the container * being willing to autowire it on demand. */ - private function isRegistered(object $container, string $name): bool + private function isRegistered(Collection $container, string $name): bool { - foreach (['hasDefinition', 'hasInstance'] as $method) { - if (true === method_exists($container, $method) && true === $container->$method($name)) { - return true; - } - } - - return false; + return true === $container->hasDefinition($name) + || true === $container->hasInstance($name); } /** @@ -108,9 +99,9 @@ private function isRegistered(object $container, string $name): bool * Asked for by name rather than pulled from a known key, because a project * may register it under either and the container answers both the same way. */ - private function manager(object $container): Manager + private function manager(object $container): Enumerable { - if (false === $container instanceof Container) { + if (false === $container instanceof Collection) { throw new Exception( get_class($container) . ' is not a Phalcon container' ); @@ -131,7 +122,10 @@ private function manager(object $container): Manager $manager = $container->get(Manager::class); - if (false === $manager instanceof Manager) { + // Enumerable rather than Manager: reporting listeners is the only thing + // this command asks of it, and that capability is what the contract + // publishes. + if (false === $manager instanceof Enumerable) { throw new Exception(Manager::class . ' resolved to something else'); } diff --git a/src/Command/ListCommand.php b/src/Command/ListCommand.php index d77165d..460dcf9 100644 --- a/src/Command/ListCommand.php +++ b/src/Command/ListCommand.php @@ -20,8 +20,6 @@ use Crest\Console\PackageVersion; use Crest\Console\Parsing\Definition; -use function ksort; - /** * Every command the tool can run, one row each. * @@ -40,17 +38,10 @@ public function define(): Definition public function handle(Input $input, Output $output): int { - $commands = Commands::registry()->all(); - ksort($commands); - - $rows = []; - foreach ($commands as $name => $class) { - $rows[] = [$name, (new $class())->define()->getDescription()]; - } - - $output->line(Commands::NAME . ' ' . PackageVersion::of(Commands::PACKAGE)); - $output->line(); - $output->table(['COMMAND', 'DESCRIPTION'], $rows); + $output->commandTable( + Commands::NAME . ' ' . PackageVersion::of(Commands::PACKAGE), + Commands::registry()->descriptions() + ); return 0; } diff --git a/src/Command/Make/ActionCommand.php b/src/Command/Make/ActionCommand.php index f2b6c75..7c87b5e 100644 --- a/src/Command/Make/ActionCommand.php +++ b/src/Command/Make/ActionCommand.php @@ -13,25 +13,17 @@ namespace Crest\Command\Make; +use Crest\ADR\ActionResolver; use Crest\ADR\Convention; use Crest\ADR\PhalconRouterResolver; use Crest\ADR\Target; -use Crest\Console\Command\Command; +use Crest\Command\ProjectCommand; use Crest\Console\Exceptions\Exception; use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; -use Crest\Generator\Stub; -use Crest\Paths; -use Crest\Project\Config; - -use function class_exists; -use function dirname; -use function file_exists; -use function file_put_contents; + use function implode; -use function is_dir; -use function mkdir; use function sprintf; use function str_replace; use function strtolower; @@ -41,26 +33,49 @@ * Generates an ADR Action and places it where the convention router will find * it. Boots nothing - it reads config and writes a file, which is why it keeps * working on a project that does not currently run. + * + * `--responder` picks between the two packaged shapes; `--stub` names any stub + * instead, resolved through the same two-level chain, so a project that has run + * stub:publish can generate from its own edited copy. An empty `--stub=` counts + * as absent, which is how optionString() reads every other option. */ -final class ActionCommand extends Command +final class ActionCommand extends ProjectCommand { private const RESPONDERS = ['json' => 'action', 'view' => 'action-view']; + private readonly ActionResolver $resolver; + + /** + * Defaulted, so the kernel's `new $class()` still works and nothing outside + * has to know which resolver this command wants. + * + * Injectable for the same reason route:list is: the class name a route + * produces is the framework's rule, and a test can only prove crest asked + * for it - rather than derived it - by watching what it does with an answer + * no local rule would give. + */ + public function __construct(?ActionResolver $resolver = null) + { + $this->resolver = $resolver ?? new PhalconRouterResolver(); + } + public function define(): Definition { return Definition::for('make:action', 'Create an ADR action for a route') ->argument('method', true, 'HTTP method, e.g. GET') ->argument('path', true, 'Route path, e.g. /company/{id}') ->option('responder=s', 'Responder style: json or view', 'json') - ->option('force', 'Overwrite an existing action', false); + ->option('stub=s', 'Render a named stub instead of the responder default') + ->option('template=s', 'Template the view responder renders; defaults to /index') + // No declared default: resolveOptions() supplies false for a flag + // without consulting one, so passing it would state something that + // is never read. + ->option('force', 'Overwrite an existing action'); } public function handle(Input $input, Output $output): int { - $config = Config::discover( - $input->optionStringOrNull('directory'), - $input->optionStringOrNull('config') - ); + $config = $this->config($input); $responder = strtolower($input->optionString('responder')); @@ -70,45 +85,59 @@ public function handle(Input $input, Output $output): int ); } - $convention = new Convention( - $config->namespaceFor('action'), - new PhalconRouterResolver() - ); - $target = $convention->target( + $named = $input->optionString('stub'); + + // Both choose a stub. Honoring one and dropping the other silently is + // how someone spends an afternoon wondering why their stub is ignored. + if ('' !== $named && true === $input->hasOption('responder')) { + throw new Exception( + '--stub and --responder both name a stub to render; pass one or the other' + ); + } + + $convention = new Convention($config->namespaceFor('action'), $this->resolver); + $target = $convention->target( $input->argumentString('method'), $input->argumentString('path') ); $file = $config->path('action') . '/' . $target->relativePath; - if (true === file_exists($file) && true !== $input->option('force')) { - throw new Exception(sprintf('%s already exists; pass --force to overwrite', $file)); - } - - $stub = new Stub(Paths::stubs(), $config->root()); + $writer = $this->writer($config); + $template = $this->template($target, $input->optionString('template')); - $contents = $stub->render( - $config->flavor()->value, - self::RESPONDERS[$responder], + $writer->render( + $file, + '' !== $named ? $named : self::RESPONDERS[$responder], [ 'namespace' => $target->namespace, 'class' => $target->class, 'attributes' => $this->attributeBlock($target), 'params' => $this->paramsBlock($target), - 'template' => $this->template($target), - ] + 'template' => $template, + ], + true === $input->option('force') ); - $directory = dirname($file); - if (false === is_dir($directory)) { - mkdir($directory, 0o775, true); - } - - file_put_contents($file, $contents); - $output->success(sprintf('Created %s', $file)); $output->line(sprintf('Answers %s %s', $target->method, $target->path)); + // Only for the packaged view stub. A --stub the project supplied may or + // may not render a template, and crest does not know which, so it says + // nothing rather than guessing. + if ('view' === $responder) { + $output->line(); + $output->line('Nothing renders it yet. The responder asks for this template:'); + $output->line(); + $output->line(' ' . $template); + $output->line(); + $output->line( + 'Create it wherever your renderer looks. Renderer::render() takes a ' + . 'name, not a path, so the directory and the extension belong to the ' + . 'renderer rather than to crest.' + ); + } + return 0; } @@ -162,8 +191,21 @@ private function paramsBlock(Target $target): string . " }\n"; } - private function template(Target $target): string + /** + * The template name the view responder renders. + * + * Derived from the route unless the caller names one. The derivation is + * crest's own convention and not the framework's - withTemplate() accepts + * any string, and Renderer::render() defines neither directory nor + * extension - so --template exists to replace a guess rather than leave + * someone renaming the file afterwards. + */ + private function template(Target $target, string $named): string { + if ('' !== $named) { + return $named; + } + $path = trim(str_replace('{', '', str_replace('}', '', $target->path)), '/'); return ('' === $path ? 'index' : $path) . '/index'; diff --git a/src/Command/Make/CommandCommand.php b/src/Command/Make/CommandCommand.php new file mode 100644 index 0000000..cd3920c --- /dev/null +++ b/src/Command/Make/CommandCommand.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command\Make; + +use Crest\Command\ProjectCommand; +use Crest\Commands; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +use function sprintf; +use function str_replace; +use function strlen; +use function strtolower; +use function substr; + +/** + * Generates a crest command, for a package that wants to contribute its own. + * + * The registry has exactly one way in - `extra.crest.commands` in a package's + * composer.json, read from every installed package including the root project. + * There is no autoload scan and no convention directory, so a generated command + * is invisible to `crest list` until that block exists. Crest prints it rather + * than editing the manifest: this would otherwise be the only command that + * writes to composer.json, and it would be so for three lines of output. + * + * The registry name is derived by lowercasing the class, minus its suffix, which + * is right for the single-word case and an obvious placeholder otherwise - + * `SendEmails` gives `sendemails`, not `send-emails`. Nothing can derive + * `migration:run` from a class name, so the generated definition is a starting + * point either way. + */ +final class CommandCommand extends ProjectCommand +{ + private const KEY = 'command'; + private const SUFFIX = 'Command'; + + public function define(): Definition + { + return Definition::for('make:command', 'Create a crest command') + ->argument('name', true, 'Command name, e.g. Greet') + ->option('force', 'Overwrite an existing command'); + } + + public function handle(Input $input, Output $output): int + { + $config = $this->config($input); + $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); + $name = $this->registryName($placement->class); + + $writer = $this->writer($config); + + $writer->render( + $placement->file, + self::KEY, + [ + 'namespace' => $placement->namespace, + 'class' => $placement->class, + 'command' => $name, + ], + true === $input->option('force') + ); + + $output->success(sprintf('Created %s', $placement->file)); + $output->line('Nothing lists it yet. Declare it in the package composer.json:'); + $output->line(); + $output->line(' "extra": {'); + $output->line(sprintf(' "%s": {', Commands::KEY)); + $output->line(' "commands": {'); + $output->line( + sprintf( + ' "%s": "%s"', + $name, + str_replace('\\', '\\\\', $placement->namespace . '\\' . $placement->class) + ) + ); + $output->line(' }'); + $output->line(' }'); + $output->line(' }'); + + return 0; + } + + /** + * The name the registry answers to. Falls back to the whole class when + * stripping the suffix leaves nothing, so `make:command Command` still + * yields a usable name rather than an empty one. + */ + private function registryName(string $class): string + { + return strtolower(substr($class, 0, -strlen(self::SUFFIX))) ?: strtolower($class); + } +} diff --git a/src/Command/Make/MiddlewareCommand.php b/src/Command/Make/MiddlewareCommand.php new file mode 100644 index 0000000..7b9b6ad --- /dev/null +++ b/src/Command/Make/MiddlewareCommand.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command\Make; + +use Crest\Command\ProjectCommand; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +use function sprintf; + +/** + * Generates an ADR Middleware - a wrapper around the handler chain that may + * pass the request through, decorate the response, short-circuit with its own, + * or throw into the error responder. + * + * The generated class is inert until the router's middleware map names it, and + * crest will not edit the project's bootstrap to do that. So the command prints + * the registration instead: the file is crest's to write, the wiring is the + * developer's to place. + */ +final class MiddlewareCommand extends ProjectCommand +{ + private const KEY = 'middleware'; + private const SUFFIX = 'Middleware'; + + public function define(): Definition + { + return Definition::for('make:middleware', 'Create an ADR middleware') + ->argument('name', true, 'Middleware name, e.g. Auth') + ->option('force', 'Overwrite an existing middleware'); + } + + public function handle(Input $input, Output $output): int + { + $config = $this->config($input); + $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); + + $writer = $this->writer($config); + + $writer->render( + $placement->file, + self::KEY, + [ + 'namespace' => $placement->namespace, + 'class' => $placement->class, + ], + true === $input->option('force') + ); + + $output->success(sprintf('Created %s', $placement->file)); + $output->line('Nothing runs it yet. Add it to the router\'s middleware map:'); + $output->line(); + $output->line( + sprintf( + " \$router->setMiddlewareMap(['' => [\\%s\\%s::class]]);", + $placement->namespace, + $placement->class + ) + ); + $output->line(); + $output->line( + "The key is a namespace suffix under the base namespace: '' guards every " + . "action, '\\Album' only the actions beneath it." + ); + + return 0; + } +} diff --git a/src/Command/Make/ProviderCommand.php b/src/Command/Make/ProviderCommand.php new file mode 100644 index 0000000..eeff4aa --- /dev/null +++ b/src/Command/Make/ProviderCommand.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command\Make; + +use Crest\Command\ProjectCommand; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +use function sprintf; + +/** + * Generates a service provider for the flavor's container. + * + * Under ADR that means Phalcon\Container and the Provider contract, whose + * provide() takes a service Collection. MVC will register against DI, which has + * its own contract and its own registration call - which is why this generator + * is flavor-scoped rather than shared. + * + * Like make:middleware, the generated class is inert until something calls it, + * and crest does not edit the project's front controller. It prints the call + * instead, including the parent:: line - omitting that one is a silent failure + * that takes the ADR services down with it. + */ +final class ProviderCommand extends ProjectCommand +{ + private const KEY = 'provider'; + private const SUFFIX = 'Provider'; + + public function define(): Definition + { + return Definition::for('make:provider', 'Create a service provider') + ->argument('name', true, 'Provider name, e.g. Cache') + ->option('force', 'Overwrite an existing provider'); + } + + public function handle(Input $input, Output $output): int + { + $config = $this->config($input); + $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); + + $writer = $this->writer($config); + + $writer->render( + $placement->file, + self::KEY, + [ + 'namespace' => $placement->namespace, + 'class' => $placement->class, + ], + true === $input->option('force') + ); + + $output->success(sprintf('Created %s', $placement->file)); + $output->line('Nothing registers it yet. Call it from your front controller:'); + $output->line(); + $output->line(' protected function registerProviders(Container $container): void'); + $output->line(' {'); + $output->line(' parent::registerProviders($container);'); + $output->line(); + $output->line( + sprintf( + ' (new \\%s\\%s())->provide($container);', + $placement->namespace, + $placement->class + ) + ); + $output->line(' }'); + $output->line(); + $output->line('Keep the parent call: it is what registers the ADR services.'); + + return 0; + } +} diff --git a/src/Command/Make/ResponderCommand.php b/src/Command/Make/ResponderCommand.php new file mode 100644 index 0000000..c3fca4a --- /dev/null +++ b/src/Command/Make/ResponderCommand.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command\Make; + +use Crest\Command\ProjectCommand; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +use function sprintf; + +/** + * Generates an ADR Responder - the one layer that speaks HTTP, turning a domain + * payload into a response. + * + * The generated class implements the contract directly rather than extending + * AbstractFormattedResponder: that base composes a formatter chain, which is + * the right answer for content negotiation and the wrong one to hand someone + * who asked for a responder to fill in. + * + * Boots nothing - it reads config and writes a file, so it keeps working on a + * project that does not currently run. + */ +final class ResponderCommand extends ProjectCommand +{ + private const KEY = 'responder'; + private const SUFFIX = 'Responder'; + + public function define(): Definition + { + return Definition::for('make:responder', 'Create an ADR responder') + ->argument('name', true, 'Responder name, e.g. Album') + // No declared default: resolveOptions() supplies false for a flag + // without consulting one, so passing it would state something that + // is never read. + ->option('force', 'Overwrite an existing responder'); + } + + public function handle(Input $input, Output $output): int + { + $config = $this->config($input); + $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); + + $writer = $this->writer($config); + + $writer->render( + $placement->file, + self::KEY, + [ + 'namespace' => $placement->namespace, + 'class' => $placement->class, + ], + true === $input->option('force') + ); + + $output->success(sprintf('Created %s', $placement->file)); + + return 0; + } +} diff --git a/src/Command/ProjectCommand.php b/src/Command/ProjectCommand.php new file mode 100644 index 0000000..9aae100 --- /dev/null +++ b/src/Command/ProjectCommand.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Command\Command; +use Crest\Console\Input; +use Crest\Generator\ArtifactWriter; +use Crest\Generator\ClassName; +use Crest\Generator\Placement; +use Crest\Generator\Stub; +use Crest\Paths; +use Crest\Project\Config; + +/** + * Base for every command that reads the project it is run against. + * + * `--directory` and `--config` are global options the kernel merges into each + * definition, so resolving them was repeated identically in ten commands. It + * lives here rather than on Crest\Console\Command\Command because that class may + * not reference Crest\Project - Crest\Console stays independent of the rest of + * the tool, which IsolationTest enforces. And it is not a Config::fromInput() + * factory, because that would point Crest\Project at Crest\Console\Input and + * couple project configuration to the console for the sake of two arguments. + */ +abstract class ProjectCommand extends Command +{ + protected function config(Input $input): Config + { + return Config::discover( + $input->optionStringOrNull('directory'), + $input->optionStringOrNull('config') + ); + } + + /** + * Where a user-named artifact goes. + * + * Takes the name rather than the Input it came from: reading the `name` + * argument here would be an unwritten contract with every subclass, and a + * generator that called its argument something else would get a confusing + * complaint about the empty string. + * + * The name is validated before any configuration is read, so a typo is + * reported as a typo rather than as whatever the psr-4 map happens to say + * about the directory it would have landed in. + */ + protected function placement(Config $config, string $name, string $key, string $suffix): Placement + { + $class = ClassName::suffixed($name, $suffix); + + return new Placement( + $class, + $config->path($key) . '/' . $class . '.php', + $config->namespaceFor($key) + ); + } + + /** + * The writer a generator renders through. + * + * Assembly was repeated verbatim in every make:* command, which meant five + * copies of the stub resolution order - packaged root, then project root - + * and five places to change when it moves. Contributed commands get it by + * extending this class rather than by knowing how a writer goes together. + * + * stub:publish deliberately does not come through here: it copies rather + * than renders, so it has no stub to construct a writer around and uses the + * static ArtifactWriter::write() instead. + */ + protected function writer(Config $config): ArtifactWriter + { + return new ArtifactWriter( + new Stub(Paths::stubs(), $config->root()), + $config->flavor()->value + ); + } +} diff --git a/src/Command/Route/ListCommand.php b/src/Command/Route/ListCommand.php index 31a2723..bf5bdac 100644 --- a/src/Command/Route/ListCommand.php +++ b/src/Command/Route/ListCommand.php @@ -15,27 +15,22 @@ use Crest\ADR\ActionResolver; use Crest\ADR\PhalconRouterResolver; -use Crest\Console\Command\Command; +use Crest\Command\ProjectCommand; use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; -use Crest\Project\Config; use FilesystemIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; use Throwable; -use function array_pop; use function array_values; use function class_exists; -use function explode; -use function implode; use function is_dir; use function ksort; use function str_replace; use function strlen; -use function strtoupper; use function substr; /** @@ -46,8 +41,25 @@ * classes and ask the framework what each one answers. That is what this does - * it derives nothing itself. */ -final class ListCommand extends Command +final class ListCommand extends ProjectCommand { + private readonly ActionResolver $resolver; + + /** + * Defaulted, so the kernel's `new $class()` still works and nothing outside + * has to know which resolver this command wants. + * + * Injectable so a test can prove the METHOD column is whatever the resolver + * answered. Without the seam, every conforming class name yields the same + * verb whether crest asks the framework or derives it locally - which is + * exactly the mistake this command used to make, and no fixture can tell + * the two apart. + */ + public function __construct(?ActionResolver $resolver = null) + { + $this->resolver = $resolver ?? new PhalconRouterResolver(); + } + public function define(): Definition { return Definition::for('route:list', 'List the routes the application answers'); @@ -55,14 +67,10 @@ public function define(): Definition public function handle(Input $input, Output $output): int { - $config = Config::discover( - $input->optionStringOrNull('directory'), - $input->optionStringOrNull('config') - ); + $config = $this->config($input); $base = $config->namespaceFor('action'); $directory = $config->path('action'); - $resolver = new PhalconRouterResolver(); $routes = []; @@ -73,15 +81,19 @@ public function handle(Input $input, Output $output): int // Action's params(), and an unloaded class reports none. $this->load($file, $fqcn); - $path = $resolver->pathFor($base, $fqcn); + $method = $this->resolver->methodFor($base, $fqcn); + $path = $this->resolver->pathFor($base, $fqcn); - if (null === $path) { + // Both answer null for exactly the same classes - the ones the + // convention would never have produced - so neither guard is + // redundant to read, and neither is load-bearing on its own. + if (null === $method || null === $path) { continue; } // Keyed by path alone: one path names exactly one Action, so the // path is already unique and sorting by it is sorting the listing. - $routes[$path] = [$this->verb($class), $path, $fqcn]; + $routes[$path] = [$method, $path, $fqcn]; } if ([] === $routes) { @@ -161,20 +173,4 @@ private function load(string $file, string $class): void // Reported without its attributes rather than not at all. } } - - /** - * The HTTP verb, taken as whatever precedes the concatenated namespace - * segments in the class name. - * - * Derived rather than matched against a list of verbs, so crest holds no - * copy of which verbs the framework recognises - if it gains one, this - * keeps working. - */ - private function verb(string $class): string - { - $parts = explode('\\', $class); - $last = array_pop($parts); - - return strtoupper(substr($last, 0, strlen($last) - strlen(implode('', $parts)))); - } } diff --git a/src/Command/Stub/PublishCommand.php b/src/Command/Stub/PublishCommand.php new file mode 100644 index 0000000..2078412 --- /dev/null +++ b/src/Command/Stub/PublishCommand.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command\Stub; + +use Crest\Command\ProjectCommand; +use Crest\Console\Exceptions\Exception; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; +use Crest\Generator\ArtifactWriter; +use Crest\Generator\Stub; +use Crest\Paths; + +use function basename; +use function file_get_contents; +use function glob; +use function is_file; +use function preg_match; +use function sprintf; + +/** + * Copies packaged stubs into the project so they can be edited. + * + * The two-level stub chain has always worked - a file under the project's + * override directory wins over the packaged one - but nothing told anybody it + * was there, and guessing the layout is not a reasonable ask. This command is + * what makes the mechanism discoverable. + * + * Renders nothing, so it is the one generator-adjacent command with no stub of + * its own and no flavor-specific behavior beyond which directory it reads. + */ +final class PublishCommand extends ProjectCommand +{ + /** + * A packaged stub name. Hyphens are in because `action-view` is one. + */ + private const NAME = '/^[A-Za-z0-9_-]+$/'; + + public function define(): Definition + { + return Definition::for('stub:publish', 'Copy packaged stubs into the project for editing') + ->argument('name', false, 'A single stub, e.g. action. Omit to publish them all') + ->option('force', 'Overwrite stubs the project has already published'); + } + + public function handle(Input $input, Output $output): int + { + $config = $this->config($input); + + $flavor = $config->flavor()->value; + $name = $input->argumentString('name'); + $force = true === $input->option('force'); + + foreach ($this->sources($flavor, $name) as $source) { + $target = Stub::overridePath($config->root(), $flavor, basename($source, '.stub')); + + if (true === is_file($target) && false === $force) { + $output->line( + sprintf('Skipped %s; it exists already, pass --force to overwrite', $target) + ); + + continue; + } + + ArtifactWriter::write($target, (string) file_get_contents($source)); + + $output->success(sprintf('Published %s', $target)); + } + + return 0; + } + + /** + * The packaged stubs this run will copy. + * + * Reads the packaged directory rather than going through Stub::resolve(): + * resolution prefers a project override, and publishing an already-published + * stub over itself is not a copy anyone asked for. + * + * @return list + */ + private function sources(string $flavor, string $name): array + { + if ('' !== $name) { + // A name is a name, not a path. Without this, `stub:publish + // ../../elsewhere/thing` resolves and copies a file from outside the + // package - harmless on a developer's own machine, but the failure + // it produces otherwise explains nothing. + if (0 === preg_match(self::NAME, $name)) { + throw new Exception(sprintf("'%s' is not a stub name", $name)); + } + + $single = Stub::packagedPath(Paths::stubs(), $flavor, $name); + + if (false === is_file($single)) { + throw new Exception(sprintf("stub '%s/%s' is not packaged", $flavor, $name)); + } + + return [$single]; + } + + // glob() sorts alphabetically unless told not to, so the listing is + // stable without a sort of its own. + $found = glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: []; + + if ([] === $found) { + throw new Exception(sprintf("no stubs are packaged for the '%s' flavor", $flavor)); + } + + return $found; + } +} diff --git a/src/Commands.php b/src/Commands.php index 0535b5f..e0b209c 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -14,18 +14,22 @@ namespace Crest; use Crest\Command\AboutCommand; -use Crest\Command\ListCommand; use Crest\Command\Config\ShowCommand as ConfigShowCommand; use Crest\Command\Container\ListCommand as ContainerListCommand; use Crest\Command\Event\ListCommand as EventListCommand; +use Crest\Command\ListCommand; use Crest\Command\Make\ActionCommand; +use Crest\Command\Make\CommandCommand; +use Crest\Command\Make\MiddlewareCommand; +use Crest\Command\Make\ProviderCommand; +use Crest\Command\Make\ResponderCommand; use Crest\Command\Route\ListCommand as RouteListCommand; +use Crest\Command\Stub\PublishCommand as StubPublishCommand; use Crest\Console\Registry; /** * Crest's identity and command set. The console core is deliberately anonymous; - * this class is what makes it crest. When Crest\Console becomes - * phalcon/console, this file is the only thing that stays behind. + * this class is what makes it crest. */ final class Commands { @@ -57,7 +61,12 @@ public static function registry(): Registry ->add('event:list', EventListCommand::class) ->add('list', ListCommand::class, 'commands', 'enumerate') ->add('make:action', ActionCommand::class) + ->add('make:command', CommandCommand::class) + ->add('make:middleware', MiddlewareCommand::class) + ->add('make:provider', ProviderCommand::class) + ->add('make:responder', ResponderCommand::class) ->add('route:list', RouteListCommand::class) + ->add('stub:publish', StubPublishCommand::class) ->withDiscovery(self::KEY); } } diff --git a/src/Console/Kernel.php b/src/Console/Kernel.php index 886433a..d48ef47 100644 --- a/src/Console/Kernel.php +++ b/src/Console/Kernel.php @@ -22,7 +22,6 @@ use function array_search; use function array_slice; use function in_array; -use function ksort; use function str_starts_with; use const STDERR; @@ -33,8 +32,7 @@ * binds, runs, and turns console exceptions into clean stderr lines. * * Owns no identity: the tool's name, its package and its command set are - * supplied by the caller, which is what allows this class to be moved to - * phalcon/console without edits. + * supplied by the caller. */ final class Kernel { @@ -90,7 +88,7 @@ public function handle(array $argv): int $first = $tokens[0] ?? null; if ('--version' === $first || '-V' === $first) { - $this->output->line($this->name . ' ' . $this->version()); + $this->output->banner($this->name . ' ' . $this->version()); return 0; } @@ -143,18 +141,10 @@ private function beforeLiteral(array $tokens): array private function listCommands(): void { - $commands = $this->registry->all(); - ksort($commands); - - $rows = []; - foreach ($commands as $name => $class) { - $command = new $class(); - $rows[] = [$name, $command->define()->getDescription()]; - } - - $this->output->line($this->name . ' ' . $this->version()); - $this->output->line(); - $this->output->table(['COMMAND', 'DESCRIPTION'], $rows); + $this->output->commandTable( + $this->name . ' ' . $this->version(), + $this->registry->descriptions() + ); } /** diff --git a/src/Console/Output.php b/src/Console/Output.php index 6b6bd7c..af92bc5 100644 --- a/src/Console/Output.php +++ b/src/Console/Output.php @@ -35,9 +35,16 @@ */ final class Output { - public const COLOR_GREEN = "\033[32m"; - public const COLOR_RED = "\033[31m"; - public const COLOR_RESET = "\033[0m"; + public const COLOR_GREEN = "\033[32m"; + public const COLOR_ORANGE = "\033[38;5;208m"; + public const COLOR_RED = "\033[31m"; + public const COLOR_RESET = "\033[0m"; + + /** + * The glyph a banner opens with. Named for its shape rather than for any + * one tool. + */ + public const MARK = '⟩⟩⟩'; private bool $decorated; @@ -59,6 +66,42 @@ public function __construct($stdout = STDOUT, $stderr = STDERR, ?bool $decorated $this->decorated = $decorated ?? $this->detectDecoration($stdout); } + /** + * The identity line a run opens with: the chevron mark, then whatever the + * caller puts after it - by convention the tool name and its version. + * + * The mark is colored through decorate() rather than carrying its own + * escapes, so a piped run or one with NO_COLOR set gets the glyph and no + * control codes. + */ + public function banner(string $text): void + { + $this->line($this->decorate(self::MARK, self::COLOR_ORANGE) . ' ' . $text); + } + + /** + * The command listing: banner, blank line, one row per command. + * + * Presentation only - the caller supplies the descriptions, so this class + * stays unaware of how a registry answers. Shared because the kernel prints + * this listing when invoked with no arguments and an addressable `list` + * command prints the same thing, and two copies of the layout had to be + * kept in agreement by hand. + * + * @param array $descriptions Command name => description. + */ + public function commandTable(string $banner, array $descriptions): void + { + $rows = []; + foreach ($descriptions as $name => $description) { + $rows[] = [$name, $description]; + } + + $this->banner($banner); + $this->line(); + $this->table(['COMMAND', 'DESCRIPTION'], $rows); + } + public function error(string $text): void { fwrite($this->stderr, $this->decorate($text, self::COLOR_RED) . PHP_EOL); @@ -105,8 +148,7 @@ public function table(array $headers, array $rows, bool $withHeaders = true): vo /** * Renders a command's usage block from its definition. Presentation lives - * here rather than on Definition so the schema stays a pure data structure - * when it is promoted into cli-options-parser. + * here rather than on Definition so the schema stays a pure data structure. */ public function usage(string $tool, Definition $definition): void { diff --git a/src/Console/Registry.php b/src/Console/Registry.php index 8d8d808..b979ac3 100644 --- a/src/Console/Registry.php +++ b/src/Console/Registry.php @@ -20,6 +20,7 @@ use function class_exists; use function is_array; use function is_string; +use function ksort; use function sprintf; /** @@ -34,8 +35,7 @@ * Aliases resolve through get()/has() but never appear in all(), so `list` * shows one row per command. * - * Ships empty and names no commands: the owning tool seeds it. That is what - * lets this class move to phalcon/console unchanged. + * Ships empty and names no commands: the owning tool seeds it. */ final class Registry { @@ -86,6 +86,33 @@ public function all(): array return $this->commands; } + /** + * Every canonical name mapped to its description, sorted by name. + * + * The one method here that instantiates anything: a description lives on + * the command's definition, so answering this means constructing each + * command. Resolution through get()/has() still instantiates nothing. + * + * Lives here rather than in the callers because the kernel prints this + * listing when invoked with no arguments and the addressable `list` command + * prints the same thing - two copies of the loop that had to be kept in + * agreement by hand. + * + * @return array + */ + public function descriptions(): array + { + $commands = $this->all(); + ksort($commands); + + $descriptions = []; + foreach ($commands as $name => $class) { + $descriptions[$name] = (new $class())->define()->getDescription(); + } + + return $descriptions; + } + /** * @return class-string */ diff --git a/src/Generator/ArtifactWriter.php b/src/Generator/ArtifactWriter.php new file mode 100644 index 0000000..9a7a156 --- /dev/null +++ b/src/Generator/ArtifactWriter.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Generator; + +use Crest\Console\Exceptions\Exception; + +use function dirname; +use function file_put_contents; +use function is_dir; +use function is_file; +use function mkdir; +use function sprintf; + +/** + * Puts a generated file on disk: refuse to clobber, render, create the + * directory, write. + * + * Every make:* command repeated this sequence verbatim, which meant one + * mkdir() mode literal per command in the mutation-testing ignore list, and a + * refuse-to-overwrite message that could drift between commands. One copy now. + * + * A collaborator rather than a base class on purpose: make:action derives its + * target from the route convention and passes a different set of placeholders, + * so a template method would have to expose that divergence as hooks. Composing + * keeps every handle() readable top to bottom. + */ +final class ArtifactWriter +{ + public function __construct( + private readonly Stub $stub, + private readonly string $flavor, + ) { + } + + /** + * Writes contents to a file, creating the directory if it is missing. + * + * Both operations are checked. Unchecked, a read-only target produced two + * PHP warnings and then "Created " with exit 0 - the tool reporting a + * file it had not written. + * + * The warnings are suppressed rather than left to surface alongside the + * exception: the kernel renders a console exception as one clean stderr + * line, and a raw warning ahead of it would bury the sentence that explains + * what went wrong. + * + * Static because it needs nothing from the instance, which lets stub:publish + * reuse it - that command copies rather than renders, so it has no stub of + * its own to construct a writer around. + */ + public static function write(string $file, string $contents): void + { + $directory = dirname($file); + + if (false === is_dir($directory) && false === @mkdir($directory, 0o775, true)) { + throw new Exception(sprintf('could not create %s', $directory)); + } + + if (false === @file_put_contents($file, $contents)) { + throw new Exception(sprintf('could not write %s', $file)); + } + } + + /** + * Renders a stub into place. + * + * @param array $replacements + */ + public function render(string $file, string $name, array $replacements, bool $force): void + { + // is_file(), not file_exists(): the latter is also true of a directory, + // which would report "already exists" and then fail to write. + if (true === is_file($file) && false === $force) { + throw new Exception(sprintf('%s already exists; pass --force to overwrite', $file)); + } + + self::write($file, $this->stub->render($this->flavor, $name, $replacements)); + } +} diff --git a/src/Generator/ClassName.php b/src/Generator/ClassName.php new file mode 100644 index 0000000..4e4e220 --- /dev/null +++ b/src/Generator/ClassName.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Generator; + +use Crest\Console\Exceptions\Exception; + +use function preg_match; +use function sprintf; +use function str_ends_with; + +/** + * Turns the name a user typed into the class name a generator writes. + * + * Suffixing is idempotent: `make:middleware Cors` and + * `make:middleware CorsMiddleware` both produce CorsMiddleware. Someone who + * spells out the convention should not be punished with + * CorsMiddlewareMiddleware for knowing it. + * + * The name is otherwise taken verbatim - crest does not case-correct it, + * because the class it writes should be the class that was asked for. + * + * make:action never comes through here: Convention derives that class name from + * the route, so nothing the user types names it. + */ +final class ClassName +{ + /** + * One unqualified class name. Namespaced input is rejected rather than + * split into directories, because the answer to `make:responder Admin/Album` + * is a decision about layout, not something to guess at. + * + * The high-byte range is PHP's own rule for an identifier, so a class named + * in a non-Latin script is accepted rather than refused for being unusual. + * Deliberately byte-oriented and not /u: that is exactly how PHP itself + * decides what may name a class. + */ + private const PATTERN = '/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$/'; + + public static function suffixed(string $name, string $suffix): string + { + if (0 === preg_match(self::PATTERN, $name)) { + throw new Exception( + sprintf("'%s' is not a usable class name; expected a single name like 'Album'", $name) + ); + } + + if (true === str_ends_with($name, $suffix)) { + return $name; + } + + return $name . $suffix; + } +} diff --git a/src/Generator/Placement.php b/src/Generator/Placement.php new file mode 100644 index 0000000..297b1e0 --- /dev/null +++ b/src/Generator/Placement.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Generator; + +/** + * Where a named artifact goes and what it is called. + * + * The counterpart to Crest\ADR\Target, which answers the same question for + * Actions - except an Action's name is derived from its route, so Target also + * carries the method and path. Everything else is named by the user, so this + * carries nothing but the placement. + * + * Holds no reference to Config: the command resolves the three values and hands + * them over, which keeps project configuration out of Crest\Generator. + */ +final class Placement +{ + public function __construct( + public readonly string $class, + public readonly string $file, + public readonly string $namespace, + ) { + } +} diff --git a/src/Generator/Stub.php b/src/Generator/Stub.php index e45255e..8e0e48b 100644 --- a/src/Generator/Stub.php +++ b/src/Generator/Stub.php @@ -25,17 +25,65 @@ * Loads a stub through a two-level chain - project override, then the copy * shipped in the package - and substitutes placeholders. Plain string * replacement, deliberately not a template engine. + * + * The static path builders exist so that stub:publish writes exactly where + * resolve() reads. Nothing outside this class assembles a stub path. */ final class Stub { - private ?string $projectRoot; + /** + * Where a project keeps the stubs it has taken over, relative to its root. + * + * Private: callers ask for a path rather than assembling one, so the layout + * lives here alone. Two copies of the convention that drifted would put + * published stubs somewhere resolution never looks - silent, and maddening + * to diagnose. + */ + private const OVERRIDE_DIRECTORY = 'resources/stubs'; private string $packagedRoot; + private ?string $projectRoot; + + /** + * Roots are stored as given. Normalizing a trailing slash is the path + * builders' job, and doing it here as well would mean two places could be + * changed independently while the tests still passed. + */ public function __construct(string $packagedRoot, ?string $projectRoot = null) { - $this->packagedRoot = rtrim($packagedRoot, '/'); - $this->projectRoot = null === $projectRoot ? null : rtrim($projectRoot, '/'); + $this->packagedRoot = $packagedRoot; + $this->projectRoot = $projectRoot; + } + + /** + * Where a project's own copy of a stub lives - the path stub:publish writes + * and resolve() prefers. Returned whether or not it exists. + */ + public static function overridePath(string $projectRoot, string $flavor, string $name): string + { + return self::packagedPath( + rtrim($projectRoot, '/') . '/' . self::OVERRIDE_DIRECTORY, + $flavor, + $name + ); + } + + /** + * The directory a package keeps a flavor's stubs in. The whole-directory + * answer, for callers that enumerate rather than name one stub. + */ + public static function packagedDirectory(string $packagedRoot, string $flavor): string + { + return rtrim($packagedRoot, '/') . '/' . $flavor; + } + + /** + * A stub's location as shipped in a package. + */ + public static function packagedPath(string $packagedRoot, string $flavor, string $name): string + { + return self::packagedDirectory($packagedRoot, $flavor) . '/' . $name . '.stub'; } /** @@ -54,17 +102,15 @@ public function render(string $flavor, string $name, array $replacements): strin public function resolve(string $flavor, string $name): string { - $relative = sprintf('%s/%s.stub', $flavor, $name); - if (null !== $this->projectRoot) { - $override = $this->projectRoot . '/resources/stubs/' . $relative; + $override = self::overridePath($this->projectRoot, $flavor, $name); if (true === is_file($override)) { return $override; } } - $packaged = $this->packagedRoot . '/' . $relative; + $packaged = self::packagedPath($this->packagedRoot, $flavor, $name); if (true === is_file($packaged)) { return $packaged; diff --git a/src/Project/Config.php b/src/Project/Config.php index f3932c9..9eeb763 100644 --- a/src/Project/Config.php +++ b/src/Project/Config.php @@ -18,10 +18,10 @@ use function array_keys; use function dirname; use function explode; -use function in_array; use function file_get_contents; use function getcwd; use function implode; +use function in_array; use function is_array; use function is_dir; use function is_file; @@ -45,8 +45,6 @@ */ final class Config { - private const DEFAULT_PATHS = ['action' => 'src/Action']; - /** * @param array $paths * @param array $namespaces @@ -88,140 +86,35 @@ public static function discover(?string $directory = null, ?string $configFile = return self::infer($directory); } - public function flavor(): Flavor - { - return $this->flavor; - } - - public function namespace(): string - { - return $this->namespace; - } - /** - * The namespace a named location maps to. + * Where each generated artifact lands when crest.php does not say. * - * An explicit `namespaces` entry in crest.php wins. Otherwise the answer - * comes from composer.json's psr-4 map - the authoritative statement of - * which prefix covers which directory - by finding the longest declared - * directory that prefixes this path and appending the remainder. + * Keyed by flavor rather than shared, because the artifacts themselves are + * flavor-specific: a provider registers against `Phalcon\Container` under + * ADR and against DI under MVC, so one flat set would offer every project + * directories for artifacts it can never generate. * - * PSR-4 maps directory segments to namespace segments verbatim, so the - * remainder is used as-is with no case transformation. - * - * Throws when no psr-4 entry covers the path: that configuration cannot - * autoload whatever is written there, and a clear error is worth more than - * a plausible guess. - */ - public function namespaceFor(string $key): string - { - if (true === isset($this->namespaces[$key])) { - return trim($this->namespaces[$key], '\\'); - } - - // substr, not str_replace: a global replace would also strip a repeat - // of the root further down the path. - $relative = trim(substr($this->path($key), strlen($this->root)), '/'); - $best = null; - $prefix = ''; - - foreach ($this->psr4 as $candidate => $directory) { - $directory = trim($directory, '/'); - - if ( - $relative !== $directory - && false === str_starts_with($relative, $directory . '/') - ) { - continue; - } - - if (null !== $best && strlen($directory) <= strlen($best)) { - continue; - } - - $best = $directory; - $prefix = trim($candidate, '\\'); - } - - if (null === $best) { - throw new Exception( - sprintf("no psr-4 autoload entry covers '%s'", $relative) - ); - } - - $remainder = trim(substr($relative, strlen($best)), '/'); - - if ('' === $remainder) { - return $prefix; - } - - return $prefix . '\\' . implode('\\', explode('/', $remainder)); - } - - /** - * How the project boots, as declared - either a front controller class or - * a path to a file returning a container. Null when nothing was declared. - * - * Returned verbatim rather than resolved, because the two forms resolve - * differently and only the caller knows which it is looking at. - * - * Services and listeners cannot be read off the filesystem the way routes - * can: they exist only once the application has registered them. - */ - public function bootstrap(): ?string - { - return $this->bootstrap; - } - - /** - * Whether the config file stated this top-level key, as opposed to it - * taking a default. `flavor`, `namespace`, `paths`, `namespaces`. - */ - public function isDeclared(string $key): bool - { - return in_array($key, $this->declared, true); - } - - /** - * Every named location, resolved to an absolute path. + * Only ADR is populated. The others get their keys when their generators + * land - defaults no command reads would show up in `config:show` as + * locations that mean nothing. * * @return array */ - public function paths(): array - { - $resolved = []; - - foreach (array_keys($this->paths) as $key) { - $resolved[$key] = $this->path($key); - } - - return $resolved; - } - - /** - * The config file this was read from, or null when everything was inferred - * from composer.json. - */ - public function source(): ?string + private static function defaultPaths(Flavor $flavor): array { - return $this->source; - } - - /** - * Absolute path for a named location. - */ - public function path(string $key): string - { - if (false === isset($this->paths[$key])) { - throw new Exception(sprintf("unknown path '%s'", $key)); - } - - return $this->root . '/' . trim($this->paths[$key], '/'); - } - - public function root(): string - { - return $this->root; + return match ($flavor) { + Flavor::ADR => [ + 'action' => 'src/Action', + // Not an ADR artifact: a crest command is the same class in any + // flavor. It sits here because ADR is the only populated set, + // and moves to a shared one when cli, mvc and micro arrive. + 'command' => 'src/Command', + 'middleware' => 'src/Middleware', + 'provider' => 'src/Provider', + 'responder' => 'src/Responder', + ], + Flavor::CLI, Flavor::MVC => [], + }; } /** @@ -250,7 +143,7 @@ private static function fromArray(array $declared, string $root, string $source) $namespace = trim($declared['namespace'], '\\'); } - $paths = self::DEFAULT_PATHS; + $paths = self::defaultPaths($flavor); if (true === isset($declared['paths']) && true === is_array($declared['paths'])) { /** @var array $supplied */ $supplied = $declared['paths']; @@ -311,7 +204,7 @@ private static function infer(string $directory): self Flavor::ADR, trim($prefix, '\\'), $directory, - self::DEFAULT_PATHS, + self::defaultPaths(Flavor::ADR), [], $psr4 ); @@ -351,4 +244,140 @@ private static function psr4Map(string $root): array return $map; } + + /** + * How the project boots, as declared - either a front controller class or + * a path to a file returning a container. Null when nothing was declared. + * + * Returned verbatim rather than resolved, because the two forms resolve + * differently and only the caller knows which it is looking at. + * + * Services and listeners cannot be read off the filesystem the way routes + * can: they exist only once the application has registered them. + */ + public function bootstrap(): ?string + { + return $this->bootstrap; + } + + public function flavor(): Flavor + { + return $this->flavor; + } + + /** + * Whether the config file stated this top-level key, as opposed to it + * taking a default. `flavor`, `namespace`, `paths`, `namespaces`. + */ + public function isDeclared(string $key): bool + { + return in_array($key, $this->declared, true); + } + + public function namespace(): string + { + return $this->namespace; + } + + /** + * The namespace a named location maps to. + * + * An explicit `namespaces` entry in crest.php wins. Otherwise the answer + * comes from composer.json's psr-4 map - the authoritative statement of + * which prefix covers which directory - by finding the longest declared + * directory that prefixes this path and appending the remainder. + * + * PSR-4 maps directory segments to namespace segments verbatim, so the + * remainder is used as-is with no case transformation. + * + * Throws when no psr-4 entry covers the path: that configuration cannot + * autoload whatever is written there, and a clear error is worth more than + * a plausible guess. + */ + public function namespaceFor(string $key): string + { + if (true === isset($this->namespaces[$key])) { + return trim($this->namespaces[$key], '\\'); + } + + // substr, not str_replace: a global replace would also strip a repeat + // of the root further down the path. + $relative = trim(substr($this->path($key), strlen($this->root)), '/'); + $best = null; + $prefix = ''; + + foreach ($this->psr4 as $candidate => $directory) { + $directory = trim($directory, '/'); + + if ( + $relative !== $directory + && false === str_starts_with($relative, $directory . '/') + ) { + continue; + } + + if (null !== $best && strlen($directory) <= strlen($best)) { + continue; + } + + $best = $directory; + $prefix = trim($candidate, '\\'); + } + + if (null === $best) { + throw new Exception( + sprintf("no psr-4 autoload entry covers '%s'", $relative) + ); + } + + $remainder = trim(substr($relative, strlen($best)), '/'); + + if ('' === $remainder) { + return $prefix; + } + + return $prefix . '\\' . implode('\\', explode('/', $remainder)); + } + + /** + * Absolute path for a named location. + */ + public function path(string $key): string + { + if (false === isset($this->paths[$key])) { + throw new Exception(sprintf("unknown path '%s'", $key)); + } + + return $this->root . '/' . trim($this->paths[$key], '/'); + } + + /** + * Every named location, resolved to an absolute path. + * + * @return array + */ + public function paths(): array + { + $resolved = []; + + foreach (array_keys($this->paths) as $key) { + $resolved[$key] = $this->path($key); + } + + return $resolved; + } + + public function root(): string + { + return $this->root; + } + + /** + * The config file this was read from, or null when everything was inferred + * from composer.json. + */ + public function source(): ?string + { + return $this->source; + } } diff --git a/tests/Support/ADR/StubActionResolver.php b/tests/Support/ADR/StubActionResolver.php index 222df21..a5458e0 100644 --- a/tests/Support/ADR/StubActionResolver.php +++ b/tests/Support/ADR/StubActionResolver.php @@ -27,6 +27,7 @@ final class StubActionResolver implements ActionResolver public function __construct( private readonly string $class, private readonly ?string $path = null, + private readonly ?string $method = null, ) { } @@ -37,6 +38,11 @@ public function classFor(string $baseNamespace, string $method, string $path): s return $this->class; } + public function methodFor(string $baseNamespace, string $class): ?string + { + return $this->method; + } + public function pathFor(string $baseNamespace, string $class): ?string { return $this->path; diff --git a/tests/Support/GeneratesInAScratchProject.php b/tests/Support/GeneratesInAScratchProject.php new file mode 100644 index 0000000..ca448d8 --- /dev/null +++ b/tests/Support/GeneratesInAScratchProject.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Support; + +use Crest\Commands; +use Crest\Console\Command\Command; +use Crest\Console\Kernel; +use Crest\Console\Registry; + +use function chdir; +use function getcwd; + +/** + * A throwaway project a generator can be pointed at, plus the kernel to run one + * command against it. + * + * Every generator test needed the same four things - scratch directory, a psr-4 + * composer.json, captured streams, and the working directory moved inside the + * scratch project - and each was carrying its own copy. + * + * @mixin \PHPUnit\Framework\TestCase + */ +trait GeneratesInAScratchProject +{ + use CapturesOutput; + use ScratchDirectory; + + private string $previousCwd = ''; + + protected function endScratchProject(): void + { + chdir($this->previousCwd); + + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + /** + * @param class-string $class + * @param list $arguments + */ + protected function runProjectCommand(string $name, string $class, array $arguments): int + { + $registry = (new Registry())->add($name, $class); + $kernel = new Kernel( + Commands::NAME, + $registry, + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + return $kernel->handle(['crest', $name, ...$arguments, '--directory', $this->root]); + } + + protected function startScratchProject(string $prefix, string ...$subdirectories): void + { + $this->makeScratchDirectory($prefix, ...$subdirectories); + $this->writeComposerJson(['App\\' => 'src/']); + $this->captureStreams(); + + // Config::discover() falls back to the working directory when + // --directory does not reach it, and that fallback is reachable under + // mutation testing. Running from the scratch directory keeps even a + // mutant's writes contained instead of landing in the real src/ tree. + $this->previousCwd = (string) getcwd(); + chdir($this->root); + } +} diff --git a/tests/Unit/ADR/ConventionTest.php b/tests/Unit/ADR/ConventionTest.php index 81bcbe7..5f05281 100644 --- a/tests/Unit/ADR/ConventionTest.php +++ b/tests/Unit/ADR/ConventionTest.php @@ -20,6 +20,30 @@ final class ConventionTest extends TestCase { + public function testAStaticSegmentAfterAPlaceholderIsRejected(): void + { + // The convention cannot name this route, so the user is told rather + // than handed a file that answers a different URL. + $resolver = new StubActionResolver('App\Action\Album\Edit\GetAlbumEdit'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "'edit' cannot follow a placeholder; arguments come last, " + . "so write the route as '/album/edit/{id}'" + ); + + (new Convention('App\Action', $resolver))->target('GET', '/album/{id}/edit'); + } + + public function testClassWithNoNamespaceYieldsAnEmptyNamespace(): void + { + $resolver = new StubActionResolver('Get'); + + $target = (new Convention('', $resolver))->target('GET', '/'); + + $this->assertSame('', $target->namespace); + $this->assertSame('Get', $target->class); + } public function testEmptyPathIsPassedThroughAsRoot(): void { $resolver = new StubActionResolver('App\Action\Get'); @@ -38,30 +62,26 @@ public function testMethodIsUppercasedOnTheTarget(): void $this->assertSame('POST', $target->method); } - public function testTrailingPlaceholdersAllBecomeAttributes(): void + public function testOnlyTheStaticPrefixReachesTheResolver(): void { - $resolver = new StubActionResolver('App\Action\Company\Users\GetCompanyUsers'); + $resolver = new StubActionResolver('App\Action\Company\GetCompany'); - $target = (new Convention('App\Action', $resolver)) - ->target('GET', '/company/users/{id}/{userId}'); + (new Convention('App\Action', $resolver))->target('GET', '/company/{id}'); - $this->assertSame(['id', 'userId'], $target->attributes); - $this->assertSame(['App\Action', 'GET', '/company/users'], $resolver->calls[0]); + $this->assertSame(['App\Action', 'GET', '/company'], $resolver->calls[0]); } - public function testAStaticSegmentAfterAPlaceholderIsRejected(): void + public function testTargetSplitsTheClassIntoNamespaceClassAndPath(): void { - // The convention cannot name this route, so the user is told rather - // than handed a file that answers a different URL. - $resolver = new StubActionResolver('App\Action\Album\Edit\GetAlbumEdit'); + $resolver = new StubActionResolver('App\Action\Company\All\GetCompanyAll'); - $this->expectException(Exception::class); - $this->expectExceptionMessage( - "'edit' cannot follow a placeholder; arguments come last, " - . "so write the route as '/album/edit/{id}'" - ); + $target = (new Convention('App\Action', $resolver))->target('GET', '/company/all'); - (new Convention('App\Action', $resolver))->target('GET', '/album/{id}/edit'); + $this->assertSame('App\Action\Company\All\GetCompanyAll', $target->fqcn); + $this->assertSame('App\Action\Company\All', $target->namespace); + $this->assertSame('GetCompanyAll', $target->class); + $this->assertSame('Company/All/GetCompanyAll.php', $target->relativePath); + $this->assertSame([], $target->attributes); } public function testTheSuggestionKeepsEverySegmentInOrder(): void @@ -76,26 +96,14 @@ public function testTheSuggestionKeepsEverySegmentInOrder(): void (new Convention('App\Action', $resolver))->target('GET', '/album/{id}/edit/{slug}'); } - public function testOnlyTheStaticPrefixReachesTheResolver(): void - { - $resolver = new StubActionResolver('App\Action\Company\GetCompany'); - - (new Convention('App\Action', $resolver))->target('GET', '/company/{id}'); - - $this->assertSame(['App\Action', 'GET', '/company'], $resolver->calls[0]); - } - - public function testTargetSplitsTheClassIntoNamespaceClassAndPath(): void + public function testTopLevelClassYieldsAFlatPath(): void { - $resolver = new StubActionResolver('App\Action\Company\All\GetCompanyAll'); + $resolver = new StubActionResolver('App\Action\Get'); - $target = (new Convention('App\Action', $resolver))->target('GET', '/company/all'); + $target = (new Convention('App\Action', $resolver))->target('GET', '/'); - $this->assertSame('App\Action\Company\All\GetCompanyAll', $target->fqcn); - $this->assertSame('App\Action\Company\All', $target->namespace); - $this->assertSame('GetCompanyAll', $target->class); - $this->assertSame('Company/All/GetCompanyAll.php', $target->relativePath); - $this->assertSame([], $target->attributes); + $this->assertSame('Get.php', $target->relativePath); + $this->assertSame('App\Action', $target->namespace); } public function testTrailingBackslashesOnTheBaseNamespaceAreIgnored(): void @@ -110,23 +118,14 @@ public function testTrailingBackslashesOnTheBaseNamespaceAreIgnored(): void $this->assertSame(['App\Action', 'GET', '/company'], $resolver->calls[0]); } - public function testClassWithNoNamespaceYieldsAnEmptyNamespace(): void - { - $resolver = new StubActionResolver('Get'); - - $target = (new Convention('', $resolver))->target('GET', '/'); - - $this->assertSame('', $target->namespace); - $this->assertSame('Get', $target->class); - } - - public function testTopLevelClassYieldsAFlatPath(): void + public function testTrailingPlaceholdersAllBecomeAttributes(): void { - $resolver = new StubActionResolver('App\Action\Get'); + $resolver = new StubActionResolver('App\Action\Company\Users\GetCompanyUsers'); - $target = (new Convention('App\Action', $resolver))->target('GET', '/'); + $target = (new Convention('App\Action', $resolver)) + ->target('GET', '/company/users/{id}/{userId}'); - $this->assertSame('Get.php', $target->relativePath); - $this->assertSame('App\Action', $target->namespace); + $this->assertSame(['id', 'userId'], $target->attributes); + $this->assertSame(['App\Action', 'GET', '/company/users'], $resolver->calls[0]); } } diff --git a/tests/Unit/Command/AboutCommandTest.php b/tests/Unit/Command/AboutCommandTest.php index 86546c2..b468bf9 100644 --- a/tests/Unit/Command/AboutCommandTest.php +++ b/tests/Unit/Command/AboutCommandTest.php @@ -42,25 +42,20 @@ protected function tearDown(): void $this->closeStreams(); } - public function testDefinitionNamesItselfAbout(): void + public function testCrestRowResolvesToARealVersion(): void { - $this->assertSame('about', (new AboutCommand())->define()->getName()); + $this->about(); + + $this->assertStringContainsString( + 'Crest ' . PackageVersion::of(Commands::PACKAGE), + $this->readStdout() + ); + $this->assertNotSame(PackageVersion::UNKNOWN, PackageVersion::of(Commands::PACKAGE)); } - public function testReportsPhalconPhpAndCrestRows(): void + public function testDefinitionNamesItselfAbout(): void { - $status = $this->about(); - - // Asserted whole rather than by substring: the row values are built by - // concatenation, and a loose assertion lets a dropped separator or a - // reordered operand through unnoticed. - $expected = 'ITEM VALUE' . PHP_EOL - . 'PHP ' . PHP_VERSION . PHP_EOL - . 'Phalcon ' . $this->expectedPhalcon() . PHP_EOL - . 'Crest ' . PackageVersion::of(Commands::PACKAGE) . PHP_EOL; - - $this->assertSame(0, $status); - $this->assertSame($expected, $this->readStdout()); + $this->assertSame('about', (new AboutCommand())->define()->getName()); } public function testPhalconRowNamesTheSourceItResolvedFrom(): void @@ -80,15 +75,20 @@ public function testPhalconRowNamesTheSourceItResolvedFrom(): void $this->assertStringContainsString(' (phalcon/phalcon)', $text); } - public function testCrestRowResolvesToARealVersion(): void + public function testReportsPhalconPhpAndCrestRows(): void { - $this->about(); + $status = $this->about(); - $this->assertStringContainsString( - 'Crest ' . PackageVersion::of(Commands::PACKAGE), - $this->readStdout() - ); - $this->assertNotSame(PackageVersion::UNKNOWN, PackageVersion::of(Commands::PACKAGE)); + // Asserted whole rather than by substring: the row values are built by + // concatenation, and a loose assertion lets a dropped separator or a + // reordered operand through unnoticed. + $expected = 'ITEM VALUE' . PHP_EOL + . 'PHP ' . PHP_VERSION . PHP_EOL + . 'Phalcon ' . $this->expectedPhalcon() . PHP_EOL + . 'Crest ' . PackageVersion::of(Commands::PACKAGE) . PHP_EOL; + + $this->assertSame(0, $status); + $this->assertSame($expected, $this->readStdout()); } private function about(): int diff --git a/tests/Unit/Command/Config/ShowCommandTest.php b/tests/Unit/Command/Config/ShowCommandTest.php index 82e91ec..c2a332a 100644 --- a/tests/Unit/Command/Config/ShowCommandTest.php +++ b/tests/Unit/Command/Config/ShowCommandTest.php @@ -44,9 +44,23 @@ protected function tearDown(): void $this->removeScratchDirectory(); } - public function testDefinitionNamesItselfConfigShow(): void + public function testADefaultPathIsNotReportedAsDeclared(): void { - $this->assertSame('config:show', (new ShowCommand())->define()->getName()); + // Declaring `views` leaves `action` on its default. Marking the whole + // block declared would say the project asked for something it did not. + file_put_contents( + $this->root . '/crest.php', + " ['views' => 'templates']];\n" + ); + + $this->runCommand(); + + // Normalized: the column width now follows the longest default key, so + // asserting the padding here would pin something this test is not about. + $output = $this->normalized(); + + $this->assertStringContainsString('action ' . $this->root . '/src/Action inferred', $output); + $this->assertStringContainsString('views ' . $this->root . '/templates declared', $output); } public function testAnInferredProjectSaysSoAndShowsWhatWasInferred(): void @@ -62,14 +76,21 @@ public function testAnInferredProjectSaysSoAndShowsWhatWasInferred(): void $this->assertStringContainsString($this->root, $output); } - public function testEveryValueIsMarkedInferredWhenThereIsNoConfigFile(): void + public function testDeclaredPathsAreListed(): void { + file_put_contents( + $this->root . '/crest.php', + " ['views' => 'templates']];\n" + ); + $this->runCommand(); $output = $this->readStdout(); - $this->assertStringContainsString('inferred', $output); - $this->assertStringNotContainsString('declared', $output); + // The declared key and the surviving default both appear, resolved to + // absolute locations. + $this->assertStringContainsString($this->root . '/templates', $output); + $this->assertStringContainsString($this->root . '/src/Action', $output); } public function testDeclaredValuesAreDistinguishedFromInferredOnes(): void @@ -89,70 +110,37 @@ public function testDeclaredValuesAreDistinguishedFromInferredOnes(): void $this->assertStringContainsString('inferred', $output); } - public function testTheConfigFileIsNamedWhenOneWasUsed(): void + public function testDefinitionNamesItselfConfigShow(): void { - file_put_contents($this->root . '/crest.php', "runCommand(); - - $this->assertStringContainsString($this->root . '/crest.php', $this->readStdout()); + $this->assertSame('config:show', (new ShowCommand())->define()->getName()); } - public function testDeclaredPathsAreListed(): void + public function testEveryValueIsMarkedInferredWhenThereIsNoConfigFile(): void { - file_put_contents( - $this->root . '/crest.php', - " ['views' => 'templates']];\n" - ); - $this->runCommand(); $output = $this->readStdout(); - // The declared key and the surviving default both appear, resolved to - // absolute locations. - $this->assertStringContainsString($this->root . '/templates', $output); - $this->assertStringContainsString($this->root . '/src/Action', $output); + $this->assertStringContainsString('inferred', $output); + $this->assertStringNotContainsString('declared', $output); } - public function testADefaultPathIsNotReportedAsDeclared(): void + public function testTheConfigFileIsNamedWhenOneWasUsed(): void { - // Declaring `views` leaves `action` on its default. Marking the whole - // block declared would say the project asked for something it did not. - file_put_contents( - $this->root . '/crest.php', - " ['views' => 'templates']];\n" - ); - - $this->runCommand(); - - $output = $this->readStdout(); - - $this->assertStringContainsString('action ' . $this->root . '/src/Action inferred', $output); - $this->assertStringContainsString('views ' . $this->root . '/templates declared', $output); - } + file_put_contents($this->root . '/crest.php', "runCommand(); - $expected = 'Source: inferred from composer.json' . PHP_EOL - . PHP_EOL - . 'ITEM VALUE ORIGIN' . PHP_EOL - . 'root ' . $this->root . ' inferred' . PHP_EOL - . 'flavor adr inferred' . PHP_EOL - . 'namespace App inferred' . PHP_EOL - . PHP_EOL - . 'PATH LOCATION ORIGIN' . PHP_EOL - . 'action ' . $this->root . '/src/Action inferred' . PHP_EOL; - - $this->assertSame($expected, $this->normalised()); + $this->assertStringContainsString($this->root . '/crest.php', $this->readStdout()); } public function testTheWholeReportIsRenderedForADeclaredProject(): void { // Paths are declared out of alphabetical order, so the listing only // reads correctly because it is sorted rather than merged-and-printed. + // + // The flavor is mvc, which has no default paths, so only the two + // declared keys appear - `action` belongs to ADR alone. file_put_contents( $this->root . '/crest.php', " 'mvc', 'namespace' => 'Shop', " @@ -169,11 +157,31 @@ public function testTheWholeReportIsRenderedForADeclaredProject(): void . 'namespace Shop declared' . PHP_EOL . PHP_EOL . 'PATH LOCATION ORIGIN' . PHP_EOL - . 'action ' . $this->root . '/src/Action inferred' . PHP_EOL . 'admin ' . $this->root . '/backend declared' . PHP_EOL . 'views ' . $this->root . '/templates declared' . PHP_EOL; - $this->assertSame($expected, $this->normalised()); + $this->assertSame($expected, $this->normalized()); + } + + public function testTheWholeReportIsRenderedForAnInferredProject(): void + { + $this->runCommand(); + + $expected = 'Source: inferred from composer.json' . PHP_EOL + . PHP_EOL + . 'ITEM VALUE ORIGIN' . PHP_EOL + . 'root ' . $this->root . ' inferred' . PHP_EOL + . 'flavor adr inferred' . PHP_EOL + . 'namespace App inferred' . PHP_EOL + . PHP_EOL + . 'PATH LOCATION ORIGIN' . PHP_EOL + . 'action ' . $this->root . '/src/Action inferred' . PHP_EOL + . 'command ' . $this->root . '/src/Command inferred' . PHP_EOL + . 'middleware ' . $this->root . '/src/Middleware inferred' . PHP_EOL + . 'provider ' . $this->root . '/src/Provider inferred' . PHP_EOL + . 'responder ' . $this->root . '/src/Responder inferred' . PHP_EOL; + + $this->assertSame($expected, $this->normalized()); } /** @@ -181,7 +189,7 @@ public function testTheWholeReportIsRenderedForADeclaredProject(): void * run, so the padding does too. Collapsing runs of spaces lets the content * be asserted exactly without asserting the width. */ - private function normalised(): string + private function normalized(): string { return (string) preg_replace('/ {2,}/', ' ', $this->readStdout()); } diff --git a/tests/Unit/Command/Container/ListCommandTest.php b/tests/Unit/Command/Container/ListCommandTest.php index 0cb83a1..e6ba602 100644 --- a/tests/Unit/Command/Container/ListCommandTest.php +++ b/tests/Unit/Command/Container/ListCommandTest.php @@ -51,80 +51,79 @@ protected function tearDown(): void $this->removeScratchDirectory(); } - public function testDefinitionNamesItselfContainerList(): void + public function testABootReturningANonObjectIsReported(): void { - $this->assertSame('container:list', (new ListCommand())->define()->getName()); - } + $this->declareFront(NonContainerFront::class); - public function testWithoutABootstrapItSaysWhatToAdd(): void - { $status = $this->runCommand(); $this->assertSame(1, $status); $this->assertStringContainsString( - "name the front controller in crest.php, e.g. 'bootstrap' => App\\Front\\ApiFront::class", + NonContainerFront::class . '::boot() did not return a container', $this->readStderr() ); } - public function testAnUnknownFrontControllerIsReported(): void + public function testABootReturningSomethingOtherThanAPhalconContainerIsReported(): void { - $this->declareFront('App\Front\NoSuchFront'); + $this->declareFront(WrongContainerFront::class); $status = $this->runCommand(); $this->assertSame(1, $status); - $this->assertStringContainsString('was not found', $this->readStderr()); + $this->assertStringContainsString('stdClass is not a Phalcon container', $this->readStderr()); } - public function testAFrontWithNoBootIsReported(): void + public function testABootThatThrowsIsReportedAsABootFailure(): void { - $this->declareFront(NoBootFront::class); + $this->declareFront(FailingFront::class); $status = $this->runCommand(); $this->assertSame(1, $status); $this->assertStringContainsString( - NoBootFront::class . ' has no boot(); without one it cannot be started ' - . 'without also serving a request', + 'the project failed to boot: no database', $this->readStderr() ); } - public function testABootReturningANonObjectIsReported(): void + public function testAFrontWithNoBootIsReported(): void { - $this->declareFront(NonContainerFront::class); + $this->declareFront(NoBootFront::class); $status = $this->runCommand(); $this->assertSame(1, $status); $this->assertStringContainsString( - NonContainerFront::class . '::boot() did not return a container', + NoBootFront::class . ' has no boot(); without one it cannot be started ' + . 'without also serving a request', $this->readStderr() ); } - public function testABootReturningSomethingOtherThanAPhalconContainerIsReported(): void + public function testAnEmptyContainerSaysSo(): void { - $this->declareFront(WrongContainerFront::class); + $this->declareFront(EmptyFront::class); $status = $this->runCommand(); - $this->assertSame(1, $status); - $this->assertStringContainsString('stdClass is not a Phalcon container', $this->readStderr()); + $this->assertSame(0, $status); + $this->assertSame('no services registered' . PHP_EOL, $this->readStdout()); } - public function testABootThatThrowsIsReportedAsABootFailure(): void + public function testAnUnknownFrontControllerIsReported(): void { - $this->declareFront(FailingFront::class); + $this->declareFront('App\Front\NoSuchFront'); $status = $this->runCommand(); $this->assertSame(1, $status); - $this->assertStringContainsString( - 'the project failed to boot: no database', - $this->readStderr() - ); + $this->assertStringContainsString('was not found', $this->readStderr()); + } + + public function testDefinitionNamesItselfContainerList(): void + { + $this->assertSame('container:list', (new ListCommand())->define()->getName()); } public function testServicesAreListedSortedWithClassAndResolvedState(): void @@ -138,17 +137,18 @@ public function testServicesAreListedSortedWithClassAndResolvedState(): void . 'zebra Phalcon\Support\HelperFactory no' . PHP_EOL; $this->assertSame(0, $status); - $this->assertSame($expected, $this->normalised()); + $this->assertSame($expected, $this->normalized()); } - public function testAnEmptyContainerSaysSo(): void + public function testWithoutABootstrapItSaysWhatToAdd(): void { - $this->declareFront(EmptyFront::class); - $status = $this->runCommand(); - $this->assertSame(0, $status); - $this->assertSame('no services registered' . PHP_EOL, $this->readStdout()); + $this->assertSame(1, $status); + $this->assertStringContainsString( + "name the front controller in crest.php, e.g. 'bootstrap' => App\\Front\\ApiFront::class", + $this->readStderr() + ); } private function declareFront(string $class): void @@ -159,7 +159,7 @@ private function declareFront(string $class): void ); } - private function normalised(): string + private function normalized(): string { return (string) preg_replace('/ {2,}/', ' ', $this->readStdout()); } diff --git a/tests/Unit/Command/Event/ListCommandTest.php b/tests/Unit/Command/Event/ListCommandTest.php index 9a52087..407545f 100644 --- a/tests/Unit/Command/Event/ListCommandTest.php +++ b/tests/Unit/Command/Event/ListCommandTest.php @@ -50,11 +50,6 @@ protected function tearDown(): void $this->removeScratchDirectory(); } - public function testDefinitionNamesItselfEventList(): void - { - $this->assertSame('event:list', (new ListCommand())->define()->getName()); - } - public function testAContainerThatRegistersNoManagerSaysSo(): void { // The container would happily autowire a fresh Manager, and reporting @@ -82,6 +77,21 @@ public function testAManagerWithNoListenersSaysSo(): void $this->assertSame('no listeners attached' . PHP_EOL, $this->readStdout()); } + public function testANonPhalconContainerIsReported(): void + { + $this->declareFront(WrongContainerFront::class); + + $status = $this->runCommand(); + + $this->assertSame(1, $status); + $this->assertStringContainsString('stdClass is not a Phalcon container', $this->readStderr()); + } + + public function testDefinitionNamesItselfEventList(): void + { + $this->assertSame('event:list', (new ListCommand())->define()->getName()); + } + public function testListenersAreListedSortedByEvent(): void { $this->declareFront(EventsFront::class); @@ -94,17 +104,7 @@ public function testListenersAreListedSortedByEvent(): void . 'zebra:fired Phalcon\Support\HelperFactory' . PHP_EOL; $this->assertSame(0, $status); - $this->assertSame($expected, $this->normalised()); - } - - public function testANonPhalconContainerIsReported(): void - { - $this->declareFront(WrongContainerFront::class); - - $status = $this->runCommand(); - - $this->assertSame(1, $status); - $this->assertStringContainsString('stdClass is not a Phalcon container', $this->readStderr()); + $this->assertSame($expected, $this->normalized()); } public function testSomethingElseRegisteredAsTheManagerIsReported(): void @@ -130,7 +130,7 @@ private function declareFront(string $class): void ); } - private function normalised(): string + private function normalized(): string { return (string) preg_replace('/ {2,}/', ' ', $this->readStdout()); } diff --git a/tests/Unit/Command/ListCommandTest.php b/tests/Unit/Command/ListCommandTest.php index 9893922..8f3192e 100644 --- a/tests/Unit/Command/ListCommandTest.php +++ b/tests/Unit/Command/ListCommandTest.php @@ -58,45 +58,18 @@ protected function tearDown(): void $this->closeStreams(); } - public function testDefinitionNamesItselfList(): void - { - $this->assertSame('list', (new ListCommand())->define()->getName()); - } - public function testBannerPrecedesTheTable(): void { $this->listCommands(); $this->assertStringStartsWith( - Commands::NAME . ' ' . PackageVersion::of(Commands::PACKAGE) . PHP_EOL . PHP_EOL + Output::MARK . ' ' . Commands::NAME . ' ' . PackageVersion::of(Commands::PACKAGE) + . PHP_EOL . PHP_EOL . 'COMMAND', $this->readStdout() ); } - public function testEveryRegisteredCommandIsListedWithItsDescription(): void - { - $status = $this->listCommands(); - - $output = $this->readStdout(); - - $this->assertSame(0, $status); - - foreach (Commands::registry()->all() as $name => $class) { - $this->assertStringContainsString($name, $output); - $this->assertStringContainsString((new $class())->define()->getDescription(), $output); - } - } - - public function testListsItselfToo(): void - { - // A command the user can run must appear in the listing, including - // this one - otherwise `list` hides the very surface it documents. - $this->listCommands(); - - $this->assertStringContainsString('list', $this->readStdout()); - } - public function testCommandsAreSortedByName(): void { $this->listCommands(); @@ -131,6 +104,34 @@ public function testContributedCommandsAreSortedInAmongTheSeededOnes(): void $this->assertLessThan(strpos($output, 'about'), strpos($output, 'aaa:first')); } + public function testDefinitionNamesItselfList(): void + { + $this->assertSame('list', (new ListCommand())->define()->getName()); + } + + public function testEveryRegisteredCommandIsListedWithItsDescription(): void + { + $status = $this->listCommands(); + + $output = $this->readStdout(); + + $this->assertSame(0, $status); + + foreach (Commands::registry()->all() as $name => $class) { + $this->assertStringContainsString($name, $output); + $this->assertStringContainsString((new $class())->define()->getDescription(), $output); + } + } + + public function testListsItselfToo(): void + { + // A command the user can run must appear in the listing, including + // this one - otherwise `list` hides the very surface it documents. + $this->listCommands(); + + $this->assertStringContainsString('list', $this->readStdout()); + } + private function listCommands(): int { $output = new Output($this->stdout, $this->stderr, false); diff --git a/tests/Unit/Command/Make/ActionCommandTest.php b/tests/Unit/Command/Make/ActionCommandTest.php index 6dae034..47a35b2 100644 --- a/tests/Unit/Command/Make/ActionCommandTest.php +++ b/tests/Unit/Command/Make/ActionCommandTest.php @@ -14,123 +14,169 @@ namespace Crest\Tests\Unit\Command\Make; use Crest\Command\Make\ActionCommand; -use Crest\Commands; +use Crest\Console\Input; use Crest\Console\Kernel; -use Crest\Console\Registry; -use Crest\Tests\Support\CapturesOutput; -use Crest\Tests\Support\ScratchDirectory; +use Crest\Console\Output; +use Crest\Generator\Stub; +use Crest\Tests\Support\ADR\StubActionResolver; +use Crest\Tests\Support\GeneratesInAScratchProject; use Phalcon\ADR\Router\Router; use PHPUnit\Framework\TestCase; -use function chdir; +use function dirname; use function file_get_contents; use function file_put_contents; -use function getcwd; +use function mkdir; final class ActionCommandTest extends TestCase { - use CapturesOutput; - use ScratchDirectory; - - private string $previousCwd = ''; + use GeneratesInAScratchProject; protected function setUp(): void { - $this->makeScratchDirectory('make-action', 'src/Action'); - $this->writeComposerJson(['App\\' => 'src/']); - $this->captureStreams(); - - // The command writes real files, and Config::discover() falls back to - // the working directory when --directory does not reach it. Under - // mutation testing that fallback is reachable, and from the repository - // root it would generate into the actual src/ tree. Running from the - // scratch directory keeps even the fallback contained. - $this->previousCwd = (string) getcwd(); - chdir($this->root); + $this->startScratchProject('make-action', 'src/Action'); } protected function tearDown(): void { - chdir($this->previousCwd); - - $this->closeStreams(); - $this->removeScratchDirectory(); + $this->endScratchProject(); } - public function testDefinitionNamesItselfMakeAction(): void + public function testActionWithNoPlaceholdersDeclaresNoParams(): void { - $this->assertSame('make:action', (new ActionCommand())->define()->getName()); + $this->runCommand(['GET', '/health']); + + $contents = (string) file_get_contents( + $this->root . '/src/Action/Health/GetHealth.php' + ); + + $this->assertStringNotContainsString('params()', $contents); } - public function testForceOverwritesAnExistingAction(): void + public function testActionWithNoPlaceholdersHasNoAccessorBlock(): void { $this->runCommand(['GET', '/health']); - file_put_contents($this->root . '/src/Action/Health/GetHealth.php', 'stale'); - $status = $this->runCommand(['GET', '/health', '--force']); + $contents = (string) file_get_contents($this->root . '/src/Action/Health/GetHealth.php'); + + $this->assertStringNotContainsString('getAttributes()', $contents); + $this->assertStringContainsString( + " {\n \$payload = Payload::success([]);", + $contents + ); + } + + public function testANamedStubIsRenderedInsteadOfTheResponderDefault(): void + { + // Resolved through the same two-level chain as everything else, so a + // project that has run stub:publish can generate from its own copy. + $this->publishStub('minimal', "runCommand(['GET', '/health', '--stub=minimal']); $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', + $this->assertSame( + "root . '/src/Action/Health/GetHealth.php') ); } - public function testPlaceholderPathGeneratesTheResourceAction(): void + public function testAnEmptyStubOptionFallsBackToTheResponderDefault(): void { - $status = $this->runCommand(['GET', '/company/{id}']); + // `--stub=` is a shell mishap, not a request for a stub called ''. It + // reads as absent, matching how optionString() treats every option. + $status = $this->runCommand(['GET', '/health', '--stub=']); $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Action/Company/GetCompany.php'); + $this->assertStringContainsString( + 'implements Action', + (string) file_get_contents($this->root . '/src/Action/Health/GetHealth.php') + ); } - public function testRefusesToOverwriteWithoutForce(): void + public function testAStubThatDoesNotExistIsReported(): void { - $this->runCommand(['GET', '/health']); - - $status = $this->runCommand(['GET', '/health']); + $status = $this->runCommand(['GET', '/health', '--stub=nope']); $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); + $this->assertStringContainsString("stub 'adr/nope' not found", $this->readStderr()); } - public function testStaticTwoSegmentPathWritesTheOperationAction(): void + public function testAttributeAccessorsAreSeparatedFromTheBodyByABlankLine(): void { - $status = $this->runCommand(['GET', '/company/all']); + $this->runCommand(['GET', '/company/users/{id}/{userId}']); - $file = $this->root . '/src/Action/Company/All/GetCompanyAll.php'; + $contents = (string) file_get_contents( + $this->root . '/src/Action/Company/Users/GetCompanyUsers.php' + ); - $this->assertSame(0, $status); - $this->assertFileExists($file); + // Exact block: one accessor per placeholder, then a single blank line + // before the body the stub already carries. + $expected = " \$id = \$request->getAttributes()->get('id');\n" + . " \$userId = \$request->getAttributes()->get('userId');\n" + . "\n" + . ' $payload = Payload::success([]);'; - $contents = (string) file_get_contents($file); + $this->assertStringContainsString($expected, $contents); + } - $this->assertStringContainsString('namespace App\Action\Company\All;', $contents); - $this->assertStringContainsString('final class GetCompanyAll implements Action', $contents); - $this->assertStringContainsString('Responder $responder', $contents); + public function testCreatedPathAndRouteAreReported(): void + { + $this->runCommand(['GET', '/company/all']); + + $output = $this->readStdout(); + + $this->assertStringContainsString( + 'Created ' . $this->root . '/src/Action/Company/All/GetCompanyAll.php', + $output + ); + $this->assertStringContainsString('Answers GET /company/all', $output); } - public function testViewResponderUsesTheViewStub(): void + public function testDefinitionNamesItselfMakeAction(): void { - $status = $this->runCommand(['GET', '/privacy', '--responder=view']); + $this->assertSame('make:action', (new ActionCommand())->define()->getName()); + } - $contents = (string) file_get_contents($this->root . '/src/Action/Privacy/GetPrivacy.php'); + public function testForceOverwritesAnExistingAction(): void + { + $this->runCommand(['GET', '/health']); + file_put_contents($this->root . '/src/Action/Health/GetHealth.php', 'stale'); + + $status = $this->runCommand(['GET', '/health', '--force']); $this->assertSame(0, $status); - $this->assertStringContainsString('ViewResponder $responder', $contents); - $this->assertStringContainsString("withTemplate('privacy/index')", $contents); + $this->assertStringNotContainsString( + 'stale', + (string) file_get_contents($this->root . '/src/Action/Health/GetHealth.php') + ); } - public function testWritesTheAttributeAccessorForPlaceholders(): void + public function testGeneratedActionIsTheOnlyClassThatAnswersItsRoute(): void { - $this->runCommand(['GET', '/company/{id}']); + // One path names exactly one class, so nothing can shadow what is + // generated. This replaces the old candidate warning, which existed + // only because the router used to try several class shapes per path. + $this->runCommand(['GET', '/company/all']); - $contents = (string) file_get_contents($this->root . '/src/Action/Company/GetCompany.php'); + $router = new Router(); + $router->setBaseNamespace('App\Action'); - $this->assertStringContainsString("\$id = \$request->getAttributes()->get('id');", $contents); + $this->assertSame( + '/company/all', + $router->pathFor('App\Action\Company\All\GetCompanyAll') + ); } - public function testAttributeAccessorsAreSeparatedFromTheBodyByABlankLine(): void + public function testMethodArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'method'", $this->readStderr()); + } + + public function testParamsAreDeclaredInPathOrder(): void { $this->runCommand(['GET', '/company/users/{id}/{userId}']); @@ -138,14 +184,29 @@ public function testAttributeAccessorsAreSeparatedFromTheBodyByABlankLine(): voi $this->root . '/src/Action/Company/Users/GetCompanyUsers.php' ); - // Exact block: one accessor per placeholder, then a single blank line - // before the body the stub already carries. - $expected = " \$id = \$request->getAttributes()->get('id');\n" - . " \$userId = \$request->getAttributes()->get('userId');\n" - . "\n" - . " \$payload = Payload::success([]);"; + // Declaration order matches path order, so the accessors and the + // constraints line up with the segments they describe. + $this->assertStringContainsString( + " 'id' => ['type' => 'string'],\n" + . " 'userId' => ['type' => 'string'],", + $contents + ); + } - $this->assertStringContainsString($expected, $contents); + public function testPathArgumentIsRequired(): void + { + $status = $this->runCommand(['GET']); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'path'", $this->readStderr()); + } + + public function testPlaceholderPathGeneratesTheResourceAction(): void + { + $status = $this->runCommand(['GET', '/company/{id}']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Action/Company/GetCompany.php'); } public function testPlaceholdersProduceAParamsDeclaration(): void @@ -171,86 +232,128 @@ public function testPlaceholdersProduceAParamsDeclaration(): void . " 'id' => ['type' => 'string'],\n" . " ];\n" . " }\n" - . "}"; + . '}'; $this->assertStringContainsString($expected, $contents); } - public function testParamsAreDeclaredInPathOrder(): void + public function testRefusesToOverwriteWithoutForce(): void { - $this->runCommand(['GET', '/company/users/{id}/{userId}']); + $this->runCommand(['GET', '/health']); - $contents = (string) file_get_contents( - $this->root . '/src/Action/Company/Users/GetCompanyUsers.php' - ); + $status = $this->runCommand(['GET', '/health']); - // Declaration order matches path order, so the accessors and the - // constraints line up with the segments they describe. - $this->assertStringContainsString( - " 'id' => ['type' => 'string'],\n" - . " 'userId' => ['type' => 'string'],", - $contents - ); + $this->assertSame(1, $status); + $this->assertStringContainsString('already exists', $this->readStderr()); } - public function testActionWithNoPlaceholdersDeclaresNoParams(): void + public function testResponderNameIsCaseInsensitive(): void { - $this->runCommand(['GET', '/health']); + $status = $this->runCommand(['GET', '/privacy', '--responder', 'VIEW']); - $contents = (string) file_get_contents( - $this->root . '/src/Action/Health/GetHealth.php' + $this->assertSame(0, $status); + $this->assertStringContainsString( + 'ViewResponder $responder', + (string) file_get_contents($this->root . '/src/Action/Privacy/GetPrivacy.php') ); - - $this->assertStringNotContainsString('params()', $contents); } - public function testViewTemplateStripsPlaceholderBraces(): void + public function testRootRouteGetsTheIndexTemplate(): void { - $this->runCommand(['GET', '/company/{id}', '--responder=view']); + $this->runCommand(['GET', '/', '--responder=view']); - $contents = (string) file_get_contents($this->root . '/src/Action/Company/GetCompany.php'); + $contents = (string) file_get_contents($this->root . '/src/Action/Get.php'); - // Braces must not survive into the template name: dropping either - // str_replace would leave 'company/{id/index' or 'company/id}/index'. - $this->assertStringContainsString("withTemplate('company/id/index')", $contents); + $this->assertStringContainsString("withTemplate('index/index')", $contents); } - public function testRootRouteGetsTheIndexTemplate(): void + public function testStaticTwoSegmentPathWritesTheOperationAction(): void { - $this->runCommand(['GET', '/', '--responder=view']); + $status = $this->runCommand(['GET', '/company/all']); - $contents = (string) file_get_contents($this->root . '/src/Action/Get.php'); + $file = $this->root . '/src/Action/Company/All/GetCompanyAll.php'; - $this->assertStringContainsString("withTemplate('index/index')", $contents); + $this->assertSame(0, $status); + $this->assertFileExists($file); + + $contents = (string) file_get_contents($file); + + $this->assertStringContainsString('namespace App\Action\Company\All;', $contents); + $this->assertStringContainsString('final class GetCompanyAll implements Action', $contents); + $this->assertStringContainsString('Responder $responder', $contents); } - public function testActionWithNoPlaceholdersHasNoAccessorBlock(): void + public function testStubAndResponderTogetherAreRejected(): void { - $this->runCommand(['GET', '/health']); + $this->publishStub('minimal', 'x'); - $contents = (string) file_get_contents($this->root . '/src/Action/Health/GetHealth.php'); + $status = $this->runCommand(['GET', '/health', '--stub=minimal', '--responder=view']); - $this->assertStringNotContainsString('getAttributes()', $contents); + $this->assertSame(1, $status); $this->assertStringContainsString( - " {\n \$payload = Payload::success([]);", - $contents + '--stub and --responder both name a stub to render; pass one or the other', + $this->readStderr() ); } - public function testPathArgumentIsRequired(): void + public function testTemplateOptionReplacesTheDerivedName(): void { - $status = $this->runCommand(['GET']); + $status = $this->runCommand( + ['GET', '/company/all', '--responder=view', '--template=shared/table'] + ); - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'path'", $this->readStderr()); + $contents = (string) file_get_contents( + $this->root . '/src/Action/Company/All/GetCompanyAll.php' + ); + + $this->assertSame(0, $status); + $this->assertStringContainsString("withTemplate('shared/table')", $contents); + $this->assertStringNotContainsString('company/all/index', $contents); + $this->assertStringContainsString(' shared/table', $this->readStdout()); } - public function testMethodArgumentIsRequired(): void + public function testTheJsonResponderReportsNoTemplate(): void { - $status = $this->runCommand([]); + $this->runCommand(['GET', '/company/all']); - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'method'", $this->readStderr()); + $this->assertStringNotContainsString('Nothing renders it yet', $this->readStdout()); + } + + public function testTheTargetClassComesFromTheResolver(): void + { + // `/company/all` would normally name App\Action\Company\All\GetCompanyAll. + // Nothing crest could derive turns it into this, so the file landing here + // proves the class name was asked for rather than computed - and the + // recorded call proves what it was asked. + $resolver = new StubActionResolver('App\Action\Totally\Elsewhere\Surprise'); + $command = new ActionCommand($resolver); + + $status = $command->handle( + new Input( + 'make:action', + $command->define() + ->merge(Kernel::globals()) + ->bind(['GET', '/company/all', '--directory', $this->root]) + ), + new Output($this->stdout, $this->stderr, false) + ); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Action/Totally/Elsewhere/Surprise.php'); + $this->assertSame([['App\Action', 'GET', '/company/all']], $resolver->calls); + } + + public function testTheViewResponderReportsTheTemplateItAsksFor(): void + { + // The action is inert until the template exists, and crest does not + // write it: Renderer::render() takes a name, so only the project's + // renderer knows where it lives. + $this->runCommand(['GET', '/company/all', '--responder=view']); + + $output = $this->readStdout(); + + $this->assertStringContainsString('Nothing renders it yet', $output); + $this->assertStringContainsString(' company/all/index', $output); } public function testUnknownResponderIsRejected(): void @@ -264,44 +367,47 @@ public function testUnknownResponderIsRejected(): void ); } - public function testResponderNameIsCaseInsensitive(): void + public function testViewResponderUsesTheViewStub(): void { - $status = $this->runCommand(['GET', '/privacy', '--responder', 'VIEW']); + $status = $this->runCommand(['GET', '/privacy', '--responder=view']); + + $contents = (string) file_get_contents($this->root . '/src/Action/Privacy/GetPrivacy.php'); $this->assertSame(0, $status); - $this->assertStringContainsString( - 'ViewResponder $responder', - (string) file_get_contents($this->root . '/src/Action/Privacy/GetPrivacy.php') - ); + $this->assertStringContainsString('ViewResponder $responder', $contents); + $this->assertStringContainsString("withTemplate('privacy/index')", $contents); } - public function testGeneratedActionIsTheOnlyClassThatAnswersItsRoute(): void + public function testViewTemplateStripsPlaceholderBraces(): void { - // One path names exactly one class, so nothing can shadow what is - // generated. This replaces the old candidate warning, which existed - // only because the router used to try several class shapes per path. - $this->runCommand(['GET', '/company/all']); + $this->runCommand(['GET', '/company/{id}', '--responder=view']); - $router = new Router(); - $router->setBaseNamespace('App\Action'); + $contents = (string) file_get_contents($this->root . '/src/Action/Company/GetCompany.php'); - $this->assertSame( - '/company/all', - $router->pathFor('App\Action\Company\All\GetCompanyAll') - ); + // Braces must not survive into the template name: dropping either + // str_replace would leave 'company/{id/index' or 'company/id}/index'. + $this->assertStringContainsString("withTemplate('company/id/index')", $contents); } - public function testCreatedPathAndRouteAreReported(): void + public function testWritesTheAttributeAccessorForPlaceholders(): void { - $this->runCommand(['GET', '/company/all']); + $this->runCommand(['GET', '/company/{id}']); - $output = $this->readStdout(); + $contents = (string) file_get_contents($this->root . '/src/Action/Company/GetCompany.php'); - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Action/Company/All/GetCompanyAll.php', - $output - ); - $this->assertStringContainsString('Answers GET /company/all', $output); + $this->assertStringContainsString("\$id = \$request->getAttributes()->get('id');", $contents); + } + + /** + * Writes a stub into the project override directory, where stub:publish puts + * them and Stub::resolve() looks first. + */ + private function publishStub(string $name, string $contents): void + { + $path = Stub::overridePath($this->root, 'adr', $name); + + mkdir(dirname($path), 0o775, true); + file_put_contents($path, $contents); } /** @@ -309,16 +415,6 @@ public function testCreatedPathAndRouteAreReported(): void */ private function runCommand(array $arguments): int { - $registry = (new Registry())->add('make:action', ActionCommand::class); - $kernel = new Kernel( - Commands::NAME, - $registry, - Commands::PACKAGE, - $this->stdout, - $this->stderr, - false - ); - - return $kernel->handle(['crest', 'make:action', ...$arguments, '--directory', $this->root]); + return $this->runProjectCommand('make:action', ActionCommand::class, $arguments); } } diff --git a/tests/Unit/Command/Make/CommandCommandTest.php b/tests/Unit/Command/Make/CommandCommandTest.php new file mode 100644 index 0000000..26dd685 --- /dev/null +++ b/tests/Unit/Command/Make/CommandCommandTest.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Make; + +use Crest\Command\Make\CommandCommand; +use Crest\Commands; +use Crest\Tests\Support\GeneratesInAScratchProject; +use PHPUnit\Framework\TestCase; + +use function file_get_contents; +use function file_put_contents; + +use const PHP_EOL; + +final class CommandCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('make-command', 'src/Command'); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testAnUnusableNameIsReported(): void + { + $status = $this->runCommand(['Admin/Greet']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'Admin/Greet' is not a usable class name", + $this->readStderr() + ); + } + + public function testCreatedPathIsReported(): void + { + $this->runCommand(['Greet']); + + $this->assertStringContainsString( + 'Created ' . $this->root . '/src/Command/GreetCommand.php', + $this->readStdout() + ); + } + + public function testDefinitionNamesItselfMakeCommand(): void + { + $this->assertSame('make:command', (new CommandCommand())->define()->getName()); + } + + public function testForceOverwritesAnExistingCommand(): void + { + $this->runCommand(['Greet']); + file_put_contents($this->root . '/src/Command/GreetCommand.php', 'stale'); + + $status = $this->runCommand(['Greet', '--force']); + + $this->assertSame(0, $status); + $this->assertStringNotContainsString( + 'stale', + (string) file_get_contents($this->root . '/src/Command/GreetCommand.php') + ); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testRefusesToOverwriteWithoutForce(): void + { + $this->runCommand(['Greet']); + + $status = $this->runCommand(['Greet']); + + $this->assertSame(1, $status); + $this->assertStringContainsString('already exists', $this->readStderr()); + } + + public function testTheBaseClassIsAliasedSoTheNameCanNeverCollide(): void + { + // `make:command Command` is the pathological case: without the alias the + // stub would emit `final class Command extends Command`, which does not + // compile. The registry name falls back to the whole class rather than + // the empty string stripping the suffix would leave. + $status = $this->runCommand(['Command']); + + $contents = (string) file_get_contents($this->root . '/src/Command/Command.php'); + + $this->assertSame(0, $status); + $this->assertStringContainsString('final class Command extends CrestCommand', $contents); + $this->assertStringContainsString("Definition::for('command',", $contents); + } + + public function testTheCommandDirectoryIsCreatedWhenItIsAbsent(): void + { + $this->safeDeleteDirectory($this->root . '/src/Command'); + + $status = $this->runCommand(['Greet']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Command/GreetCommand.php'); + } + + public function testTheExtraBlockIsPrintedWithEscapedBackslashes(): void + { + // The registry has no other way in, so the block is the deliverable. + // composer.json is JSON: the namespace separators have to arrive + // doubled or the declaration does not parse. + $this->runCommand(['Greet']); + + $expected = 'Created ' . $this->root . '/src/Command/GreetCommand.php' . PHP_EOL + . 'Nothing lists it yet. Declare it in the package composer.json:' . PHP_EOL + . PHP_EOL + . ' "extra": {' . PHP_EOL + . ' "' . Commands::KEY . '": {' . PHP_EOL + . ' "commands": {' . PHP_EOL + . ' "greet": "App\\\\Command\\\\GreetCommand"' . PHP_EOL + . ' }' . PHP_EOL + . ' }' . PHP_EOL + . ' }' . PHP_EOL; + + $this->assertSame($expected, $this->readStdout()); + } + + public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + { + $status = $this->runCommand(['GreetCommand']); + + $contents = (string) file_get_contents($this->root . '/src/Command/GreetCommand.php'); + + $this->assertSame(0, $status); + $this->assertFileDoesNotExist($this->root . '/src/Command/GreetCommandCommand.php'); + // The registry name is derived from the class minus its suffix, so + // spelling the suffix out must not leak into it as 'greetcommand'. + $this->assertStringContainsString("Definition::for('greet',", $contents); + } + + public function testTheWholeCommandIsRendered(): void + { + // Asserted whole rather than by substring: this is generated code nobody + // reviews, so a dropped use statement or a mangled signature has to fail + // here or it ships. + $status = $this->runCommand(['Greet']); + + $expected = "success('greet ran');\n" + . "\n" + . " return 0;\n" + . " }\n" + . "}\n"; + + $this->assertSame(0, $status); + $this->assertSame( + $expected, + (string) file_get_contents($this->root . '/src/Command/GreetCommand.php') + ); + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('make:command', CommandCommand::class, $arguments); + } +} diff --git a/tests/Unit/Command/Make/GuidanceContractsTest.php b/tests/Unit/Command/Make/GuidanceContractsTest.php new file mode 100644 index 0000000..2614519 --- /dev/null +++ b/tests/Unit/Command/Make/GuidanceContractsTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Make; + +use Crest\Command\Make\MiddlewareCommand; +use Crest\Command\Make\ProviderCommand; +use Crest\Console\PackageVersion; +use Crest\Tests\Support\GeneratesInAScratchProject; +use Phalcon\ADR\Front\AbstractHttpFront; +use Phalcon\ADR\Router\Router; +use Phalcon\Container\Container; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionMethod; +use ReflectionNamedType; + +use function extension_loaded; +use function sprintf; + +/** + * make:middleware and make:provider print wiring the developer copies into their + * project, and that wiring names framework members crest holds no other + * reference to. Nothing otherwise links the printed text to the API it + * describes, so a rename leaves crest confidently instructing someone to write + * something that does not work - and the failure lands on the developer, who has + * no reason to suspect the tool that told them what to type. + * + * Both halves are pinned here: the command still prints the call, and the + * framework still has the member the call names. + */ +final class GuidanceContractsTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('make-guidance', 'src/Middleware', 'src/Provider'); + + if ( + false === PackageVersion::isInstalled('phalcon/phalcon') + && false === extension_loaded('phalcon') + ) { + $this->markTestSkipped('verifying the printed wiring needs Phalcon present'); + } + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testMiddlewareGuidanceNamesARouterMethodThatExists(): void + { + $this->runProjectCommand('make:middleware', MiddlewareCommand::class, ['Auth']); + + $this->assertStringContainsString('setMiddlewareMap(', $this->readStdout()); + + $this->assertSame(['array'], $this->parameterTypes(Router::class, 'setMiddlewareMap')); + } + + public function testProviderGuidanceNamesAFrontMethodThatExists(): void + { + $this->runProjectCommand('make:provider', ProviderCommand::class, ['Cache']); + + $stdout = $this->readStdout(); + + $this->assertStringContainsString('registerProviders(', $stdout); + $this->assertStringContainsString('parent::registerProviders(', $stdout); + + $this->assertSame( + [Container::class], + $this->parameterTypes(AbstractHttpFront::class, 'registerProviders') + ); + } + + public function testTheParentCallHasAnImplementationToReach(): void + { + // "Keep the parent call: it is what registers the ADR services" holds + // only while the parent declares a concrete body of its own. Abstract, + // or declared somewhere further up, and the printed advice is wrong. + $method = new ReflectionMethod(AbstractHttpFront::class, 'registerProviders'); + + $this->assertFalse($method->isAbstract()); + $this->assertSame(AbstractHttpFront::class, $method->getDeclaringClass()->getName()); + } + + /** + * @param class-string $class + * + * @return list + */ + private function parameterTypes(string $class, string $method): array + { + $reflection = new ReflectionClass($class); + + // Checked here rather than with method_exists() at the call site: with a + // literal class and method the analyzer folds that to a constant true, + // so the assertion that was meant to catch a rename never ran. + if (false === $reflection->hasMethod($method)) { + $this->fail( + sprintf('%s no longer declares %s(), which crest prints as guidance', $class, $method) + ); + } + + $types = []; + + foreach ($reflection->getMethod($method)->getParameters() as $parameter) { + $type = $parameter->getType(); + + $types[] = $type instanceof ReflectionNamedType ? $type->getName() : (string) $type; + } + + return $types; + } +} diff --git a/tests/Unit/Command/Make/MiddlewareCommandTest.php b/tests/Unit/Command/Make/MiddlewareCommandTest.php new file mode 100644 index 0000000..8452833 --- /dev/null +++ b/tests/Unit/Command/Make/MiddlewareCommandTest.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Make; + +use Crest\Command\Make\MiddlewareCommand; +use Crest\Tests\Support\GeneratesInAScratchProject; +use PHPUnit\Framework\TestCase; + +use function file_get_contents; +use function file_put_contents; + +use const PHP_EOL; + +final class MiddlewareCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('make-middleware', 'src/Middleware'); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testAnUnusableNameIsReported(): void + { + $status = $this->runCommand(['Admin/Auth']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'Admin/Auth' is not a usable class name", + $this->readStderr() + ); + } + + public function testCreatedPathIsReported(): void + { + $this->runCommand(['Auth']); + + $this->assertStringContainsString( + 'Created ' . $this->root . '/src/Middleware/AuthMiddleware.php', + $this->readStdout() + ); + } + + public function testDefinitionNamesItselfMakeMiddleware(): void + { + $this->assertSame('make:middleware', (new MiddlewareCommand())->define()->getName()); + } + + public function testForceOverwritesAnExistingMiddleware(): void + { + $this->runCommand(['Auth']); + file_put_contents($this->root . '/src/Middleware/AuthMiddleware.php', 'stale'); + + $status = $this->runCommand(['Auth', '--force']); + + $this->assertSame(0, $status); + $this->assertStringNotContainsString( + 'stale', + (string) file_get_contents($this->root . '/src/Middleware/AuthMiddleware.php') + ); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testRefusesToOverwriteWithoutForce(): void + { + $this->runCommand(['Auth']); + + $status = $this->runCommand(['Auth']); + + $this->assertSame(1, $status); + $this->assertStringContainsString('already exists', $this->readStderr()); + } + + public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void + { + // `make:middleware Middleware` is the pathological case: the suffix is + // already there, so the class is named Middleware - and without the + // alias the stub would emit `implements Middleware` beside + // `use ...\Middleware;`, which does not compile. + $status = $this->runCommand(['Middleware']); + + $contents = (string) file_get_contents($this->root . '/src/Middleware/Middleware.php'); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + 'final class Middleware implements MiddlewareContract', + $contents + ); + } + + public function testTheMiddlewareDirectoryIsCreatedWhenItIsAbsent(): void + { + $this->safeDeleteDirectory($this->root . '/src/Middleware'); + + $status = $this->runCommand(['Auth']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Middleware/AuthMiddleware.php'); + } + + public function testTheRegistrationSnippetIsPrintedWithTheFullClassName(): void + { + // The generated class is inert until the router names it, and crest will + // not edit the bootstrap - so the hint is the whole deliverable and is + // asserted as one block. Substring checks would let the blank lines that + // separate the snippet from the prose disappear, and a wall of text is + // not something anyone pastes from. + $this->runCommand(['Auth']); + + $expected = 'Created ' . $this->root . '/src/Middleware/AuthMiddleware.php' . PHP_EOL + . "Nothing runs it yet. Add it to the router's middleware map:" . PHP_EOL + . PHP_EOL + . " \$router->setMiddlewareMap(['' => [\\App\\Middleware\\AuthMiddleware::class]]);" + . PHP_EOL + . PHP_EOL + . "The key is a namespace suffix under the base namespace: '' guards every " + . "action, '\\Album' only the actions beneath it." . PHP_EOL; + + $this->assertSame($expected, $this->readStdout()); + } + + public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + { + $status = $this->runCommand(['AuthMiddleware']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Middleware/AuthMiddleware.php'); + $this->assertFileDoesNotExist($this->root . '/src/Middleware/AuthMiddlewareMiddleware.php'); + } + + public function testTheWholeMiddlewareIsRendered(): void + { + // Asserted whole rather than by substring: this is generated code nobody + // reviews, so a dropped use statement or a mangled signature has to fail + // here or it ships. + $status = $this->runCommand(['Auth']); + + $expected = "assertSame(0, $status); + $this->assertSame( + $expected, + (string) file_get_contents($this->root . '/src/Middleware/AuthMiddleware.php') + ); + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('make:middleware', MiddlewareCommand::class, $arguments); + } +} diff --git a/tests/Unit/Command/Make/ProviderCommandTest.php b/tests/Unit/Command/Make/ProviderCommandTest.php new file mode 100644 index 0000000..2c3cc28 --- /dev/null +++ b/tests/Unit/Command/Make/ProviderCommandTest.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Make; + +use Crest\Command\Make\ProviderCommand; +use Crest\Tests\Support\GeneratesInAScratchProject; +use PHPUnit\Framework\TestCase; + +use function file_get_contents; +use function file_put_contents; + +use const PHP_EOL; + +final class ProviderCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('make-provider', 'src/Provider'); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testAnUnusableNameIsReported(): void + { + $status = $this->runCommand(['Admin/Cache']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'Admin/Cache' is not a usable class name", + $this->readStderr() + ); + } + + public function testCreatedPathIsReported(): void + { + $this->runCommand(['Cache']); + + $this->assertStringContainsString( + 'Created ' . $this->root . '/src/Provider/CacheProvider.php', + $this->readStdout() + ); + } + + public function testDefinitionNamesItselfMakeProvider(): void + { + $this->assertSame('make:provider', (new ProviderCommand())->define()->getName()); + } + + public function testForceOverwritesAnExistingProvider(): void + { + $this->runCommand(['Cache']); + file_put_contents($this->root . '/src/Provider/CacheProvider.php', 'stale'); + + $status = $this->runCommand(['Cache', '--force']); + + $this->assertSame(0, $status); + $this->assertStringNotContainsString( + 'stale', + (string) file_get_contents($this->root . '/src/Provider/CacheProvider.php') + ); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testRefusesToOverwriteWithoutForce(): void + { + $this->runCommand(['Cache']); + + $status = $this->runCommand(['Cache']); + + $this->assertSame(1, $status); + $this->assertStringContainsString('already exists', $this->readStderr()); + } + + public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void + { + // `make:provider Provider` is the pathological case: the suffix is + // already there, so the class is named Provider - and without the alias + // the stub would emit `implements Provider` beside `use ...\Provider;`, + // which does not compile. Collection is left unaliased: no artifact + // suffix can produce that name. + $status = $this->runCommand(['Provider']); + + $contents = (string) file_get_contents($this->root . '/src/Provider/Provider.php'); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + 'final class Provider implements ProviderContract', + $contents + ); + } + + public function testTheProviderDirectoryIsCreatedWhenItIsAbsent(): void + { + $this->safeDeleteDirectory($this->root . '/src/Provider'); + + $status = $this->runCommand(['Cache']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Provider/CacheProvider.php'); + } + + public function testTheRegistrationSnippetIsPrintedWithTheParentCall(): void + { + // The whole hint is the deliverable, asserted as one block: the blank + // lines make it paste-able, and the parent:: line is what keeps the ADR + // services registered. Losing either is a silent failure downstream. + $this->runCommand(['Cache']); + + $expected = 'Created ' . $this->root . '/src/Provider/CacheProvider.php' . PHP_EOL + . 'Nothing registers it yet. Call it from your front controller:' . PHP_EOL + . PHP_EOL + . ' protected function registerProviders(Container $container): void' . PHP_EOL + . ' {' . PHP_EOL + . ' parent::registerProviders($container);' . PHP_EOL + . PHP_EOL + . ' (new \App\Provider\CacheProvider())->provide($container);' . PHP_EOL + . ' }' . PHP_EOL + . PHP_EOL + . 'Keep the parent call: it is what registers the ADR services.' . PHP_EOL; + + $this->assertSame($expected, $this->readStdout()); + } + + public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + { + $status = $this->runCommand(['CacheProvider']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Provider/CacheProvider.php'); + $this->assertFileDoesNotExist($this->root . '/src/Provider/CacheProviderProvider.php'); + } + + public function testTheWholeProviderIsRendered(): void + { + // Asserted whole rather than by substring: this is generated code nobody + // reviews, so a dropped use statement or a mangled signature has to fail + // here or it ships. + $status = $this->runCommand(['Cache']); + + $expected = "set(Thing::class, Thing::class);\n" + . " // \$services->bind(ThingInterface::class, Thing::class);\n" + . " // \$services->setAlias(ThingInterface::class, 'thing');\n" + . " }\n" + . "}\n"; + + $this->assertSame(0, $status); + $this->assertSame( + $expected, + (string) file_get_contents($this->root . '/src/Provider/CacheProvider.php') + ); + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('make:provider', ProviderCommand::class, $arguments); + } +} diff --git a/tests/Unit/Command/Make/ResponderCommandTest.php b/tests/Unit/Command/Make/ResponderCommandTest.php new file mode 100644 index 0000000..1c86835 --- /dev/null +++ b/tests/Unit/Command/Make/ResponderCommandTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Make; + +use Crest\Command\Make\ResponderCommand; +use Crest\Tests\Support\GeneratesInAScratchProject; +use PHPUnit\Framework\TestCase; + +use function file_get_contents; +use function file_put_contents; + +final class ResponderCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('make-responder', 'src/Responder'); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testAnUnusableNameIsReported(): void + { + $status = $this->runCommand(['Admin/Album']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'Admin/Album' is not a usable class name", + $this->readStderr() + ); + } + + public function testCreatedPathIsReported(): void + { + $this->runCommand(['Album']); + + $this->assertStringContainsString( + 'Created ' . $this->root . '/src/Responder/AlbumResponder.php', + $this->readStdout() + ); + } + + public function testDefinitionNamesItselfMakeResponder(): void + { + $this->assertSame('make:responder', (new ResponderCommand())->define()->getName()); + } + + public function testForceOverwritesAnExistingResponder(): void + { + $this->runCommand(['Album']); + file_put_contents($this->root . '/src/Responder/AlbumResponder.php', 'stale'); + + $status = $this->runCommand(['Album', '--force']); + + $this->assertSame(0, $status); + $this->assertStringNotContainsString( + 'stale', + (string) file_get_contents($this->root . '/src/Responder/AlbumResponder.php') + ); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testRefusesToOverwriteWithoutForce(): void + { + $this->runCommand(['Album']); + + $status = $this->runCommand(['Album']); + + $this->assertSame(1, $status); + $this->assertStringContainsString('already exists', $this->readStderr()); + } + + public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void + { + // `make:responder Responder` is the pathological case: the suffix is + // already there, so the class is named Responder - and without the alias + // the stub would emit `implements Responder` beside + // `use ...\Responder;`, which does not compile. + $status = $this->runCommand(['Responder']); + + $contents = (string) file_get_contents($this->root . '/src/Responder/Responder.php'); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + 'final class Responder implements ResponderContract', + $contents + ); + } + + public function testTheResponderDirectoryIsCreatedWhenItIsAbsent(): void + { + // A project that has never had a responder has no src/Responder, and the + // default path is only a default - nothing guarantees it exists. + $this->safeDeleteDirectory($this->root . '/src/Responder'); + + $status = $this->runCommand(['Album']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Responder/AlbumResponder.php'); + } + + public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + { + $status = $this->runCommand(['AlbumResponder']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/src/Responder/AlbumResponder.php'); + $this->assertFileDoesNotExist($this->root . '/src/Responder/AlbumResponderResponder.php'); + } + + public function testTheWholeResponderIsRendered(): void + { + // Asserted whole rather than by substring: this is generated code nobody + // reviews, so a dropped use statement or a mangled signature has to fail + // here or it ships. + $status = $this->runCommand(['Album']); + + $expected = "setJsonContent(\$payload->getResult());\n" + . "\n" + . " return \$response;\n" + . " }\n" + . "}\n"; + + $this->assertSame(0, $status); + $this->assertSame( + $expected, + (string) file_get_contents($this->root . '/src/Responder/AlbumResponder.php') + ); + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('make:responder', ResponderCommand::class, $arguments); + } +} diff --git a/tests/Unit/Command/Route/ListCommandTest.php b/tests/Unit/Command/Route/ListCommandTest.php index 3338a3d..3a17592 100644 --- a/tests/Unit/Command/Route/ListCommandTest.php +++ b/tests/Unit/Command/Route/ListCommandTest.php @@ -15,8 +15,11 @@ use Crest\Command\Route\ListCommand; use Crest\Commands; +use Crest\Console\Input; use Crest\Console\Kernel; +use Crest\Console\Output; use Crest\Console\Registry; +use Crest\Tests\Support\ADR\StubActionResolver; use Crest\Tests\Support\CapturesOutput; use Crest\Tests\Support\ScratchDirectory; use PHPUnit\Framework\TestCase; @@ -24,7 +27,6 @@ use function file_put_contents; use function is_dir; use function mkdir; -use function strpos; use const PHP_EOL; @@ -46,6 +48,35 @@ protected function tearDown(): void $this->removeScratchDirectory(); } + public function testAClassTheConventionWouldNotProduceIsSkipped(): void + { + // pathFor() returns null for a name the convention could not have + // generated, so a stray helper in the action tree is not a route. + $this->writeAction('Health', 'GetHealth', 'App\Action\Health'); + $this->writeAction('Health', 'SomeHelper', 'App\Action\Health'); + + $this->runCommand(); + + $output = $this->readStdout(); + + $this->assertStringContainsString('GetHealth', $output); + $this->assertStringNotContainsString('SomeHelper', $output); + } + + public function testARootLevelActionIsListed(): void + { + // No namespace segments at all, so the verb is the entire class name - + // the edge of the derivation. + file_put_contents( + $this->root . '/src/Action/Get.php', + "runCommand(); + + $this->assertStringContainsString('GET /', $this->readStdout()); + } + public function testDefinitionNamesItselfRouteList(): void { $this->assertSame('route:list', (new ListCommand())->define()->getName()); @@ -65,23 +96,29 @@ public function testListsAStaticRoute(): void $this->assertStringContainsString('App\Action\Health\GetHealth', $output); } - public function testShowsTrailingAttributesFromParams(): void + public function testNonPhpFilesAreIgnored(): void { - // The placeholder only appears if the Action was loaded and its - // params() read - a name-only scan would print '/album/edit'. - $this->writeAction( - 'Album/Edit', - 'GetAlbumEdit', - 'App\Action\Album\Edit', - " public static function params(): array\n" - . " {\n" - . " return ['id' => ['type' => 'string']];\n" - . " }\n" - ); + $this->writeAction('Health', 'GetHealth', 'App\Action\Health'); + file_put_contents($this->root . '/src/Action/Health/notes.md', 'not an action'); + file_put_contents($this->root . '/src/Action/Health/.gitkeep', ''); $this->runCommand(); - $this->assertStringContainsString('/album/edit/{id}', $this->readStdout()); + $output = $this->readStdout(); + + $this->assertStringContainsString('/health', $output); + $this->assertStringNotContainsString('notes', $output); + } + + public function testReportsWhenThereAreNoActions(): void + { + $status = $this->runCommand(); + + $this->assertSame(0, $status); + $this->assertSame( + 'no actions found in ' . $this->root . '/src/Action' . PHP_EOL, + $this->readStdout() + ); } public function testRoutesAreSortedByPath(): void @@ -105,75 +142,84 @@ public function testRoutesAreSortedByPath(): void $this->assertSame($expected, $this->readStdout()); } - public function testTheWholeTableIsRendered(): void + public function testShowsTrailingAttributesFromParams(): void { - // Asserted whole: the header row, the column order, the verb and the - // path are each built separately, and a substring check lets any one of - // them be wrong while the others carry the assertion. - $this->writeAction('Session', 'PostSession', 'App\Action\Session'); - $this->writeAction('Health', 'GetHealth', 'App\Action\Health'); + // The placeholder only appears if the Action was loaded and its + // params() read - a name-only scan would print '/album/edit'. + $this->writeAction( + 'Album/Edit', + 'GetAlbumEdit', + 'App\Action\Album\Edit', + " public static function params(): array\n" + . " {\n" + . " return ['id' => ['type' => 'string']];\n" + . " }\n" + ); $this->runCommand(); - $expected = 'METHOD PATH ACTION' . PHP_EOL - . 'GET /health App\Action\Health\GetHealth' . PHP_EOL - . 'POST /session App\Action\Session\PostSession' . PHP_EOL; - - $this->assertSame($expected, $this->readStdout()); - } - - public function testReportsWhenThereAreNoActions(): void - { - $status = $this->runCommand(); - - $this->assertSame(0, $status); - $this->assertSame( - 'no actions found in ' . $this->root . '/src/Action' . PHP_EOL, - $this->readStdout() - ); + $this->assertStringContainsString('/album/edit/{id}', $this->readStdout()); } - public function testNonPhpFilesAreIgnored(): void + public function testTheMethodColumnIsWhateverTheResolverAnswered(): void { + // TRACE is not in the framework's verb list and no rule crest could + // hold would derive it from `GetHealth`. Only a command that asks the + // resolver can print it. + // + // Nothing weaker distinguishes the two: for any class name the + // convention would actually produce, deriving the verb locally and + // asking the framework agree, which is why the whole suite passed + // before this command stopped deriving it. $this->writeAction('Health', 'GetHealth', 'App\Action\Health'); - file_put_contents($this->root . '/src/Action/Health/notes.md', 'not an action'); - file_put_contents($this->root . '/src/Action/Health/.gitkeep', ''); - $this->runCommand(); + $command = new ListCommand(new StubActionResolver('', '/health', 'TRACE')); - $output = $this->readStdout(); + $status = $command->handle( + new Input( + 'route:list', + $command->define()->merge(Kernel::globals())->bind(['--directory', $this->root]) + ), + new Output($this->stdout, $this->stderr, false) + ); - $this->assertStringContainsString('/health', $output); - $this->assertStringNotContainsString('notes', $output); + $expected = 'METHOD PATH ACTION' . PHP_EOL + . 'TRACE /health App\Action\Health\GetHealth' . PHP_EOL; + + $this->assertSame(0, $status); + $this->assertSame($expected, $this->readStdout()); } - public function testAClassTheConventionWouldNotProduceIsSkipped(): void + public function testTheWholeTableIsRendered(): void { - // pathFor() returns null for a name the convention could not have - // generated, so a stray helper in the action tree is not a route. + // Asserted whole: the header row, the column order, the verb and the + // path are each built separately, and a substring check lets any one of + // them be wrong while the others carry the assertion. + $this->writeAction('Session', 'PostSession', 'App\Action\Session'); $this->writeAction('Health', 'GetHealth', 'App\Action\Health'); - $this->writeAction('Health', 'SomeHelper', 'App\Action\Health'); $this->runCommand(); - $output = $this->readStdout(); + $expected = 'METHOD PATH ACTION' . PHP_EOL + . 'GET /health App\Action\Health\GetHealth' . PHP_EOL + . 'POST /session App\Action\Session\PostSession' . PHP_EOL; - $this->assertStringContainsString('GetHealth', $output); - $this->assertStringNotContainsString('SomeHelper', $output); + $this->assertSame($expected, $this->readStdout()); } - public function testARootLevelActionIsListed(): void + private function runCommand(): int { - // No namespace segments at all, so the verb is the entire class name - - // the edge of the derivation. - file_put_contents( - $this->root . '/src/Action/Get.php', - "add('route:list', ListCommand::class); + $kernel = new Kernel( + Commands::NAME, + $registry, + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false ); - $this->runCommand(); - - $this->assertStringContainsString('GET /', $this->readStdout()); + return $kernel->handle(['crest', 'route:list', '--directory', $this->root]); } private function writeAction( @@ -193,19 +239,4 @@ private function writeAction( "add('route:list', ListCommand::class); - $kernel = new Kernel( - Commands::NAME, - $registry, - Commands::PACKAGE, - $this->stdout, - $this->stderr, - false - ); - - return $kernel->handle(['crest', 'route:list', '--directory', $this->root]); - } } diff --git a/tests/Unit/Command/Stub/PublishCommandTest.php b/tests/Unit/Command/Stub/PublishCommandTest.php new file mode 100644 index 0000000..ee6f109 --- /dev/null +++ b/tests/Unit/Command/Stub/PublishCommandTest.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Stub; + +use Crest\Command\Stub\PublishCommand; +use Crest\Generator\Stub; +use Crest\Paths; +use Crest\Tests\Support\GeneratesInAScratchProject; +use PHPUnit\Framework\TestCase; + +use function basename; +use function file_get_contents; +use function file_put_contents; +use function glob; + +final class PublishCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('stub-publish', 'src/Action'); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testAFlavorWithNoPackagedStubsIsReported(): void + { + // cli ships nothing yet. Publishing silently and printing no lines would + // read as success. + file_put_contents($this->root . '/crest.php', " 'cli'];\n"); + + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "no stubs are packaged for the 'cli' flavor", + $this->readStderr() + ); + } + + public function testAnAlreadyPublishedStubIsSkippedNotOverwritten(): void + { + $this->runCommand(['action']); + + $published = Stub::overridePath($this->root, 'adr', 'action'); + file_put_contents($published, 'edited'); + + $status = $this->runCommand(['action']); + + // Exit 0, not 1: nothing failed. Clobbering an edited stub is what would + // be the failure. + $this->assertSame(0, $status); + $this->assertSame('edited', (string) file_get_contents($published)); + $this->assertStringContainsString( + 'Skipped ' . $published . '; it exists already, pass --force to overwrite', + $this->readStdout() + ); + } + + public function testAPublishedStubIsAByteForByteCopy(): void + { + $this->runCommand(['action']); + + $this->assertSame( + (string) file_get_contents(Paths::stubs() . '/adr/action.stub'), + (string) file_get_contents( + Stub::overridePath($this->root, 'adr', 'action') + ) + ); + } + + public function testAPublishedStubLandsWhereResolutionLooksForIt(): void + { + // The point of the whole command: an edited copy has to win. If publish + // and resolve ever disagreed about the layout this would still pass file + // checks while doing nothing useful. + $this->runCommand(['action']); + + $published = Stub::overridePath($this->root, 'adr', 'action'); + file_put_contents($published, 'edited'); + + $stub = new Stub(Paths::stubs(), $this->root); + + $this->assertSame($published, $stub->resolve('adr', 'action')); + $this->assertSame('edited', $stub->render('adr', 'action', [])); + } + + public function testASingleStubMayBePublishedOnItsOwn(): void + { + $status = $this->runCommand(['action']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'action')); + $this->assertFileDoesNotExist(Stub::overridePath($this->root, 'adr', 'responder')); + } + + public function testAStubNameThatIsAPathIsRejected(): void + { + // Without the guard this resolves through is_file() and copies a file in + // from outside the package. No privilege is crossed - it is the + // developer's own machine - but "is not packaged" would be a lie about + // what went wrong. + $status = $this->runCommand(['../../../etc/passwd']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'../../../etc/passwd' is not a stub name", + $this->readStderr() + ); + } + + public function testAStubThatIsNotPackagedIsReported(): void + { + $status = $this->runCommand(['nope']); + + $this->assertSame(1, $status); + $this->assertStringContainsString("stub 'adr/nope' is not packaged", $this->readStderr()); + } + + public function testDefinitionNamesItselfStubPublish(): void + { + $this->assertSame('stub:publish', (new PublishCommand())->define()->getName()); + } + + public function testEveryPackagedAdrStubIsPublished(): void + { + // Asserted against the packaged directory rather than a hardcoded list, + // so a stub added later is covered without touching this test - and a + // stub that stops being published fails it. + $status = $this->runCommand([]); + + $this->assertSame(0, $status); + + foreach ($this->packagedStubs() as $name) { + $this->assertFileExists( + Stub::overridePath($this->root, 'adr', $name) + ); + } + } + + public function testForceOverwritesAPublishedStub(): void + { + $this->runCommand(['action']); + + $published = Stub::overridePath($this->root, 'adr', 'action'); + file_put_contents($published, 'edited'); + + $status = $this->runCommand(['action', '--force']); + + $this->assertSame(0, $status); + $this->assertSame( + (string) file_get_contents(Paths::stubs() . '/adr/action.stub'), + (string) file_get_contents($published) + ); + } + + public function testPublishedPathsAreReported(): void + { + $this->runCommand(['action']); + + $this->assertStringContainsString( + 'Published ' . Stub::overridePath($this->root, 'adr', 'action'), + $this->readStdout() + ); + } + + public function testPublishingContinuesPastAStubTheProjectAlreadyHas(): void + { + // A skipped stub must not end the run. `action-view` sorts before + // `action`, so with the loop breaking instead of continuing everything + // from `command` onwards would silently never be published. + $this->runCommand(['action']); + + $this->runCommand([]); + + foreach ($this->packagedStubs() as $name) { + $this->assertFileExists( + Stub::overridePath($this->root, 'adr', $name) + ); + } + } + + /** + * @return list + */ + private function packagedStubs(): array + { + $names = []; + + foreach (glob(Paths::stubs() . '/adr/*.stub') ?: [] as $path) { + $names[] = basename($path, '.stub'); + } + + return $names; + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('stub:publish', PublishCommand::class, $arguments); + } +} diff --git a/tests/Unit/CommandsTest.php b/tests/Unit/CommandsTest.php index 1716c10..6ebe3f4 100644 --- a/tests/Unit/CommandsTest.php +++ b/tests/Unit/CommandsTest.php @@ -21,6 +21,26 @@ final class CommandsTest extends TestCase { + public function testAliasesAreNotListedAsCommands(): void + { + $this->assertSame( + [ + 'about', + 'config:show', + 'container:list', + 'event:list', + 'list', + 'make:action', + 'make:command', + 'make:middleware', + 'make:provider', + 'make:responder', + 'route:list', + 'stub:publish', + ], + array_keys(Commands::registry()->all()) + ); + } public function testEveryRegisteredCommandNamesItselfConsistently(): void { foreach (Commands::registry()->all() as $name => $class) { @@ -63,20 +83,4 @@ public function testRegistryResolvesTheListAliases(): void $this->assertTrue($registry->has('commands')); $this->assertTrue($registry->has('enumerate')); } - - public function testAliasesAreNotListedAsCommands(): void - { - $this->assertSame( - [ - 'about', - 'config:show', - 'container:list', - 'event:list', - 'list', - 'make:action', - 'route:list', - ], - array_keys(Commands::registry()->all()) - ); - } } diff --git a/tests/Unit/Console/InputTest.php b/tests/Unit/Console/InputTest.php index 314ea15..a637b63 100644 --- a/tests/Unit/Console/InputTest.php +++ b/tests/Unit/Console/InputTest.php @@ -29,17 +29,6 @@ public function testArgumentStringReturnsABoundValue(): void $this->assertSame('GET', $this->input()->argumentString('method')); } - public function testNonStringValuesDoNotLeakThroughTheStringAccessors(): void - { - // A list option is a list, not a string. The typed accessor - // reports absence rather than handing back something uncastable. - $input = $this->input(); - - $this->assertSame('', $input->optionString('fields')); - $this->assertNull($input->optionStringOrNull('fields')); - $this->assertSame(['id', 'name'], $input->option('fields')); - } - public function testCommandNameIsExposed(): void { $this->assertSame('make:action', $this->input()->command); @@ -53,6 +42,17 @@ public function testHasOptionDelegatesToWhatWasSupplied(): void $this->assertFalse($input->hasOption('force')); } + public function testNonStringValuesDoNotLeakThroughTheStringAccessors(): void + { + // A list option is a list, not a string. The typed accessor + // reports absence rather than handing back something uncastable. + $input = $this->input(); + + $this->assertSame('', $input->optionString('fields')); + $this->assertNull($input->optionStringOrNull('fields')); + $this->assertSame(['id', 'name'], $input->option('fields')); + } + public function testOptionAndArgumentReturnRawValues(): void { $input = $this->input(); diff --git a/tests/Unit/Console/IsolationTest.php b/tests/Unit/Console/IsolationTest.php index 667eb14..3eeccba 100644 --- a/tests/Unit/Console/IsolationTest.php +++ b/tests/Unit/Console/IsolationTest.php @@ -26,12 +26,11 @@ use function str_starts_with; /** - * Two boundaries, both of which turn into package boundaries later: + * Two boundaries that keep the console core independent of the tool built on it: * - * - Crest\Console must be extractable to phalcon/console. That holds only - * while nothing under it names crest or reaches into the rest of the tree. - * - Crest\Console\Parsing must be extractable to phalcon/cli-options-parser, - * which holds only while it never reaches back into Crest\Console. + * - Nothing under Crest\Console may name crest or reach into the rest of the + * tree. + * - Crest\Console\Parsing may never reach back into Crest\Console. */ final class IsolationTest extends TestCase { @@ -42,6 +41,12 @@ final class IsolationTest extends TestCase 'Crest\\Generator', 'crest', 'Crest ', + // Matching is case-sensitive and has to stay that way - every file here + // declares `namespace Crest\Console`. So the shouted form is listed + // separately: a constant named CREST slipped past the lowercase needle + // once, and naming a tool from inside this cluster is the one thing + // these tests exist to prevent. + 'CREST', ]; /** @@ -60,6 +65,27 @@ public static function parsingFiles(): iterable yield from self::filesUnder(dirname(__DIR__, 3) . '/src/Console/Parsing'); } + /** + * @return iterable + */ + private static function filesUnder(string $root): iterable + { + // SKIP_DOTS is not optional: without it the iterator descends into '.' + // and never terminates. + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS) + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ('php' !== $file->getExtension()) { + continue; + } + + yield $file->getPathname() => [$file->getPathname()]; + } + } + /** * @dataProvider consoleFiles */ @@ -77,9 +103,8 @@ public function testConsoleFileCarriesNoCrestIdentity(string $path): void } /** - * The parsing cluster becomes its own package first, so the arrow only - * ever points application -> parsing. Any `use Crest\Console\X` that is not - * itself under Parsing reverses it. + * The arrow only ever points application -> parsing. Any + * `use Crest\Console\X` that is not itself under Parsing reverses it. * * @dataProvider parsingFiles */ @@ -104,25 +129,4 @@ public function testParsingFileNeverReachesIntoTheApplicationCluster(string $pat sprintf('%s may not depend on the application cluster', $path) ); } - - /** - * @return iterable - */ - private static function filesUnder(string $root): iterable - { - // SKIP_DOTS is not optional: without it the iterator descends into '.' - // and never terminates. - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS) - ); - - /** @var SplFileInfo $file */ - foreach ($iterator as $file) { - if ('php' !== $file->getExtension()) { - continue; - } - - yield $file->getPathname() => [$file->getPathname()]; - } - } } diff --git a/tests/Unit/Console/KernelTest.php b/tests/Unit/Console/KernelTest.php index c863e0a..5f3c553 100644 --- a/tests/Unit/Console/KernelTest.php +++ b/tests/Unit/Console/KernelTest.php @@ -14,6 +14,7 @@ namespace Crest\Tests\Unit\Console; use Crest\Console\Kernel; +use Crest\Console\Output; use Crest\Console\PackageVersion; use Crest\Console\Registry; use Crest\Tests\Support\CapturesOutput; @@ -21,6 +22,7 @@ use Crest\Tests\Support\Console\ThrowingCommand; use PHPUnit\Framework\TestCase; +use function preg_quote; use function strpos; use const PHP_EOL; @@ -48,87 +50,66 @@ public function testBindingErrorIsAOneLineStderrMessage(): void $this->assertStringNotContainsString('#0', $this->readStderr()); } - public function testGlobalOptionsAreAvailableToEveryCommand(): void - { - $status = $this->kernel()->handle(['demo', 'fake', '--directory', '/srv']); - - $this->assertSame(0, $status); - } - - public function testHelpForACommandRendersItsUsage(): void + public function testDoubleDashShieldsALaterHelpFlagFromTheKernel(): void { - $status = $this->kernel()->handle(['demo', 'fake', '--help']); - - $output = $this->readStdout(); + // `--` makes everything after it literal, so a positional of '--help' + // must reach the command instead of printing usage. + $status = $this->kernel()->handle(['demo', 'fake', '--', '--help']); $this->assertSame(0, $status); - $this->assertStringContainsString('A command that exists only for tests', $output); - $this->assertStringContainsString('Usage: demo fake', $output); - $this->assertStringContainsString('--directory', $output); - $this->assertStringNotContainsString('hello', $output); + $this->assertSame('hello --help' . PHP_EOL, $this->readStdout()); } - public function testKnownCommandRuns(): void + public function testDoubleDashShieldsALaterTraceFlagFromTheKernel(): void { - $status = $this->kernel()->handle(['demo', 'fake', 'phalcon']); + // Same rule for --trace: after `--` it is a value, so the failure below + // must still render as a single line with no stack frames. + $status = $this->kernel()->handle(['demo', 'fake', 'a', 'b', '--', '--trace']); - $this->assertSame(0, $status); - $this->assertSame('hello phalcon' . PHP_EOL, $this->readStdout()); + $this->assertSame(1, $status); + $this->assertStringNotContainsString('#0', $this->readStderr()); } - public function testNoArgumentsListsCommands(): void + public function testGlobalOptionsAreAvailableToEveryCommand(): void { - $status = $this->kernel()->handle(['demo']); + $status = $this->kernel()->handle(['demo', 'fake', '--directory', '/srv']); $this->assertSame(0, $status); - $this->assertStringContainsString('fake', $this->readStdout()); } - public function testToolNameIsNeverHardcoded(): void + public function testGlobalsAreDeclaredOnceAndPubliclyReadable(): void { - // The whole point of the constructor argument: nothing in the console - // core may say "crest". If this fails, the extraction is broken. - $this->kernel()->handle(['demo', 'nope']); - $this->kernel()->handle(['demo', '--version']); + // Public because a tool embedding the kernel needs to document the + // options it inherits without re-declaring them. + $globals = Kernel::globals(); - $this->assertStringNotContainsStringIgnoringCase('crest', $this->readStdout()); - $this->assertStringNotContainsStringIgnoringCase('crest', $this->readStderr()); + $this->assertSame('', $globals->getName()); + $this->assertNotNull($globals->findOption('config')); + $this->assertNotNull($globals->findOption('directory')); + $this->assertNotNull($globals->findOption('trace')); + $this->assertSame('help', $globals->findOption('h')?->name); + $this->assertSame('quiet', $globals->findOption('q')?->name); } - public function testUnknownCommandExitsOneWithStderr(): void + public function testHelpForACommandRendersItsUsage(): void { - $status = $this->kernel()->handle(['demo', 'nope']); - - $this->assertSame(1, $status); - $this->assertStringContainsString("demo: unknown command 'nope'", $this->readStderr()); - } + $status = $this->kernel()->handle(['demo', 'fake', '--help']); - public function testVersionIsHandledBeforeCommandResolution(): void - { - $status = $this->kernel()->handle(['demo', '--version']); + $output = $this->readStdout(); $this->assertSame(0, $status); - $this->assertStringContainsString('demo', $this->readStdout()); + $this->assertStringContainsString('A command that exists only for tests', $output); + $this->assertStringContainsString('Usage: demo fake', $output); + $this->assertStringContainsString('--directory', $output); + $this->assertStringNotContainsString('hello', $output); } - public function testDoubleDashShieldsALaterHelpFlagFromTheKernel(): void + public function testKnownCommandRuns(): void { - // `--` makes everything after it literal, so a positional of '--help' - // must reach the command instead of printing usage. - $status = $this->kernel()->handle(['demo', 'fake', '--', '--help']); + $status = $this->kernel()->handle(['demo', 'fake', 'phalcon']); $this->assertSame(0, $status); - $this->assertSame('hello --help' . PHP_EOL, $this->readStdout()); - } - - public function testDoubleDashShieldsALaterTraceFlagFromTheKernel(): void - { - // Same rule for --trace: after `--` it is a value, so the failure below - // must still render as a single line with no stack frames. - $status = $this->kernel()->handle(['demo', 'fake', 'a', 'b', '--', '--trace']); - - $this->assertSame(1, $status); - $this->assertStringNotContainsString('#0', $this->readStderr()); + $this->assertSame('hello phalcon' . PHP_EOL, $this->readStdout()); } public function testListCommandsIsSortedByName(): void @@ -146,20 +127,6 @@ public function testListCommandsIsSortedByName(): void $this->assertLessThan(strpos($output, 'zebra'), strpos($output, 'alpha')); } - public function testGlobalsAreDeclaredOnceAndPubliclyReadable(): void - { - // Public because a tool embedding the kernel needs to document the - // options it inherits without re-declaring them. - $globals = Kernel::globals(); - - $this->assertSame('', $globals->getName()); - $this->assertNotNull($globals->findOption('config')); - $this->assertNotNull($globals->findOption('directory')); - $this->assertNotNull($globals->findOption('trace')); - $this->assertSame('help', $globals->findOption('h')?->name); - $this->assertSame('quiet', $globals->findOption('q')?->name); - } - public function testListingIsBannerBlankLineThenTable(): void { $this->kernel()->handle(['demo']); @@ -167,7 +134,7 @@ public function testListingIsBannerBlankLineThenTable(): void // Asserted whole: the banner is concatenated and the blank line and // header row are each a separate call, so substring checks let a // dropped separator or a missing row through. - $expected = 'demo ' . PackageVersion::of('phalcon/crest') . PHP_EOL + $expected = Output::MARK . ' demo ' . PackageVersion::of('phalcon/crest') . PHP_EOL . PHP_EOL . 'COMMAND DESCRIPTION' . PHP_EOL . 'fake A command that exists only for tests' . PHP_EOL; @@ -175,6 +142,14 @@ public function testListingIsBannerBlankLineThenTable(): void $this->assertSame($expected, $this->readStdout()); } + public function testNoArgumentsListsCommands(): void + { + $status = $this->kernel()->handle(['demo']); + + $this->assertSame(0, $status); + $this->assertStringContainsString('fake', $this->readStdout()); + } + public function testShortHelpFlagAlsoRendersUsage(): void { $status = $this->kernel()->handle(['demo', 'fake', '-h']); @@ -188,7 +163,18 @@ public function testShortVersionFlagMatchesTheLongOne(): void $status = $this->kernel()->handle(['demo', '-V']); $this->assertSame(0, $status); - $this->assertStringStartsWith('demo ', $this->readStdout()); + $this->assertStringStartsWith(Output::MARK . ' demo ', $this->readStdout()); + } + + public function testToolNameIsNeverHardcoded(): void + { + // The whole point of the constructor argument: nothing in the console + // core may say "crest". If this fails, the extraction is broken. + $this->kernel()->handle(['demo', 'nope']); + $this->kernel()->handle(['demo', '--version']); + + $this->assertStringNotContainsStringIgnoringCase('crest', $this->readStdout()); + $this->assertStringNotContainsStringIgnoringCase('crest', $this->readStderr()); } public function testTraceAddsStackFramesToABindingError(): void @@ -199,6 +185,14 @@ public function testTraceAddsStackFramesToABindingError(): void $this->assertStringContainsString('#0', $this->readStderr()); } + public function testUnexpectedThrowableHonorsTrace(): void + { + $status = $this->throwingKernel()->handle(['demo', 'boom', '--trace']); + + $this->assertSame(1, $status); + $this->assertStringContainsString('#0', $this->readStderr()); + } + public function testUnexpectedThrowableIsAlsoOneCleanLine(): void { $status = $this->throwingKernel()->handle(['demo', 'boom']); @@ -208,23 +202,35 @@ public function testUnexpectedThrowableIsAlsoOneCleanLine(): void $this->assertStringNotContainsString('#0', $this->readStderr()); } - public function testUnexpectedThrowableHonoursTrace(): void + public function testUnknownCommandExitsOneWithStderr(): void { - $status = $this->throwingKernel()->handle(['demo', 'boom', '--trace']); + $status = $this->kernel()->handle(['demo', 'nope']); $this->assertSame(1, $status); - $this->assertStringContainsString('#0', $this->readStderr()); + $this->assertStringContainsString("demo: unknown command 'nope'", $this->readStderr()); + } + + public function testVersionIsHandledBeforeCommandResolution(): void + { + $status = $this->kernel()->handle(['demo', '--version']); + + $this->assertSame(0, $status); + $this->assertStringContainsString('demo', $this->readStdout()); } public function testVersionLineCarriesAResolvedVersion(): void { $this->kernel()->handle(['demo', '--version']); - // 'demo ' plus something - an empty version would mean the package - // lookup silently failed. - $this->assertMatchesRegularExpression('/^demo \S+/', $this->readStdout()); + // The mark, then 'demo ' plus something - an empty version would mean + // the package lookup silently failed. + $this->assertMatchesRegularExpression( + '/^' . preg_quote(Output::MARK, '/') . ' demo \S+/', + $this->readStdout() + ); } + private function kernel(): Kernel { $registry = (new Registry())->add('fake', FakeCommand::class); diff --git a/tests/Unit/Console/OutputTest.php b/tests/Unit/Console/OutputTest.php index 9b41eac..910643e 100644 --- a/tests/Unit/Console/OutputTest.php +++ b/tests/Unit/Console/OutputTest.php @@ -34,6 +34,30 @@ protected function tearDown(): void $this->closeStreams(); } + public function testBannerColorsOnlyTheMarkWhenDecorated(): void + { + // The text after the mark is the caller's, and stays uncolored. + $output = new Output($this->stdout, $this->stderr, true); + + $output->banner('demo 1.2.3'); + + $this->assertSame( + "\033[38;5;208m" . Output::MARK . "\033[0m" . ' demo 1.2.3' . PHP_EOL, + $this->readStdout() + ); + } + + public function testBannerPrintsTheMarkThenTheTextUndecorated(): void + { + // Undecorated keeps the glyph and drops the escapes: a piped run should + // still read as a banner, just without color. + $output = new Output($this->stdout, $this->stderr, false); + + $output->banner('demo 1.2.3'); + + $this->assertSame(Output::MARK . ' demo 1.2.3' . PHP_EOL, $this->readStdout()); + } + public function testErrorGoesToStderrNotStdout(): void { $output = new Output($this->stdout, $this->stderr, false); @@ -62,6 +86,15 @@ public function testLineWithNoArgumentWritesOnlyANewline(): void $this->assertSame(PHP_EOL, $this->readStdout()); } + public function testLineWritesToStdoutWithNewline(): void + { + $output = new Output($this->stdout, $this->stderr, false); + + $output->line('hello'); + + $this->assertSame('hello' . PHP_EOL, $this->readStdout()); + } + public function testNoColorEnvironmentVariableDisablesDecoration(): void { putenv('NO_COLOR=1'); @@ -78,28 +111,6 @@ public function testNoColorEnvironmentVariableDisablesDecoration(): void } } - public function testUndecoratedIsDetectedForANonTtyStream(): void - { - // php://memory is never a tty, so auto-detection must settle on - // undecorated once NO_COLOR is out of the way. - putenv('NO_COLOR'); - - $output = new Output($this->stdout, $this->stderr); - - $output->success('done'); - - $this->assertSame('done' . PHP_EOL, $this->readStdout()); - } - - public function testLineWritesToStdoutWithNewline(): void - { - $output = new Output($this->stdout, $this->stderr, false); - - $output->line('hello'); - - $this->assertSame('hello' . PHP_EOL, $this->readStdout()); - } - public function testSuccessIsUndecoratedWhenDecorationIsOff(): void { $output = new Output($this->stdout, $this->stderr, false); @@ -118,17 +129,15 @@ public function testSuccessWrapsInGreenWhenDecorated(): void $this->assertSame("\033[32mdone\033[0m" . PHP_EOL, $this->readStdout()); } - public function testTablePadsColumnsToTheWidestCell(): void + public function testTableGivesAnEntirelyEmptyColumnNoWidth(): void { $output = new Output($this->stdout, $this->stderr, false); - $output->table(['NAME', 'VALUE'], [['php', '8.4.1'], ['label', 'dev']]); - - $expected = 'NAME VALUE' . PHP_EOL - . 'php 8.4.1' . PHP_EOL - . 'label dev' . PHP_EOL; + // Column 0 is empty in both the header and the row, so it must occupy + // no characters at all - only the separator remains before 'x'. + $output->table(['', '', ''], [['', 'x', 'y']], false); - $this->assertSame($expected, $this->readStdout()); + $this->assertSame(' x y' . PHP_EOL, $this->readStdout()); } public function testTableMeasuresMultibyteCellsByCharacterNotByte(): void @@ -146,6 +155,19 @@ public function testTableMeasuresMultibyteCellsByCharacterNotByte(): void $this->assertSame($expected, $this->readStdout()); } + public function testTablePadsColumnsToTheWidestCell(): void + { + $output = new Output($this->stdout, $this->stderr, false); + + $output->table(['NAME', 'VALUE'], [['php', '8.4.1'], ['label', 'dev']]); + + $expected = 'NAME VALUE' . PHP_EOL + . 'php 8.4.1' . PHP_EOL + . 'label dev' . PHP_EOL; + + $this->assertSame($expected, $this->readStdout()); + } + public function testTableSuppressesTheHeaderRowButKeepsItsWidths(): void { $output = new Output($this->stdout, $this->stderr, false); @@ -158,25 +180,27 @@ public function testTableSuppressesTheHeaderRowButKeepsItsWidths(): void $this->assertSame('a b' . PHP_EOL, $this->readStdout()); } - public function testTableGivesAnEntirelyEmptyColumnNoWidth(): void + public function testTableTrimsTrailingPaddingFromTheLastColumn(): void { $output = new Output($this->stdout, $this->stderr, false); - // Column 0 is empty in both the header and the row, so it must occupy - // no characters at all - only the separator remains before 'x'. - $output->table(['', '', ''], [['', 'x', 'y']], false); + $output->table(['A', 'B'], [['x', 'longer'], ['y', 'z']]); - $this->assertSame(' x y' . PHP_EOL, $this->readStdout()); + // 'z' must not be padded out to 'longer' width - the row is rtrimmed. + $this->assertStringEndsWith('y z' . PHP_EOL, $this->readStdout()); } - public function testTableTrimsTrailingPaddingFromTheLastColumn(): void + public function testUndecoratedIsDetectedForANonTtyStream(): void { - $output = new Output($this->stdout, $this->stderr, false); + // php://memory is never a tty, so auto-detection must settle on + // undecorated once NO_COLOR is out of the way. + putenv('NO_COLOR'); - $output->table(['A', 'B'], [['x', 'longer'], ['y', 'z']]); + $output = new Output($this->stdout, $this->stderr); - // 'z' must not be padded out to 'longer' width - the row is rtrimmed. - $this->assertStringEndsWith('y z' . PHP_EOL, $this->readStdout()); + $output->success('done'); + + $this->assertSame('done' . PHP_EOL, $this->readStdout()); } public function testUsageBracketsRequiredAndOptionalArgumentsDifferently(): void diff --git a/tests/Unit/Console/Parsing/BindTest.php b/tests/Unit/Console/Parsing/BindTest.php index 7868cc0..54e8e36 100644 --- a/tests/Unit/Console/Parsing/BindTest.php +++ b/tests/Unit/Console/Parsing/BindTest.php @@ -19,6 +19,30 @@ final class BindTest extends TestCase { + public function testALoneDashIsAPositionalNotAnOption(): void + { + // Conventionally '-' means stdin; it is one character, so the short + // option branch must not claim it. + $definition = Definition::for('cat')->argument('file', false); + + $this->assertSame('-', $definition->bind(['-'])->argument('file')); + } + + public function testAnAttachedValueStopsTheOptionConsumingTheNextToken(): void + { + $bound = $this->definition()->bind(['--responder=view', 'GET', '/health']); + + $this->assertSame('view', $bound->option('responder')); + $this->assertSame('GET', $bound->argument('method')); + $this->assertSame('/health', $bound->argument('path')); + } + + public function testAttachedValueMayItselfContainAnEqualsSign(): void + { + $bound = $this->definition()->bind(['GET', '/health', '--responder=a=b']); + + $this->assertSame('a=b', $bound->option('responder')); + } public function testDoubleDashSendsEverythingAfterItToArguments(): void { $bound = $this->definition()->bind(['GET', '--', '--force']); @@ -28,6 +52,14 @@ public function testDoubleDashSendsEverythingAfterItToArguments(): void $this->assertFalse($bound->option('force')); } + public function testEveryTokenAfterTheDoubleDashIsKept(): void + { + $bound = $this->definition()->bind(['--', 'GET', '/health']); + + $this->assertSame('GET', $bound->argument('method')); + $this->assertSame('/health', $bound->argument('path')); + } + public function testFlagBeforeAPositionalDoesNotEatIt(): void { // The Cop regression this whole layer exists to prevent. @@ -38,6 +70,14 @@ public function testFlagBeforeAPositionalDoesNotEatIt(): void $this->assertSame('/company/all', $bound->argument('path')); } + public function testFlagWithAnAttachedValueIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("option '--force' takes no value"); + + $this->definition()->bind(['GET', '/health', '--force=yes']); + } + public function testListOptionSplitsOnCommas(): void { $definition = Definition::for('make:model')->option('fields=l', 'Fields', []); @@ -55,6 +95,34 @@ public function testMissingRequiredArgumentThrows(): void $this->definition()->bind(['GET']); } + public function testOnlyTheFinalLetterOfAClusterConsumesTheNextToken(): void + { + $definition = Definition::for('serve') + ->option('force|f', 'Force', false) + ->option('output|o=s', 'Output', ''); + + $bound = $definition->bind(['-fo', 'build']); + + // 'build' belongs to -o, the last letter; -f stays a bare flag. + $this->assertTrue($bound->option('force')); + $this->assertSame('build', $bound->option('output')); + } + + public function testOptionalArgumentFallsBackToItsDeclaredDefault(): void + { + $definition = Definition::for('greet') + ->argument('subject', false, 'Who', 'world'); + + $this->assertSame('world', $definition->bind([])->argument('subject')); + } + + public function testOptionalValueOptionFallsBackToItsDefaultWhenBare(): void + { + $definition = Definition::for('make:action')->option('output=s?', 'Output', 'stdout'); + + $this->assertSame('stdout', $definition->bind(['--output'])->option('output')); + } + public function testOptionDefaultsAreAppliedWhenAbsent(): void { $bound = $this->definition()->bind(['GET', '/health']); @@ -63,6 +131,20 @@ public function testOptionDefaultsAreAppliedWhenAbsent(): void $this->assertSame('json', $bound->option('responder')); } + public function testParsingContinuesAfterAShortOptionCluster(): void + { + $definition = Definition::for('make:action') + ->argument('method', true) + ->argument('path', true) + ->option('force|f', 'Overwrite', false); + + $bound = $definition->bind(['-f', 'GET', '/health']); + + $this->assertTrue($bound->option('force')); + $this->assertSame('GET', $bound->argument('method')); + $this->assertSame('/health', $bound->argument('path')); + } + public function testRequiredValueOptionWithNoValueThrows(): void { $this->expectException(Exception::class); @@ -83,6 +165,14 @@ public function testShortFlagsClusterIntoSeparateBooleans(): void $this->assertTrue($bound->option('quiet')); } + public function testSuppliedOptionsAreDistinguishedFromDefaulted(): void + { + $bound = $this->definition()->bind(['GET', '/health', '--force']); + + $this->assertTrue($bound->hasOption('force')); + $this->assertFalse($bound->hasOption('responder')); + } + public function testTooManyArgumentsThrows(): void { $this->expectException(Exception::class); @@ -99,6 +189,14 @@ public function testUnknownOptionThrows(): void $this->definition()->bind(['GET', '/health', '--frce']); } + public function testUnknownShortOptionThrows(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("unknown option '-z'"); + + $this->definition()->bind(['GET', '/health', '-z']); + } + public function testValueMayBeAttachedWithAnEqualsSign(): void { $bound = $this->definition()->bind(['GET', '/health', '--responder=view']); @@ -113,42 +211,6 @@ public function testValueMayBeTheFollowingToken(): void $this->assertSame('view', $bound->option('responder')); } - public function testFlagWithAnAttachedValueIsRejected(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage("option '--force' takes no value"); - - $this->definition()->bind(['GET', '/health', '--force=yes']); - } - - public function testOptionalValueOptionFallsBackToItsDefaultWhenBare(): void - { - $definition = Definition::for('make:action')->option('output=s?', 'Output', 'stdout'); - - $this->assertSame('stdout', $definition->bind(['--output'])->option('output')); - } - - public function testOnlyTheFinalLetterOfAClusterConsumesTheNextToken(): void - { - $definition = Definition::for('serve') - ->option('force|f', 'Force', false) - ->option('output|o=s', 'Output', ''); - - $bound = $definition->bind(['-fo', 'build']); - - // 'build' belongs to -o, the last letter; -f stays a bare flag. - $this->assertTrue($bound->option('force')); - $this->assertSame('build', $bound->option('output')); - } - - public function testUnknownShortOptionThrows(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage("unknown option '-z'"); - - $this->definition()->bind(['GET', '/health', '-z']); - } - public function testValueOptionDoesNotSwallowAFollowingOption(): void { $definition = Definition::for('make:action') @@ -163,69 +225,6 @@ public function testValueOptionDoesNotSwallowAFollowingOption(): void $this->assertTrue($bound->option('force')); } - public function testOptionalArgumentFallsBackToItsDeclaredDefault(): void - { - $definition = Definition::for('greet') - ->argument('subject', false, 'Who', 'world'); - - $this->assertSame('world', $definition->bind([])->argument('subject')); - } - - public function testSuppliedOptionsAreDistinguishedFromDefaulted(): void - { - $bound = $this->definition()->bind(['GET', '/health', '--force']); - - $this->assertTrue($bound->hasOption('force')); - $this->assertFalse($bound->hasOption('responder')); - } - - public function testALoneDashIsAPositionalNotAnOption(): void - { - // Conventionally '-' means stdin; it is one character, so the short - // option branch must not claim it. - $definition = Definition::for('cat')->argument('file', false); - - $this->assertSame('-', $definition->bind(['-'])->argument('file')); - } - - public function testEveryTokenAfterTheDoubleDashIsKept(): void - { - $bound = $this->definition()->bind(['--', 'GET', '/health']); - - $this->assertSame('GET', $bound->argument('method')); - $this->assertSame('/health', $bound->argument('path')); - } - - public function testParsingContinuesAfterAShortOptionCluster(): void - { - $definition = Definition::for('make:action') - ->argument('method', true) - ->argument('path', true) - ->option('force|f', 'Overwrite', false); - - $bound = $definition->bind(['-f', 'GET', '/health']); - - $this->assertTrue($bound->option('force')); - $this->assertSame('GET', $bound->argument('method')); - $this->assertSame('/health', $bound->argument('path')); - } - - public function testAttachedValueMayItselfContainAnEqualsSign(): void - { - $bound = $this->definition()->bind(['GET', '/health', '--responder=a=b']); - - $this->assertSame('a=b', $bound->option('responder')); - } - - public function testAnAttachedValueStopsTheOptionConsumingTheNextToken(): void - { - $bound = $this->definition()->bind(['--responder=view', 'GET', '/health']); - - $this->assertSame('view', $bound->option('responder')); - $this->assertSame('GET', $bound->argument('method')); - $this->assertSame('/health', $bound->argument('path')); - } - private function definition(): Definition { return Definition::for('make:action') diff --git a/tests/Unit/Console/Parsing/DefinitionTest.php b/tests/Unit/Console/Parsing/DefinitionTest.php index ee0e51e..befc9fb 100644 --- a/tests/Unit/Console/Parsing/DefinitionTest.php +++ b/tests/Unit/Console/Parsing/DefinitionTest.php @@ -20,6 +20,14 @@ final class DefinitionTest extends TestCase { + public function testArgumentsAreOptionalUnlessAskedToBeRequired(): void + { + // No explicit second argument: the default must leave it optional, so + // binding nothing is legal. + $definition = Definition::for('greet')->argument('subject'); + + $this->assertNull($definition->bind([])->argument('subject')); + } public function testBareSpecIsAFlag(): void { $definition = Definition::for('make:action')->option('force', 'Overwrite'); @@ -32,6 +40,19 @@ public function testBareSpecIsAFlag(): void $this->assertSame('Overwrite', $option->description); } + public function testDescriptionIsExposed(): void + { + $this->assertSame('Make it', Definition::for('make:action', 'Make it')->getDescription()); + } + + public function testDuplicateOptionOnTheSameDefinitionIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("option '--force' is already declared"); + + Definition::for('make:action')->option('force')->option('force'); + } + public function testEmptyModeSuffixIsRejected(): void { $this->expectException(Exception::class); @@ -73,6 +94,18 @@ public function testFindOptionResolvesAShortAlias(): void $this->assertSame('help', $definition->findOption('h')?->name); } + public function testMergeKeepsBothSetsOfOptions(): void + { + $globals = Definition::for('')->option('trace', 'Trace'); + $command = Definition::for('make:action')->option('force', 'Force'); + + $merged = $command->merge($globals); + + $this->assertNotNull($merged->findOption('trace')); + $this->assertNotNull($merged->findOption('force')); + $this->assertSame('make:action', $merged->getName()); + } + public function testMergeThrowsOnACollidingOptionName(): void { $globals = Definition::for('')->option('force', 'Global force'); @@ -95,16 +128,12 @@ public function testMergeThrowsOnACollidingShortAlias(): void $command->merge($globals); } - public function testMergeKeepsBothSetsOfOptions(): void + public function testModeSuffixIsTakenWholeNotUpToTheSecondEquals(): void { - $globals = Definition::for('')->option('trace', 'Trace'); - $command = Definition::for('make:action')->option('force', 'Force'); - - $merged = $command->merge($globals); + $this->expectException(Exception::class); + $this->expectExceptionMessage("unknown option mode '=s=x'"); - $this->assertNotNull($merged->findOption('trace')); - $this->assertNotNull($merged->findOption('force')); - $this->assertSame('make:action', $merged->getName()); + Definition::for('make:action')->option('stub=s=x', 'Stub'); } public function testOptionalArgumentsMayNotPrecedeRequiredOnes(): void @@ -117,9 +146,12 @@ public function testOptionalArgumentsMayNotPrecedeRequiredOnes(): void ->argument('path', true); } - public function testDescriptionIsExposed(): void + public function testShortAliasIsEverythingAfterTheFirstPipe(): void { - $this->assertSame('Make it', Definition::for('make:action', 'Make it')->getDescription()); + $definition = Definition::for('about')->option('help|h|x', 'Help'); + + $this->assertSame('help', $definition->findOption('h|x')?->name); + $this->assertNull($definition->findOption('h')); } public function testUnknownModeSuffixIsRejected(): void @@ -130,39 +162,6 @@ public function testUnknownModeSuffixIsRejected(): void Definition::for('make:action')->option('stub=x', 'Stub'); } - public function testArgumentsAreOptionalUnlessAskedToBeRequired(): void - { - // No explicit second argument: the default must leave it optional, so - // binding nothing is legal. - $definition = Definition::for('greet')->argument('subject'); - - $this->assertNull($definition->bind([])->argument('subject')); - } - - public function testDuplicateOptionOnTheSameDefinitionIsRejected(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage("option '--force' is already declared"); - - Definition::for('make:action')->option('force')->option('force'); - } - - public function testModeSuffixIsTakenWholeNotUpToTheSecondEquals(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage("unknown option mode '=s=x'"); - - Definition::for('make:action')->option('stub=s=x', 'Stub'); - } - - public function testShortAliasIsEverythingAfterTheFirstPipe(): void - { - $definition = Definition::for('about')->option('help|h|x', 'Help'); - - $this->assertSame('help', $definition->findOption('h|x')?->name); - $this->assertNull($definition->findOption('h')); - } - public function testUnknownOptionReturnsNull(): void { $definition = Definition::for('about'); diff --git a/tests/Unit/Console/RegistryTest.php b/tests/Unit/Console/RegistryTest.php index 6b857b1..32c4273 100644 --- a/tests/Unit/Console/RegistryTest.php +++ b/tests/Unit/Console/RegistryTest.php @@ -75,50 +75,24 @@ public function testConstructorSeedsFromAMap(): void $this->assertTrue($registry->has('fake')); } - public function testGetThrowsForAnUnknownName(): void + public function testContributedCommandIsVisibleToHasWithoutListingFirst(): void { - $this->expectException(Exception::class); - $this->expectExceptionMessage("unknown command 'nope'"); + $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); - (new Registry())->get('nope'); + $this->assertTrue((new Registry())->withDiscovery(self::KEY)->has('fake')); } - public function testGetStillThrowsAfterDiscoveryFindsNothing(): void + public function testContributedCommandResolvesThroughGetWithoutListingFirst(): void { - // A miss triggers the deferred scan; nothing in the test environment - // contributes under this key, so the miss must still surface. - $registry = (new Registry()) - ->add('fake', FakeCommand::class) - ->withDiscovery('crest-registry-test-key'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage("unknown command 'nope'"); - - $registry->get('nope'); - } + // get() misses the seeded map, so resolve() itself has to trigger the + // scan - nothing has called all() to do it beforehand. + $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); - public function testWithDiscoveryIsChainableAndSeededNamesStillResolve(): void - { - $registry = (new Registry()) - ->add('fake', FakeCommand::class) - ->withDiscovery('crest-registry-test-key'); + $registry = (new Registry())->withDiscovery(self::KEY); $this->assertSame(FakeCommand::class, $registry->get('fake')); } - public function testHasIsTrueForAnAlias(): void - { - $registry = (new Registry())->add('fake', FakeCommand::class, 'fk'); - - $this->assertTrue($registry->has('fk')); - $this->assertFalse($registry->has('nope')); - } - - public function testStartsEmpty(): void - { - $this->assertSame([], (new Registry())->all()); - } - public function testDiscoveryAddsAContributedCommand(): void { $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); @@ -142,9 +116,11 @@ public function testDiscoveryIgnoresANonArrayExtraEntry(): void $this->assertSame([], (new Registry())->withDiscovery(self::KEY)->all()); } - public function testDiscoveryIgnoresAPackageWithNoMatchingExtraKey(): void + public function testDiscoveryIgnoresANumericCommandName(): void { - $this->installExtra(['some-other-tool' => ['commands' => ['x' => FakeCommand::class]]]); + // A JSON array rather than an object yields int keys; those are not + // command names and must not be registered. + $this->installExtra([self::KEY => ['commands' => [FakeCommand::class]]]); $this->assertSame([], (new Registry())->withDiscovery(self::KEY)->all()); } @@ -156,15 +132,21 @@ public function testDiscoveryIgnoresAnUnloadableClass(): void $this->assertSame([], (new Registry())->withDiscovery(self::KEY)->all()); } - public function testDiscoveryIgnoresANumericCommandName(): void + public function testDiscoveryIgnoresAPackageWithNoMatchingExtraKey(): void { - // A JSON array rather than an object yields int keys; those are not - // command names and must not be registered. - $this->installExtra([self::KEY => ['commands' => [FakeCommand::class]]]); + $this->installExtra(['some-other-tool' => ['commands' => ['x' => FakeCommand::class]]]); $this->assertSame([], (new Registry())->withDiscovery(self::KEY)->all()); } + public function testDiscoveryIsSkippedEntirelyWithoutAKey(): void + { + $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); + + // No withDiscovery() call, so the contributed command stays invisible. + $this->assertSame([], (new Registry())->all()); + } + public function testDiscoveryRunsOnlyOnce(): void { $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); @@ -180,25 +162,34 @@ public function testDiscoveryRunsOnlyOnce(): void $this->assertSame(['fake' => FakeCommand::class], $registry->all()); } - public function testScanContinuesPastAPackageWithANonArrayExtraEntry(): void + public function testGetStillThrowsAfterDiscoveryFindsNothing(): void { - // A malformed contributor must be skipped, not abort the whole scan. - $this->installTwoPackages( - [self::KEY => 'nonsense'], - [self::KEY => ['commands' => ['fake' => FakeCommand::class]]] - ); + // A miss triggers the deferred scan; nothing in the test environment + // contributes under this key, so the miss must still surface. + $registry = (new Registry()) + ->add('fake', FakeCommand::class) + ->withDiscovery('crest-registry-test-key'); - $this->assertSame(['fake' => FakeCommand::class], (new Registry())->withDiscovery(self::KEY)->all()); + $this->expectException(Exception::class); + $this->expectExceptionMessage("unknown command 'nope'"); + + $registry->get('nope'); } - public function testScanContinuesPastAPackageWithANonArrayCommandsEntry(): void + public function testGetThrowsForAnUnknownName(): void { - $this->installTwoPackages( - [self::KEY => ['commands' => 'nonsense']], - [self::KEY => ['commands' => ['fake' => FakeCommand::class]]] - ); + $this->expectException(Exception::class); + $this->expectExceptionMessage("unknown command 'nope'"); - $this->assertSame(['fake' => FakeCommand::class], (new Registry())->withDiscovery(self::KEY)->all()); + (new Registry())->get('nope'); + } + + public function testHasIsTrueForAnAlias(): void + { + $registry = (new Registry())->add('fake', FakeCommand::class, 'fk'); + + $this->assertTrue($registry->has('fk')); + $this->assertFalse($registry->has('nope')); } public function testScanContinuesPastANumericCommandName(): void @@ -220,30 +211,25 @@ public function testScanContinuesPastAnUnloadableClass(): void $this->assertSame(['fake' => FakeCommand::class], (new Registry())->withDiscovery(self::KEY)->all()); } - public function testContributedCommandResolvesThroughGetWithoutListingFirst(): void - { - // get() misses the seeded map, so resolve() itself has to trigger the - // scan - nothing has called all() to do it beforehand. - $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); - - $registry = (new Registry())->withDiscovery(self::KEY); - - $this->assertSame(FakeCommand::class, $registry->get('fake')); - } - - public function testContributedCommandIsVisibleToHasWithoutListingFirst(): void + public function testScanContinuesPastAPackageWithANonArrayCommandsEntry(): void { - $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); + $this->installTwoPackages( + [self::KEY => ['commands' => 'nonsense']], + [self::KEY => ['commands' => ['fake' => FakeCommand::class]]] + ); - $this->assertTrue((new Registry())->withDiscovery(self::KEY)->has('fake')); + $this->assertSame(['fake' => FakeCommand::class], (new Registry())->withDiscovery(self::KEY)->all()); } - public function testDiscoveryIsSkippedEntirelyWithoutAKey(): void + public function testScanContinuesPastAPackageWithANonArrayExtraEntry(): void { - $this->installExtra([self::KEY => ['commands' => ['fake' => FakeCommand::class]]]); + // A malformed contributor must be skipped, not abort the whole scan. + $this->installTwoPackages( + [self::KEY => 'nonsense'], + [self::KEY => ['commands' => ['fake' => FakeCommand::class]]] + ); - // No withDiscovery() call, so the contributed command stays invisible. - $this->assertSame([], (new Registry())->all()); + $this->assertSame(['fake' => FakeCommand::class], (new Registry())->withDiscovery(self::KEY)->all()); } public function testSeededNameResolvesWithoutTriggeringDiscovery(): void @@ -261,6 +247,20 @@ public function testSeededNameResolvesWithoutTriggeringDiscovery(): void $this->assertArrayHasKey('boom', $registry->all()); } + public function testStartsEmpty(): void + { + $this->assertSame([], (new Registry())->all()); + } + + public function testWithDiscoveryIsChainableAndSeededNamesStillResolve(): void + { + $registry = (new Registry()) + ->add('fake', FakeCommand::class) + ->withDiscovery('crest-registry-test-key'); + + $this->assertSame(FakeCommand::class, $registry->get('fake')); + } + /** * Replaces the installed-package set with a single synthetic package * carrying the given composer `extra` block. diff --git a/tests/Unit/Generator/ArtifactWriterTest.php b/tests/Unit/Generator/ArtifactWriterTest.php new file mode 100644 index 0000000..154603d --- /dev/null +++ b/tests/Unit/Generator/ArtifactWriterTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Generator; + +use Crest\Console\Exceptions\Exception; +use Crest\Generator\ArtifactWriter; +use Crest\Generator\Stub; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function file_get_contents; +use function file_put_contents; +use function mkdir; + +final class ArtifactWriterTest extends TestCase +{ + use ScratchDirectory; + + protected function setUp(): void + { + $this->makeScratchDirectory('artifact-writer', 'packaged/adr'); + + file_put_contents($this->root . '/packaged/adr/thing.stub', 'hello {{ class }}'); + } + + protected function tearDown(): void + { + $this->removeScratchDirectory(); + } + + public function testADirectoryInThePlaceOfTheTargetIsNotMistakenForAnExistingFile(): void + { + // is_file() is the guard, not file_exists(): the latter is true of a + // directory too, which would report "already exists" and then fail to + // write - the wrong message for the wrong reason. + $file = $this->root . '/out/Thing.php'; + mkdir($file, 0o775, true); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('could not write ' . $file); + + $this->writer()->render($file, 'thing', ['class' => 'Thing'], false); + } + + public function testAnUncreatableDirectoryIsReported(): void + { + // A plain file where the directory has to go. mkdir() cannot succeed, + // whoever is running - unlike a chmod, which root ignores. + file_put_contents($this->root . '/blocked', 'not a directory'); + + $file = $this->root . '/blocked/Thing.php'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('could not create ' . $this->root . '/blocked'); + + $this->writer()->render($file, 'thing', ['class' => 'Thing'], false); + } + + public function testRenderCreatesMissingDirectoriesAllTheWayDown(): void + { + $file = $this->root . '/a/b/c/Thing.php'; + + $this->writer()->render($file, 'thing', ['class' => 'Thing'], false); + + $this->assertFileExists($file); + } + + public function testRenderOverwritesWhenForced(): void + { + $file = $this->root . '/out/Thing.php'; + + $this->writer()->render($file, 'thing', ['class' => 'First'], false); + $this->writer()->render($file, 'thing', ['class' => 'Second'], true); + + $this->assertSame('hello Second', (string) file_get_contents($file)); + } + + public function testRenderRefusesToOverwriteWithoutForce(): void + { + $file = $this->root . '/out/Thing.php'; + + $this->writer()->render($file, 'thing', ['class' => 'First'], false); + + $this->expectException(Exception::class); + $this->expectExceptionMessage($file . ' already exists; pass --force to overwrite'); + + $this->writer()->render($file, 'thing', ['class' => 'Second'], false); + } + + public function testRenderWritesTheSubstitutedStub(): void + { + $file = $this->root . '/out/Thing.php'; + + $this->writer()->render($file, 'thing', ['class' => 'Thing'], false); + + $this->assertSame('hello Thing', (string) file_get_contents($file)); + } + + public function testWriteReportsAFileItCouldNotWrite(): void + { + // Straight at the static entry point, which stub:publish uses. + $file = $this->root . '/out/Thing.php'; + mkdir($file, 0o775, true); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('could not write ' . $file); + + ArtifactWriter::write($file, 'contents'); + } + + private function writer(): ArtifactWriter + { + return new ArtifactWriter(new Stub($this->root . '/packaged'), 'adr'); + } +} diff --git a/tests/Unit/Generator/ClassNameTest.php b/tests/Unit/Generator/ClassNameTest.php new file mode 100644 index 0000000..0afa2c0 --- /dev/null +++ b/tests/Unit/Generator/ClassNameTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Generator; + +use Crest\Console\Exceptions\Exception; +use Crest\Generator\ClassName; +use PHPUnit\Framework\TestCase; + +final class ClassNameTest extends TestCase +{ + public function testABackslashedNameIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'Admin\\Album' is not a usable class name"); + + ClassName::suffixed('Admin\\Album', 'Responder'); + } + + public function testALeadingDigitIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'2Fast' is not a usable class name"); + + ClassName::suffixed('2Fast', 'Responder'); + } + + public function testAnAlreadySuffixedNameIsLeftAlone(): void + { + // The whole reason this exists: CorsMiddlewareMiddleware is what a naive + // concatenation produces for a user who knows the convention. + $this->assertSame('CorsMiddleware', ClassName::suffixed('CorsMiddleware', 'Middleware')); + } + + public function testANameEqualToTheSuffixIsNotDoubled(): void + { + $this->assertSame('Middleware', ClassName::suffixed('Middleware', 'Middleware')); + } + + public function testANamespacedNameIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "'Admin/Album' is not a usable class name; expected a single name like 'Album'" + ); + + ClassName::suffixed('Admin/Album', 'Responder'); + } + + public function testANameWithASpaceIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'My Responder' is not a usable class name"); + + ClassName::suffixed('My Responder', 'Responder'); + } + + public function testAnEmptyNameIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'' is not a usable class name"); + + ClassName::suffixed('', 'Responder'); + } + + public function testANonLatinNameIsAccepted(): void + { + // PHP's own identifier rule allows the high-byte range, so a class named + // in another script is a legal class - refusing it would be crest being + // narrower than the language it generates for. + $this->assertSame('ÜbergabeResponder', ClassName::suffixed('Übergabe', 'Responder')); + } + + public function testAnUnderscoreNameIsAccepted(): void + { + $this->assertSame('Legacy_Responder', ClassName::suffixed('Legacy_', 'Responder')); + } + + public function testMatchingIsCaseSensitive(): void + { + // 'middleware' is not the suffix 'Middleware', so it is appended. Exact + // matching is what keeps the output predictable. + $this->assertSame('CorsmiddlewareMiddleware', ClassName::suffixed('Corsmiddleware', 'Middleware')); + } + + public function testTheNameIsTakenVerbatimOtherwise(): void + { + // No case correction: the class written is the class that was asked for. + $this->assertSame('albumResponder', ClassName::suffixed('album', 'Responder')); + } + public function testTheSuffixIsAppendedWhenItIsAbsent(): void + { + $this->assertSame('CorsMiddleware', ClassName::suffixed('Cors', 'Middleware')); + } +} diff --git a/tests/Unit/Generator/StubContractsTest.php b/tests/Unit/Generator/StubContractsTest.php new file mode 100644 index 0000000..f4dbab0 --- /dev/null +++ b/tests/Unit/Generator/StubContractsTest.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Generator; + +use Crest\Console\PackageVersion; +use Crest\Generator\Stub; +use Crest\Paths; +use ParseError; +use Phalcon\Contracts\ADR\Action; +use Phalcon\Contracts\ADR\Handler; +use Phalcon\Contracts\ADR\Middleware; +use Phalcon\Contracts\ADR\Payload\Payload; +use Phalcon\Contracts\ADR\Responder\Responder; +use Phalcon\Contracts\Container\Service\Collection; +use Phalcon\Contracts\Container\Service\Provider; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\ResponseInterface; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use ReflectionNamedType; + +use function basename; +use function class_exists; +use function enum_exists; +use function extension_loaded; +use function glob; +use function interface_exists; +use function preg_match_all; +use function sprintf; +use function str_contains; +use function token_get_all; +use function trait_exists; + +use const TOKEN_PARSE; + +/** + * The packaged stubs name framework classes as text, and nothing else in the + * suite reads a stub as code. Without these tests a rename on the framework side + * ships a generator that writes a file the project cannot autoload, while every + * other assertion still passes. + */ +final class StubContractsTest extends TestCase +{ + private const FLAVOR = 'adr'; + + /** + * Every placeholder the packaged stubs declare, as one superset. A stub that + * gains a placeholder missing from this map fails the + * no-placeholder-left assertion rather than quietly emitting `{{ name }}`. + */ + private const REPLACEMENTS = [ + 'attributes' => '', + 'class' => 'GeneratedArtifact', + 'command' => 'generated', + 'namespace' => 'App\\Generated', + 'params' => '', + 'template' => 'generated/index', + ]; + + protected function setUp(): void + { + if ( + false === PackageVersion::isInstalled('phalcon/phalcon') + && false === extension_loaded('phalcon') + ) { + $this->markTestSkipped('resolving the stub imports needs Phalcon present'); + } + } + + /** + * The contract each stub declares it implements, and the parameter types the + * generated method signature commits to. + * + * Resolving the import is not enough: a contract can keep its name and + * change its shape, and a generated class whose signature no longer matches + * the interface it declares is a fatal at declaration time - in the user's + * project, not here. + * + * @return iterable}> + */ + public static function implementedContracts(): iterable + { + yield 'action' => [Action::class, '__invoke', [AttributeRequest::class]]; + + yield 'middleware' => [Middleware::class, '__invoke', [AttributeRequest::class, Handler::class]]; + + yield 'provider' => [Provider::class, 'provide', [Collection::class]]; + + yield 'responder' => [ + Responder::class, + '__invoke', + [RequestInterface::class, ResponseInterface::class, Payload::class], + ]; + } + + /** + * @return iterable + */ + public static function packagedStubs(): iterable + { + $directory = Stub::packagedDirectory(Paths::stubs(), self::FLAVOR); + + foreach (glob($directory . '/*.stub') ?: [] as $file) { + $name = basename($file, '.stub'); + + yield $name => [$name]; + } + } + + /** + * The assertion that would have caught a framework rename. Alias forms are + * covered: the capture stops before ` as `, so `Middleware as + * MiddlewareContract` is checked as the class it actually names. + * + * @dataProvider packagedStubs + */ + public function testEveryImportResolves(string $name): void + { + $rendered = $this->render($name); + + preg_match_all('/^use\s+(?!function\s|const\s)([\w\\\\]+)/m', $rendered, $matches); + + $this->assertNotEmpty($matches[1], sprintf("stub '%s' imports nothing", $name)); + + foreach ($matches[1] as $import) { + $this->assertTrue( + class_exists($import) + || interface_exists($import) + || trait_exists($import) + || enum_exists($import), + sprintf("stub '%s' imports %s, which does not exist", $name, $import) + ); + } + } + + /** + * @param class-string $contract + * @param list $expected + * + * @dataProvider implementedContracts + */ + public function testImplementedContractSignaturesAreUnchanged( + string $contract, + string $method, + array $expected + ): void { + $actual = []; + + foreach ((new ReflectionMethod($contract, $method))->getParameters() as $parameter) { + $type = $parameter->getType(); + + $actual[] = $type instanceof ReflectionNamedType ? $type->getName() : (string) $type; + } + + $this->assertSame( + $expected, + $actual, + sprintf('%s::%s() changed shape; the stub that implements it has not', $contract, $method) + ); + } + + /** + * @dataProvider packagedStubs + */ + public function testNoPlaceholderIsLeftUnrendered(string $name): void + { + $this->assertFalse( + str_contains($this->render($name), '{{'), + sprintf("stub '%s' left a placeholder unrendered", $name) + ); + } + + /** + * @dataProvider packagedStubs + */ + public function testRendersToParseablePhp(string $name): void + { + $rendered = $this->render($name); + + try { + // TOKEN_PARSE is what makes this a syntax check rather than a + // tokenizer run: without it a malformed stub tokenizes happily. + $this->assertNotEmpty(token_get_all($rendered, TOKEN_PARSE)); + } catch (ParseError $error) { + $this->fail( + sprintf("stub '%s' does not render to valid PHP: %s", $name, $error->getMessage()) + ); + } + + // Proves the class placeholder actually landed, rather than the file + // merely happening to parse. + $this->assertStringContainsString('class GeneratedArtifact', $rendered); + } + + private function render(string $name): string + { + return (new Stub(Paths::stubs()))->render(self::FLAVOR, $name, self::REPLACEMENTS); + } +} diff --git a/tests/Unit/Generator/StubTest.php b/tests/Unit/Generator/StubTest.php index 8f88b45..3254ed1 100644 --- a/tests/Unit/Generator/StubTest.php +++ b/tests/Unit/Generator/StubTest.php @@ -35,6 +35,27 @@ protected function tearDown(): void $this->removeScratchDirectory(); } + public function testPackagedRootIsAlsoStrippedOfATrailingSlash(): void + { + file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged'); + + $stub = new Stub($this->root . '/packaged/'); + + $this->assertSame( + $this->root . '/packaged/adr/action.stub', + $stub->resolve('adr', 'action') + ); + } + + public function testPackagedRootIsUsedWhenNoProjectRootIsGivenAtAll(): void + { + file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged {{ class }}'); + + $stub = new Stub($this->root . '/packaged'); + + $this->assertSame('packaged X', $stub->render('adr', 'action', ['class' => 'X'])); + } + public function testPackagedStubIsUsedWhenNoProjectOverrideExists(): void { file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged {{ class }}'); @@ -54,6 +75,19 @@ public function testProjectStubOverridesThePackagedOne(): void $this->assertSame('project GetHealth', $stub->render('adr', 'action', ['class' => 'GetHealth'])); } + public function testResolveReturnsTheWinningPath(): void + { + file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged'); + file_put_contents($this->root . '/project/resources/stubs/adr/action.stub', 'project'); + + $stub = new Stub($this->root . '/packaged', $this->root . '/project'); + + $this->assertSame( + $this->root . '/project/resources/stubs/adr/action.stub', + $stub->resolve('adr', 'action') + ); + } + public function testShippedActionStubsExistAndAreValidPhp(): void { $stub = new Stub(Paths::stubs()); @@ -91,38 +125,14 @@ public function testTrailingSlashesOnEitherRootAreIgnored(): void ); } - public function testPackagedRootIsUsedWhenNoProjectRootIsGivenAtAll(): void - { - file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged {{ class }}'); - - $stub = new Stub($this->root . '/packaged'); - - $this->assertSame('packaged X', $stub->render('adr', 'action', ['class' => 'X'])); - } - - public function testPackagedRootIsAlsoStrippedOfATrailingSlash(): void - { - file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged'); - - $stub = new Stub($this->root . '/packaged/'); - - $this->assertSame( - $this->root . '/packaged/adr/action.stub', - $stub->resolve('adr', 'action') - ); - } - - public function testResolveReturnsTheWinningPath(): void + public function testUnknownStubThrows(): void { - file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged'); - file_put_contents($this->root . '/project/resources/stubs/adr/action.stub', 'project'); - $stub = new Stub($this->root . '/packaged', $this->root . '/project'); - $this->assertSame( - $this->root . '/project/resources/stubs/adr/action.stub', - $stub->resolve('adr', 'action') - ); + $this->expectException(Exception::class); + $this->expectExceptionMessage("stub 'adr/nope' not found"); + + $stub->resolve('adr', 'nope'); } public function testUnreplacedPlaceholdersAreLeftAlone(): void @@ -133,14 +143,4 @@ public function testUnreplacedPlaceholdersAreLeftAlone(): void $this->assertSame('X|{{ b }}', $stub->render('adr', 'action', ['a' => 'X'])); } - - public function testUnknownStubThrows(): void - { - $stub = new Stub($this->root . '/packaged', $this->root . '/project'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage("stub 'adr/nope' not found"); - - $stub->resolve('adr', 'nope'); - } } diff --git a/tests/Unit/Project/ConfigTest.php b/tests/Unit/Project/ConfigTest.php index 5fd7bc9..b798d5d 100644 --- a/tests/Unit/Project/ConfigTest.php +++ b/tests/Unit/Project/ConfigTest.php @@ -36,6 +36,22 @@ protected function tearDown(): void $this->removeScratchDirectory(); } + public function testAdrGetsADefaultPathForEveryGeneratedArtifact(): void + { + $this->writeComposerJson(['App\\' => 'src/']); + + $this->assertSame( + [ + 'action' => $this->root . '/src/Action', + 'command' => $this->root . '/src/Command', + 'middleware' => $this->root . '/src/Middleware', + 'provider' => $this->root . '/src/Provider', + 'responder' => $this->root . '/src/Responder', + ], + Config::discover($this->root)->paths() + ); + } + public function testCrestPhpMayDeclareTheNamespaceExplicitly(): void { $this->writeComposerJson(['App\\' => 'src/']); @@ -55,215 +71,207 @@ public function testCrestPhpMayDeclareTheNamespaceExplicitly(): void $this->assertSame('Shop\Handlers', $config->namespaceFor('action')); } - public function testExplicitConfigFileWins(): void + public function testCrestPhpWithoutAComposerJsonHasAnEmptyPsr4Map(): void { - $this->writeComposerJson(['App\\' => 'src/']); + // No composer.json at all: crest.php still loads, but namespaceFor() + // has nothing to resolve against and must say so rather than guess. file_put_contents( - $this->root . '/elsewhere.php', - " 'Other'];\n" + $this->root . '/crest.php', + " 'Shop'];\n" ); - $config = Config::discover($this->root, $this->root . '/elsewhere.php'); - - $this->assertSame('Other', $config->namespace()); - } + $config = Config::discover($this->root); - public function testInfersNamespaceAndActionPathFromComposerJson(): void - { - $this->writeComposerJson(['App\\' => 'src/']); + $this->assertSame('Shop', $config->namespace()); - $config = Config::discover($this->root); + $this->expectException(Exception::class); + $this->expectExceptionMessage("no psr-4 autoload entry covers 'src/Action'"); - $this->assertSame(Flavor::ADR, $config->flavor()); - $this->assertSame('App', $config->namespace()); - $this->assertSame($this->root . '/src/Action', $config->path('action')); - $this->assertSame($this->root, $config->root()); + $config->namespaceFor('action'); } - public function testMissingComposerJsonThrows(): void + public function testDeclaredNamespaceForIsStrippedOfSurroundingBackslashes(): void { - $this->expectException(Exception::class); - $this->expectExceptionMessage('no crest.php and no composer.json found'); + $this->writeComposerJson(['App\\' => 'src/']); + file_put_contents( + $this->root . '/crest.php', + " ['action' => '\\\\Shop\\\\Handlers\\\\']];\n" + ); - Config::discover($this->root); + $this->assertSame('Shop\Handlers', Config::discover($this->root)->namespaceFor('action')); } - public function testNamespaceForDerivesFromThePsr4Pairing(): void + public function testDeclaredNamespaceIsStrippedOfSurroundingBackslashes(): void { $this->writeComposerJson(['App\\' => 'src/']); + file_put_contents( + $this->root . '/crest.php', + " '\\\\Shop\\\\'];\n" + ); - $this->assertSame('App\Action', Config::discover($this->root)->namespaceFor('action')); + $this->assertSame('Shop', Config::discover($this->root)->namespace()); } - public function testNamespaceForPrefersTheLongestMatchingPsr4Directory(): void + public function testDeclaredPathIsStrippedOfSurroundingSlashes(): void { - $this->writeComposerJson(['App\\' => 'src/', 'Deep\\' => 'src/Action/']); - mkdir($this->root . '/src/Action/Company', 0o775, true); + $this->writeComposerJson(['App\\' => 'src/']); file_put_contents( $this->root . '/crest.php', - " ['action' => 'src/Action/Company']];\n" + " ['action' => '/src/Action/']];\n" ); - $this->assertSame('Deep\Company', Config::discover($this->root)->namespaceFor('action')); + $this->assertSame($this->root . '/src/Action', Config::discover($this->root)->path('action')); } - public function testNamespaceForThrowsWhenNoPsr4EntryCoversThePath(): void + public function testDeclaredPathsAreMergedOverTheDefaultsNotReplaced(): void { - // The exact configuration that previously produced a silently wrong - // namespace: an action path no autoload rule reaches. $this->writeComposerJson(['App\\' => 'src/']); - mkdir($this->root . '/app/Handlers', 0o775, true); file_put_contents( $this->root . '/crest.php', - " 'Shop', 'paths' => ['action' => 'app/Handlers']];\n" + " ['views' => 'templates']];\n" ); - $this->expectException(Exception::class); - $this->expectExceptionMessage("no psr-4 autoload entry covers 'app/Handlers'"); + $config = Config::discover($this->root); - Config::discover($this->root)->namespaceFor('action'); + // The declared key is added and the default 'action' survives. + $this->assertSame($this->root . '/templates', $config->path('views')); + $this->assertSame($this->root . '/src/Action', $config->path('action')); } - public function testSkipsPsr4EntriesWhoseDirectoryIsAbsent(): void + public function testExplicitConfigFileBeatsADiscoveredOne(): void { - $this->writeComposerJson(['Ghost\\' => 'missing/', 'App\\' => 'src/']); + // Both exist, so this pins the precedence rather than relying on the + // walk-up finding nothing. + $this->writeComposerJson(['App\\' => 'src/']); + file_put_contents($this->root . '/crest.php', " 'Discovered'];\n"); + file_put_contents($this->root . '/elsewhere.php', " 'Explicit'];\n"); - $this->assertSame('App', Config::discover($this->root)->namespace()); + $config = Config::discover($this->root, $this->root . '/elsewhere.php'); + + $this->assertSame('Explicit', $config->namespace()); } - public function testNamespaceForReturnsThePrefixAloneWhenThePathIsTheRoot(): void + public function testExplicitConfigFileWins(): void { - // paths.action equals the psr-4 directory exactly, so there is no - // remainder to append. $this->writeComposerJson(['App\\' => 'src/']); file_put_contents( - $this->root . '/crest.php', - " ['action' => 'src']];\n" + $this->root . '/elsewhere.php', + " 'Other'];\n" ); - $this->assertSame('App', Config::discover($this->root)->namespaceFor('action')); + $config = Config::discover($this->root, $this->root . '/elsewhere.php'); + + $this->assertSame('Other', $config->namespace()); } - public function testNamespaceForKeepsTheLongestMatchWhenAShorterOneComesLater(): void + public function testFirstDeclarationWinsWhenTwoPsr4DirectoriesTie(): void { - // Declaration order puts the deeper directory first, so the second, - // shorter match must not displace it. + // Equal-length matches: the earlier declaration keeps the win, so the + // comparison has to reject an equal candidate, not just a shorter one. file_put_contents( $this->root . '/composer.json', - '{"autoload":{"psr-4":{"Deep\\\\":"src/Action/","App\\\\":"src/"}}}' - ); - mkdir($this->root . '/src/Action/Company', 0o775, true); - file_put_contents( - $this->root . '/crest.php', - " ['action' => 'src/Action/Company']];\n" + '{"autoload":{"psr-4":{"First\\\\":"src/Action/","Second\\\\":"src/Action/"}}}' ); - $this->assertSame('Deep\Company', Config::discover($this->root)->namespaceFor('action')); + $this->assertSame('First', Config::discover($this->root)->namespaceFor('action')); } - public function testCrestPhpWithoutAComposerJsonHasAnEmptyPsr4Map(): void + public function testFlavorsWithoutGeneratorsGetNoDefaultPaths(): void { - // No composer.json at all: crest.php still loads, but namespaceFor() - // has nothing to resolve against and must say so rather than guess. - file_put_contents( - $this->root . '/crest.php', - " 'Shop'];\n" - ); + // ADR is the only flavor with generators. Offering the others its + // directories would put locations in config:show for artifacts the + // project has no command to write. + $this->writeComposerJson(['App\\' => 'src/']); - $config = Config::discover($this->root); + file_put_contents($this->root . '/crest.php', " 'cli'];\n"); + $this->assertSame([], Config::discover($this->root)->paths()); - $this->assertSame('Shop', $config->namespace()); - - $this->expectException(Exception::class); - $this->expectExceptionMessage("no psr-4 autoload entry covers 'src/Action'"); - - $config->namespaceFor('action'); + file_put_contents($this->root . '/crest.php', " 'mvc'];\n"); + $this->assertSame([], Config::discover($this->root)->paths()); } - public function testExplicitConfigFileBeatsADiscoveredOne(): void + public function testInfersNamespaceAndActionPathFromComposerJson(): void { - // Both exist, so this pins the precedence rather than relying on the - // walk-up finding nothing. $this->writeComposerJson(['App\\' => 'src/']); - file_put_contents($this->root . '/crest.php', " 'Discovered'];\n"); - file_put_contents($this->root . '/elsewhere.php', " 'Explicit'];\n"); - $config = Config::discover($this->root, $this->root . '/elsewhere.php'); + $config = Config::discover($this->root); - $this->assertSame('Explicit', $config->namespace()); + $this->assertSame(Flavor::ADR, $config->flavor()); + $this->assertSame('App', $config->namespace()); + $this->assertSame($this->root . '/src/Action', $config->path('action')); + $this->assertSame($this->root, $config->root()); } - public function testTrailingSlashOnTheDirectoryIsIgnored(): void + public function testMissingComposerJsonThrows(): void { - $this->writeComposerJson(['App\\' => 'src/']); - - $config = Config::discover($this->root . '/'); + $this->expectException(Exception::class); + $this->expectExceptionMessage('no crest.php and no composer.json found'); - $this->assertSame($this->root, $config->root()); - $this->assertSame($this->root . '/src/Action', $config->path('action')); + Config::discover($this->root); } - public function testDeclaredNamespaceIsStrippedOfSurroundingBackslashes(): void + public function testNamespaceForDerivesFromThePsr4Pairing(): void { $this->writeComposerJson(['App\\' => 'src/']); - file_put_contents( - $this->root . '/crest.php', - " '\\\\Shop\\\\'];\n" - ); - $this->assertSame('Shop', Config::discover($this->root)->namespace()); + $this->assertSame('App\Action', Config::discover($this->root)->namespaceFor('action')); } - public function testDeclaredNamespaceForIsStrippedOfSurroundingBackslashes(): void + public function testNamespaceForKeepsTheLongestMatchWhenAShorterOneComesLater(): void { - $this->writeComposerJson(['App\\' => 'src/']); + // Declaration order puts the deeper directory first, so the second, + // shorter match must not displace it. + file_put_contents( + $this->root . '/composer.json', + '{"autoload":{"psr-4":{"Deep\\\\":"src/Action/","App\\\\":"src/"}}}' + ); + mkdir($this->root . '/src/Action/Company', 0o775, true); file_put_contents( $this->root . '/crest.php', - " ['action' => '\\\\Shop\\\\Handlers\\\\']];\n" + " ['action' => 'src/Action/Company']];\n" ); - $this->assertSame('Shop\Handlers', Config::discover($this->root)->namespaceFor('action')); + $this->assertSame('Deep\Company', Config::discover($this->root)->namespaceFor('action')); } - public function testDeclaredPathIsStrippedOfSurroundingSlashes(): void + public function testNamespaceForPrefersTheLongestMatchingPsr4Directory(): void { - $this->writeComposerJson(['App\\' => 'src/']); + $this->writeComposerJson(['App\\' => 'src/', 'Deep\\' => 'src/Action/']); + mkdir($this->root . '/src/Action/Company', 0o775, true); file_put_contents( $this->root . '/crest.php', - " ['action' => '/src/Action/']];\n" + " ['action' => 'src/Action/Company']];\n" ); - $this->assertSame($this->root . '/src/Action', Config::discover($this->root)->path('action')); + $this->assertSame('Deep\Company', Config::discover($this->root)->namespaceFor('action')); } - public function testDeclaredPathsAreMergedOverTheDefaultsNotReplaced(): void + public function testNamespaceForReturnsThePrefixAloneWhenThePathIsTheRoot(): void { + // paths.action equals the psr-4 directory exactly, so there is no + // remainder to append. $this->writeComposerJson(['App\\' => 'src/']); file_put_contents( $this->root . '/crest.php', - " ['views' => 'templates']];\n" + " ['action' => 'src']];\n" ); - $config = Config::discover($this->root); - - // The declared key is added and the default 'action' survives. - $this->assertSame($this->root . '/templates', $config->path('views')); - $this->assertSame($this->root . '/src/Action', $config->path('action')); + $this->assertSame('App', Config::discover($this->root)->namespaceFor('action')); } - public function testPsr4DirectoryMatchOnlyCountsWholeSegments(): void + public function testNamespaceForThrowsWhenNoPsr4EntryCoversThePath(): void { - // 'src' must not be treated as covering 'srcextra' - that is a - // different directory that happens to share a prefix. + // The exact configuration that previously produced a silently wrong + // namespace: an action path no autoload rule reaches. $this->writeComposerJson(['App\\' => 'src/']); - mkdir($this->root . '/srcextra', 0o775, true); + mkdir($this->root . '/app/Handlers', 0o775, true); file_put_contents( $this->root . '/crest.php', - " ['action' => 'srcextra']];\n" + " 'Shop', 'paths' => ['action' => 'app/Handlers']];\n" ); $this->expectException(Exception::class); - $this->expectExceptionMessage("no psr-4 autoload entry covers 'srcextra'"); + $this->expectExceptionMessage("no psr-4 autoload entry covers 'app/Handlers'"); Config::discover($this->root)->namespaceFor('action'); } @@ -280,34 +288,16 @@ public function testNonMatchingPsr4EntriesNeverBecomeTheBestMatch(): void $this->assertSame('App\Action', Config::discover($this->root)->namespaceFor('action')); } - public function testScanKeepsLookingAfterRejectingAShorterMatch(): void + public function testNoUsablePsr4EntryThrows(): void { - // Order matters: a longer match sits behind a shorter one, so the - // rejection has to skip that entry rather than end the search. - file_put_contents( - $this->root . '/composer.json', - '{"autoload":{"psr-4":{"Mid\\\\":"src/Action/","App\\\\":"src/",' - . '"Deepest\\\\":"src/Action/Company/"}}}' - ); - mkdir($this->root . '/src/Action/Company', 0o775, true); - file_put_contents( - $this->root . '/crest.php', - " ['action' => 'src/Action/Company']];\n" - ); - - $this->assertSame('Deepest', Config::discover($this->root)->namespaceFor('action')); - } + // composer.json exists but every declared directory is missing, so + // there is nothing to infer a root namespace from. + $this->writeComposerJson(['Ghost\\' => 'missing/']); - public function testFirstDeclarationWinsWhenTwoPsr4DirectoriesTie(): void - { - // Equal-length matches: the earlier declaration keeps the win, so the - // comparison has to reject an equal candidate, not just a shorter one. - file_put_contents( - $this->root . '/composer.json', - '{"autoload":{"psr-4":{"First\\\\":"src/Action/","Second\\\\":"src/Action/"}}}' - ); + $this->expectException(Exception::class); + $this->expectExceptionMessage('no crest.php and no usable psr-4 autoload entry found'); - $this->assertSame('First', Config::discover($this->root)->namespaceFor('action')); + Config::discover($this->root); } public function testPsr4DirectoryIsFoundDespiteSurroundingSlashes(): void @@ -320,16 +310,31 @@ public function testPsr4DirectoryIsFoundDespiteSurroundingSlashes(): void $this->assertSame('App', Config::discover($this->root)->namespace()); } - public function testNoUsablePsr4EntryThrows(): void + public function testPsr4DirectoryMatchOnlyCountsWholeSegments(): void { - // composer.json exists but every declared directory is missing, so - // there is nothing to infer a root namespace from. - $this->writeComposerJson(['Ghost\\' => 'missing/']); + // 'src' must not be treated as covering 'srcextra' - that is a + // different directory that happens to share a prefix. + $this->writeComposerJson(['App\\' => 'src/']); + mkdir($this->root . '/srcextra', 0o775, true); + file_put_contents( + $this->root . '/crest.php', + " ['action' => 'srcextra']];\n" + ); $this->expectException(Exception::class); - $this->expectExceptionMessage('no crest.php and no usable psr-4 autoload entry found'); + $this->expectExceptionMessage("no psr-4 autoload entry covers 'srcextra'"); - Config::discover($this->root); + Config::discover($this->root)->namespaceFor('action'); + } + + public function testPsr4EntryWithAnEmptyTargetIsSkipped(): void + { + file_put_contents( + $this->root . '/composer.json', + '{"autoload":{"psr-4":{"Empty\\\\":"","App\\\\":"src/"}}}' + ); + + $this->assertSame('App', Config::discover($this->root)->namespace()); } public function testPsr4TargetMayBeDeclaredAsAList(): void @@ -343,16 +348,41 @@ public function testPsr4TargetMayBeDeclaredAsAList(): void $this->assertSame('App', Config::discover($this->root)->namespace()); } - public function testPsr4EntryWithAnEmptyTargetIsSkipped(): void + public function testScanKeepsLookingAfterRejectingAShorterMatch(): void { + // Order matters: a longer match sits behind a shorter one, so the + // rejection has to skip that entry rather than end the search. file_put_contents( $this->root . '/composer.json', - '{"autoload":{"psr-4":{"Empty\\\\":"","App\\\\":"src/"}}}' + '{"autoload":{"psr-4":{"Mid\\\\":"src/Action/","App\\\\":"src/",' + . '"Deepest\\\\":"src/Action/Company/"}}}' + ); + mkdir($this->root . '/src/Action/Company', 0o775, true); + file_put_contents( + $this->root . '/crest.php', + " ['action' => 'src/Action/Company']];\n" ); + $this->assertSame('Deepest', Config::discover($this->root)->namespaceFor('action')); + } + + public function testSkipsPsr4EntriesWhoseDirectoryIsAbsent(): void + { + $this->writeComposerJson(['Ghost\\' => 'missing/', 'App\\' => 'src/']); + $this->assertSame('App', Config::discover($this->root)->namespace()); } + public function testTrailingSlashOnTheDirectoryIsIgnored(): void + { + $this->writeComposerJson(['App\\' => 'src/']); + + $config = Config::discover($this->root . '/'); + + $this->assertSame($this->root, $config->root()); + $this->assertSame($this->root . '/src/Action', $config->path('action')); + } + public function testUnknownFlavorThrows(): void { $this->writeComposerJson(['App\\' => 'src/']); diff --git a/tests/Unit/Project/LocatorTest.php b/tests/Unit/Project/LocatorTest.php index c05f252..d9e6cff 100644 --- a/tests/Unit/Project/LocatorTest.php +++ b/tests/Unit/Project/LocatorTest.php @@ -40,6 +40,13 @@ public function testFindsTheFileInTheStartingDirectory(): void $this->assertSame($this->root . '/crest.php', Locator::locate($this->root)); } + public function testReturnsNullOnceTheFilesystemRootIsPassed(): void + { + // Nothing is written, so the walk runs all the way to '/' and has to + // stop there rather than looping forever. + $this->assertNull(Locator::locate($this->root . '/src/Action/Deep')); + } + public function testWalksUpUntilItFindsTheFile(): void { // The whole point of the walk: crest has to work from anywhere inside @@ -51,11 +58,4 @@ public function testWalksUpUntilItFindsTheFile(): void Locator::locate($this->root . '/src/Action/Deep') ); } - - public function testReturnsNullOnceTheFilesystemRootIsPassed(): void - { - // Nothing is written, so the walk runs all the way to '/' and has to - // stop there rather than looping forever. - $this->assertNull(Locator::locate($this->root . '/src/Action/Deep')); - } }