diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dc0aae6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,52 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]: " +labels: bug +assignees: '' + +--- + +Questions? Discussions: https://phalcon.io/discussions or Discord: https://phalcon.io/discord + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior. Include the exact command you ran: + +```bash +vendor/bin/crest +``` + +> Re-run with `--trace` and paste the full output. That turns the one-line error into a stack trace, which is usually the whole answer. + +``` +paste output here +``` + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Generated output** +If the bug is in a generator, paste the file crest produced and describe how it differs from what you expected. + +```php +// paste generated code +``` + +**Details** +Paste the output of `vendor/bin/crest about` - it reports the PHP, Phalcon and crest versions in one go: + +``` +paste `crest about` output here +``` + + - Phalcon variant: ext-phalcon (`^5`) | phalcon/phalcon (`^6`) + - Operating System: + - Installation type: `composer require --dev phalcon/crest` | from source + - Project flavor: adr | mvc | cli + - `crest.php` present: yes | no (if yes, paste it) + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5e78f3d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[NFR]: " +labels: new feature request +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. If it is a new command, sketch the invocation: + +```bash +vendor/bin/crest [--options] +``` + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Prior art** +Did `phalcon-devtools` do this, and if so under what name? Crest is replacing devtools, so an existing v4 command is a strong argument on its own. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..eb37aad --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 + +updates: + # Keep the SHA-pinned GitHub Actions current. Dependabot opens reviewable + # pull requests that bump both the commit SHA and the trailing "# version" + # comment; it never merges on its own. + - package-ecosystem: "github-actions" + directories: + - "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0f98005 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +Hello! + +* Type: bug fix | new feature | code quality | documentation +* Link to issue: + +**In raising this pull request, I confirm the following:** + +- [ ] I have checked that another pull request for this purpose does not exist +- [ ] I wrote some tests for this PR +- [ ] `composer test`, `composer cs` and `composer analyze` all pass +- [ ] `composer infection` still reports 100% MSI, or any surviving mutant is documented as equivalent in `resources/infection.json5` +- [ ] I have created a PR for the [documentation](https://github.com/phalcon/docs) about this change, if user facing + +Small description of change: + +Thanks diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9cfe08e..b14e08a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -126,45 +126,14 @@ jobs: - name: "Unit tests" run: composer test - mutation: - name: "Mutation testing" - - permissions: - contents: read - - runs-on: ubuntu-latest - - needs: - - unit-tests - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 1 - token: ${{ github.token }} - - - name: "Setup PHP" - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: '8.1' - extensions: ${{ env.EXTENSIONS }} - coverage: pcov - env: - COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: "Install dependencies with Composer" - uses: ramsey/composer-install@26d8a556604053a9612623447203a691f406fbe6 # v4 - with: - composer-options: "--prefer-dist" - - # resources/infection.json5 sets minMsi/minCoveredMsi to 100, so this - # step fails on any surviving mutant that is not documented there as - # equivalent. Infection is a temporary dependency: it comes back out of - # composer.json before release, at which point this job goes with it. - - name: "Infection" - run: composer infection - + # Mutation testing is not a job here yet. Infection runs locally for now + # (`composer infection`, gated at MSI 100 by resources/infection.json5). + # + # The blocker is the PHP floor: at ^8.1 composer can only resolve + # thecodingmachine/safe to dev-master, which eagerly autoloads its function + # files and floods 8.4+ runs with deprecation notices. Once the floor moves + # past 8.1 a stable Safe release becomes resolvable and this becomes a + # standard job alongside the others. coverage: name: "Coverage, SonarQube and octocov" diff --git a/.gitignore b/.gitignore index c2b4a51..c090f39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /vendor/ +# Local convenience symlink to a sibling phalcon checkout. Never committed: +# it points outside the repository and only resolves on the machine that made it. +/phalcon /.env /tests/_output/* !/tests/_output/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9f648e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes are documented here. The format is based on [Keep a Changelog][keep_a_changelog] and this project adheres to [Semantic Versioning][semantic_versioning]. + +## [Unreleased] + +### Added + +- Added `route:list`, listing every route the application answers with its method and Action class. Reads the Action classes rather than a route table, since ADR has none. [#1](https://github.com/phalcon/crest/issues/1) +- Added `config:show`, showing the resolved project configuration and marking each value as declared or inferred. [#1](https://github.com/phalcon/crest/issues/1) +- Added `container:list`, listing the services registered in the project container with their class and whether they have been resolved. [#1](https://github.com/phalcon/crest/issues/1) +- Added `event:list`, listing the listeners attached to the project events manager. [#1](https://github.com/phalcon/crest/issues/1) +- 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. + +### Changed + +- `make:action` now writes a `params()` declaration for routes with attributes, so they arrive constrained and cast rather than as raw strings. +- `make:action` now rejects a static segment after a placeholder and suggests the supported spelling: `/album/{id}/edit` is reported as `/album/edit/{id}`. Arguments trail the static path, so the first form has no class name that describes it. +- 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. + +### Removed + +- Removed the shadowed-action warning from `make:action`. One path names exactly one Action, so nothing can be shadowed. + +[keep_a_changelog]: https://keepachangelog.com/en/1.0.0/ +[semantic_versioning]: https://semver.org/spec/v2.0.0.html +[Unreleased]: https://github.com/phalcon/crest/commits/master diff --git a/README.md b/README.md index 5b4c4f6..cb5e970 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Phalcon Crest -Command line application for Phalcon — generators, introspection and project tooling. +Command line application for Phalcon - generators, introspection and project tooling. ## Requirements - PHP `^8.1` - Phalcon, either the `ext-phalcon` C extension (`^5`) or the `phalcon/phalcon` PHP - implementation (`^6`) — crest itself needs neither to run + implementation (`^6`) - crest itself needs neither to run ## Install @@ -42,7 +42,7 @@ return [ ]; ``` -Namespaces are resolved from your psr-4 map, so a path must be covered by an autoload rule — +Namespaces are resolved from your psr-4 map, so a path must be covered by an autoload rule - `src/Action` under `App\ => src/` becomes `App\Action`. If you write to a directory your autoloader does not cover, declare the namespace outright: diff --git a/composer.json b/composer.json index 8636923..f3b9337 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "infection/infection": "^0.29", "pds/composer-script-names": "^1", "pds/skeleton": "^1", - "phalcon/phalcon": "^6.0@beta", + "phalcon/phalcon": "v6.0.x-dev", "phalcon/talon": "^0.8", "phpstan/phpstan": "^2", "phpunit/phpunit": "^10.5", diff --git a/composer.lock b/composer.lock index e564270..fd5f2be 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6adf64d3eeef7db8de52e5cf796cd4ac", + "content-hash": "494a89480762e01e1244142655e7d09c", "packages": [ { "name": "phalcon/cli-options-parser", @@ -1614,16 +1614,16 @@ }, { "name": "phalcon/phalcon", - "version": "v6.0.0beta2", + "version": "v6.0.x-dev", "source": { "type": "git", "url": "https://github.com/phalcon/phalcon.git", - "reference": "5b748dc8c36f35819922b0818d0d46d66f73fd2f" + "reference": "7e87395f5e1d100fef4976ef9d0603370c6bbc57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phalcon/phalcon/zipball/5b748dc8c36f35819922b0818d0d46d66f73fd2f", - "reference": "5b748dc8c36f35819922b0818d0d46d66f73fd2f", + "url": "https://api.github.com/repos/phalcon/phalcon/zipball/7e87395f5e1d100fef4976ef9d0603370c6bbc57", + "reference": "7e87395f5e1d100fef4976ef9d0603370c6bbc57", "shasum": "" }, "require": { @@ -1664,6 +1664,7 @@ "ext-redis": "to use Cache\\Adapter\\Redis, Session\\Adapter\\Redis, Storage\\Adapter\\Redis", "ext-yaml": "to use Config\\Adapter\\Yaml" }, + "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1681,7 +1682,7 @@ ], "support": { "issues": "https://github.com/phalcon/phalcon/issues", - "source": "https://github.com/phalcon/phalcon/tree/v6.0.0beta2" + "source": "https://github.com/phalcon/phalcon/tree/v6.0.x" }, "funding": [ { @@ -1693,7 +1694,7 @@ "type": "open_collective" } ], - "time": "2026-07-27T02:04:28+00:00" + "time": "2026-07-29T00:19:31+00:00" }, { "name": "phalcon/talon", @@ -6554,7 +6555,7 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "phalcon/phalcon": 10 + "phalcon/phalcon": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/resources/infection.json5 b/resources/infection.json5 index 667508c..d1b1b15 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -34,11 +34,22 @@ // getcwd() returns false only when the working directory is // unreadable, which the suite cannot produce. "Crest\\Project\\Config::discover", - // Environment branch: reachable only with ext-phalcon loaded, - // which the v6 image does not have. Covered by the v5 cell. + // Environment branch. See the note on Identical below. "Crest\\Command\\AboutCommand::phalcon" ] }, + "Identical": { + // AboutCommand::phalcon() has three branches - ext-phalcon loaded, + // phalcon/phalcon installed, neither - and no single container can + // reach more than one. Whichever variant the image is built as, + // the other two are unreachable, so mutants on them are reported as + // uncovered rather than escaped. Ignored so the gate measures the + // suite rather than which variant the container happens to be. + "ignore": ["Crest\\Command\\AboutCommand::phalcon"] + }, + "TrueValue": { + "ignore": ["Crest\\Command\\AboutCommand::phalcon"] + }, "CatchBlockRemoval": { // The console-exception arm and the Throwable arm render the same // one-line error and return the same status, so removing the @@ -53,13 +64,13 @@ "ignore": [ // Phalcon-missing guard: unreachable while Phalcon is loadable, // which it always is when the suite runs. - "Crest\\Adr\\PhalconRouterCandidates::candidatesFor", + "Crest\\ADR\\PhalconRouterResolver::router", "Crest\\Command\\AboutCommand::phalcon" ] }, "ConcatOperandRemoval": { "ignore": [ - "Crest\\Adr\\PhalconRouterCandidates::candidatesFor", + "Crest\\ADR\\PhalconRouterResolver::router", "Crest\\Command\\AboutCommand::phalcon" ] }, @@ -71,6 +82,10 @@ }, "DecrementInteger": { "ignore": [ + // The exception code passed alongside the boot failure. Nothing + // reads it - the message and the previous exception carry the + // 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", @@ -84,14 +99,29 @@ // 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 + // chosen so a foreign autoloader is never invoked. + "Crest\\Command\\Route\\ListCommand::load", // NO_COLOR and the tty check both yield "undecorated" against // the php://memory streams the suite uses, so the two branches // cannot be told apart without a real terminal. "Crest\\Console\\Output::detectDecoration" ] }, + "FunctionCallRemoval": { + // ksort() over the discovered routes. RecursiveDirectoryIterator + // returns entries in filesystem order, which on the test image is + // already sorted - so removing the sort changes nothing that can be + // observed here. It is not removable: the order is not guaranteed + // by anything, and a listing that reorders itself between machines + // would be worse than useless. + "ignore": ["Crest\\Command\\Route\\ListCommand::handle"] + }, "IncrementInteger": { "ignore": [ + "Crest\\Project\\Bootstrap::container", "Crest\\Command\\Make\\ActionCommand::handle", // Widening the slice by one includes the '--' token itself, // which is never '--trace', '--help' or '-h'. @@ -102,7 +132,14 @@ "ignore": ["Crest\\Console\\Output::detectDecoration"] }, "Throw_": { - "ignore": ["Crest\\Adr\\PhalconRouterCandidates::candidatesFor"] + "ignore": ["Crest\\ADR\\PhalconRouterResolver::router"] + }, + "UnwrapArrayValues": { + // The routes are keyed by path so they can be sorted; table() + // iterates with foreach and ignores keys, so dropping the + // array_values() changes nothing at runtime. It stays because + // table() declares list> and PHPStan holds it to that. + "ignore": ["Crest\\Command\\Route\\ListCommand::handle"] }, "UnwrapTrim": { // is_dir() accepts a doubled separator, so trimming the psr-4 diff --git a/resources/stubs/adr/action-view.stub b/resources/stubs/adr/action-view.stub index bdfeff0..a005b5b 100644 --- a/resources/stubs/adr/action-view.stub +++ b/resources/stubs/adr/action-view.stub @@ -26,4 +26,4 @@ final class {{ class }} implements Action Payload::success() ); } -} +{{ params }}} diff --git a/resources/stubs/adr/action.stub b/resources/stubs/adr/action.stub index de516af..0c3e12a 100644 --- a/resources/stubs/adr/action.stub +++ b/resources/stubs/adr/action.stub @@ -24,4 +24,4 @@ final class {{ class }} implements Action return ($this->responder)($request, $this->response, $payload); } -} +{{ params }}} diff --git a/src/ADR/ActionResolver.php b/src/ADR/ActionResolver.php new file mode 100644 index 0000000..7fb2be2 --- /dev/null +++ b/src/ADR/ActionResolver.php @@ -0,0 +1,42 @@ + + * + * 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\ADR; + +/** + * Where an Action class name comes from. One method, so a flavor that routes + * differently supplies its own implementation without touching the generator. + * + * Was CandidateSource: the router used to offer several candidate classes per + * path and let the first that existed win. One path now names exactly one + * class, so there is nothing to choose between and nothing to shadow. + */ +interface ActionResolver +{ + /** + * The Action class the given method and path name, derived from the + * convention alone. The class need not exist - a generator is about to + * create it. + */ + public function classFor(string $baseNamespace, string $method, string $path): string; + + /** + * The path the given Action class answers, or null when the class is not + * one this convention would have produced. + * + * Unlike classFor(), the class must be loadable: trailing attributes come + * from its params() declaration, and an unloaded class reports none - so + * `/album/edit/{id}` would be printed as `/album/edit`. + */ + public function pathFor(string $baseNamespace, string $class): ?string; +} diff --git a/src/Adr/Convention.php b/src/ADR/Convention.php similarity index 55% rename from src/Adr/Convention.php rename to src/ADR/Convention.php index 8e52126..a9fb9b7 100644 --- a/src/Adr/Convention.php +++ b/src/ADR/Convention.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace Crest\Adr; +namespace Crest\ADR; use Crest\Console\Exceptions\Exception; @@ -30,56 +30,33 @@ /** * Turns a route into somewhere to write a file. * - * The route-to-class rules are the framework's and are reached through - * CandidateSource; this class only splits placeholders off the path and turns - * the winning class name into a relative file path. + * The route-to-class rule is the framework's and is reached through + * ActionResolver; this class only splits placeholders off the path and turns + * the resulting class name into a relative file path. */ final class Convention { private readonly string $baseNamespace; - private readonly CandidateSource $source; + private readonly ActionResolver $resolver; - public function __construct(string $baseNamespace, CandidateSource $source) + public function __construct(string $baseNamespace, ActionResolver $resolver) { // Router::setBaseNamespace() rtrims backslashes; relativePath below // subtracts strlen($this->baseNamespace), so both ends must agree or // the path is off by one character. $this->baseNamespace = rtrim($baseNamespace, '\\'); - $this->source = $source; + $this->resolver = $resolver; } /** - * Every class the router would try for this path, in try order. Used to - * warn when a generated action changes how an existing route resolves. - * - * @return list - */ - public function candidates(string $method, string $path): array - { - return $this->source->candidatesFor( - $this->baseNamespace, - $method, - $this->split($path)['path'] - ); - } - - /** - * The class crest generates for a route: the first candidate the router - * would try for the path's static prefix. + * The class crest generates for a route. */ public function target(string $method, string $path): Target { - $split = $this->split($path); - $candidates = $this->source->candidatesFor($this->baseNamespace, $method, $split['path']); - - if ([] === $candidates) { - throw new Exception( - sprintf("no action class could be derived for '%s %s'", $method, $path) - ); - } + $split = $this->split($path); + $fqcn = $this->resolver->classFor($this->baseNamespace, $method, $split['path']); - $fqcn = $candidates[0]; $position = strrpos($fqcn, '\\'); $namespace = false === $position ? '' : substr($fqcn, 0, $position); $class = false === $position ? $fqcn : substr($fqcn, $position + 1); @@ -112,13 +89,16 @@ private function segments(string $path): array } /** - * One pass over the path, producing both halves at once: the static prefix - * that identifies the class, and every placeholder name that becomes a - * request attribute. + * One pass over the path, producing both halves at once: the static + * segments that identify the class, and every placeholder name that becomes + * a request attribute. * - * The prefix stops at the first placeholder because the framework camelizes - * segments blindly - camelize('{id}') is '{id}' - so a placeholder passed - * through would end up inside a class name. + * Placeholders must come last. The convention encodes which segments exist + * in the class name, not where a value sits among them, so a static segment + * after a placeholder cannot be expressed - `/album/{id}/edit` has no class + * name that describes it. Rejecting it here is the difference between + * telling the user and silently writing a file that answers a different URL + * from the one they asked for. * * @return array{path: string, attributes: list} */ @@ -126,19 +106,26 @@ private function split(string $path): array { $attributes = []; $static = []; - $stopped = false; foreach ($this->segments($path) as $segment) { if (true === str_starts_with($segment, '{')) { - $stopped = true; $attributes[] = trim($segment, '{}'); continue; } - if (false === $stopped) { - $static[] = $segment; + if ([] !== $attributes) { + throw new Exception( + sprintf( + "'%s' cannot follow a placeholder; arguments come last, " + . "so write the route as '%s'", + $segment, + $this->suggest($path) + ) + ); } + + $static[] = $segment; } return [ @@ -146,4 +133,26 @@ private function split(string $path): array 'attributes' => $attributes, ]; } + + /** + * The same route with the placeholders moved to the end, which is how the + * convention spells it. + */ + private function suggest(string $path): string + { + $static = []; + $attributes = []; + + foreach ($this->segments($path) as $segment) { + if (true === str_starts_with($segment, '{')) { + $attributes[] = $segment; + + continue; + } + + $static[] = $segment; + } + + return '/' . implode('/', [...$static, ...$attributes]); + } } diff --git a/src/ADR/PhalconRouterResolver.php b/src/ADR/PhalconRouterResolver.php new file mode 100644 index 0000000..120b004 --- /dev/null +++ b/src/ADR/PhalconRouterResolver.php @@ -0,0 +1,61 @@ + + * + * 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\ADR; + +use Crest\Console\Exceptions\Exception; +use Phalcon\ADR\Router\Router; + +use function class_exists; + +/** + * Delegates to the framework's published contract, + * Phalcon\ADR\Router\Router::classFor(). Crest deliberately holds no copy of + * the routing convention. + * + * classFor() rather than candidatesFor() because the latter walks the action + * directory to find where static segments end, and a generator is called + * precisely when those directories do not exist yet. classFor() derives from + * the convention alone, and pathFor() inverts it exactly. + * + * Available in both variants - the phalcon/phalcon package on v6, the + * extension on v5 - because crest runs from the target project's vendor + * directory and shares its autoloader. + */ +final class PhalconRouterResolver implements ActionResolver +{ + public function classFor(string $baseNamespace, string $method, string $path): string + { + return $this->router($baseNamespace)->classFor($method, $path); + } + + public function pathFor(string $baseNamespace, string $class): ?string + { + return $this->router($baseNamespace)->pathFor($class); + } + + private function router(string $baseNamespace): Router + { + if (false === class_exists(Router::class)) { + throw new Exception( + 'this command needs Phalcon to resolve routes; install phalcon/phalcon ' + . 'or enable ext-phalcon in this project' + ); + } + + $router = new Router(); + $router->setBaseNamespace($baseNamespace); + + return $router; + } +} diff --git a/src/Adr/Target.php b/src/ADR/Target.php similarity index 97% rename from src/Adr/Target.php rename to src/ADR/Target.php index 1d6f732..7d4146b 100644 --- a/src/Adr/Target.php +++ b/src/ADR/Target.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace Crest\Adr; +namespace Crest\ADR; /** * A route resolved to the class that will answer it. diff --git a/src/Adr/CandidateSource.php b/src/Adr/CandidateSource.php deleted file mode 100644 index 8727724..0000000 --- a/src/Adr/CandidateSource.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * 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\Adr; - -/** - * Where candidate Action class names come from. One method, so a flavor that - * routes differently supplies its own implementation without touching the - * generator. - */ -interface CandidateSource -{ - /** - * Every Action class the router would try for this method and path, in try - * order, unfiltered by existence. - * - * @return list - */ - public function candidatesFor(string $baseNamespace, string $method, string $path): array; -} diff --git a/src/Adr/PhalconRouterCandidates.php b/src/Adr/PhalconRouterCandidates.php deleted file mode 100644 index 1a10231..0000000 --- a/src/Adr/PhalconRouterCandidates.php +++ /dev/null @@ -1,51 +0,0 @@ - - * - * 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\Adr; - -use Crest\Console\Exceptions\Exception; -use Phalcon\ADR\Router\Router; - -use function class_exists; - -/** - * Delegates to the framework's published contract, - * Phalcon\ADR\Router\Router::candidatesFor(). Crest deliberately holds no copy - * of the routing convention: locate() and candidatesFor() share one derivation - * inside the framework, so what crest is told here is what routing actually - * does. - * - * Available in both variants - the phalcon/phalcon package on v6, the - * extension on v5 - because crest runs from the target project's vendor - * directory and shares its autoloader. - */ -final class PhalconRouterCandidates implements CandidateSource -{ - /** - * @return list - */ - public function candidatesFor(string $baseNamespace, string $method, string $path): array - { - if (false === class_exists(Router::class)) { - throw new Exception( - 'make:action needs Phalcon to resolve the route; install phalcon/phalcon ' - . 'or enable ext-phalcon in this project' - ); - } - - $router = new Router(); - $router->setBaseNamespace($baseNamespace); - - return $router->candidatesFor($method, $path); - } -} diff --git a/src/Command/Config/ShowCommand.php b/src/Command/Config/ShowCommand.php new file mode 100644 index 0000000..2376f73 --- /dev/null +++ b/src/Command/Config/ShowCommand.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\Command\Config; + +use Crest\Console\Command\Command; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; +use Crest\Project\Config; + +use function ksort; + +/** + * The configuration crest resolved for this project. + * + * crest.php is optional, so most projects run entirely on values crest worked + * out from composer.json. Printing the values without saying where each came + * 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 +{ + private const DECLARED = 'declared'; + private const INFERRED = 'inferred'; + + public function define(): Definition + { + return Definition::for('config:show', 'Show the resolved project configuration'); + } + + public function handle(Input $input, Output $output): int + { + $config = Config::discover( + $input->optionStringOrNull('directory'), + $input->optionStringOrNull('config') + ); + + $source = $config->source(); + + $output->line('Source: ' . ($source ?? 'inferred from composer.json')); + $output->line(); + + $output->table( + ['ITEM', 'VALUE', 'ORIGIN'], + [ + ['root', $config->root(), self::INFERRED], + ['flavor', $config->flavor()->value, $this->origin($config, 'flavor')], + ['namespace', $config->namespace(), $this->origin($config, 'namespace')], + ] + ); + + $paths = $config->paths(); + ksort($paths); + + $rows = []; + foreach ($paths as $key => $path) { + $rows[] = [$key, $path, $this->origin($config, 'paths.' . $key)]; + } + + $output->line(); + $output->table(['PATH', 'LOCATION', 'ORIGIN'], $rows); + + return 0; + } + + private function origin(Config $config, string $key): string + { + return true === $config->isDeclared($key) ? self::DECLARED : self::INFERRED; + } +} diff --git a/src/Command/Container/ListCommand.php b/src/Command/Container/ListCommand.php new file mode 100644 index 0000000..3f9bf54 --- /dev/null +++ b/src/Command/Container/ListCommand.php @@ -0,0 +1,96 @@ + + * + * 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\Container; + +use Crest\Console\Command\Command; +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 function get_class; +use function sort; + +/** + * The services registered in the project's container. + * + * 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 +{ + public function define(): Definition + { + return Definition::for('container:list', 'List the services registered in the container'); + } + + public function handle(Input $input, Output $output): int + { + $config = Config::discover( + $input->optionStringOrNull('directory'), + $input->optionStringOrNull('config') + ); + + $container = $this->container(Bootstrap::container($config)); + + $names = $container->getServiceNames(); + sort($names); + + if ([] === $names) { + $output->line('no services registered'); + + return 0; + } + + $rows = []; + foreach ($names as $name) { + $rows[] = [ + $name, + $this->concrete($container, $name), + true === $container->hasInstance($name) ? 'yes' : 'no', + ]; + } + + $output->table(['SERVICE', 'CLASS', 'RESOLVED'], $rows); + + return 0; + } + + /** + * What the service builds, when the definition names a class. + */ + private function concrete(Container $container, string $name): string + { + $definition = $container->getDefinition($name); + + // A service built by a factory has no class to report, which is + // information rather than a gap. + return true === $definition->hasClass() ? $definition->getClass() : 'factory'; + } + + /** + * @throws Exception when the bootstrap returned something that is not a container + */ + private function container(object $container): Container + { + if (false === $container instanceof Container) { + throw new Exception(get_class($container) . ' is not a Phalcon container'); + } + + return $container; + } +} diff --git a/src/Command/Event/ListCommand.php b/src/Command/Event/ListCommand.php new file mode 100644 index 0000000..2494a77 --- /dev/null +++ b/src/Command/Event/ListCommand.php @@ -0,0 +1,140 @@ + + * + * 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\Event; + +use Crest\Console\Command\Command; +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\Events\Manager; + +use function get_class; +use function is_object; +use function method_exists; +use function sort; + +/** + * 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 + * to everything a component fires and listening to one moment. + */ +final class ListCommand extends Command +{ + public function define(): Definition + { + return Definition::for('event:list', 'List the listeners attached to the events manager'); + } + + public function handle(Input $input, Output $output): int + { + $config = Config::discover( + $input->optionStringOrNull('directory'), + $input->optionStringOrNull('config') + ); + + $manager = $this->manager(Bootstrap::container($config)); + + /** @var list $types */ + $types = $manager->getEventTypes(); + sort($types); + + if ([] === $types) { + $output->line('no listeners attached'); + + return 0; + } + + $rows = []; + foreach ($types as $type) { + foreach ($manager->getListeners($type) as $listener) { + $rows[] = [$type, $this->describe($listener)]; + } + } + + $output->table(['EVENT', 'LISTENER'], $rows); + + return 0; + } + + /** + * A listener may be an object, a closure or a callable array; the useful + * answer is which of those, and what it is. + */ + private function describe(mixed $listener): string + { + if (true === is_object($listener)) { + return get_class($listener); + } + + return 'callable'; + } + + /** + * 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 + { + foreach (['hasDefinition', 'hasInstance'] as $method) { + if (true === method_exists($container, $method) && true === $container->$method($name)) { + return true; + } + } + + return false; + } + + /** + * The events manager the container holds. + * + * 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 + { + if (false === $container instanceof Container) { + throw new Exception( + get_class($container) . ' is not a Phalcon container' + ); + } + + // has() is not the question. The container autowires, so it answers + // true for any instantiable class and get() would hand back a brand new + // Manager with nothing attached - reported as "no listeners", which + // reads as "your application has none" rather than "you never + // registered one". Only a definition or an existing instance means the + // project actually has an events manager. + if (false === $this->isRegistered($container, Manager::class)) { + throw new Exception( + 'the bootstrap registers no ' . Manager::class + . '; without one there are no listeners to list' + ); + } + + $manager = $container->get(Manager::class); + + if (false === $manager instanceof Manager) { + throw new Exception(Manager::class . ' resolved to something else'); + } + + return $manager; + } +} diff --git a/src/Command/ListCommand.php b/src/Command/ListCommand.php new file mode 100644 index 0000000..d77165d --- /dev/null +++ b/src/Command/ListCommand.php @@ -0,0 +1,57 @@ + + * + * 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\Commands; +use Crest\Console\Command\Command; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\PackageVersion; +use Crest\Console\Parsing\Definition; + +use function ksort; + +/** + * Every command the tool can run, one row each. + * + * The kernel prints the same listing when it is invoked with no arguments, but + * that path cannot be reached by name: it belongs to Crest\Console, which may + * not know that a command called "list" exists. This command is how the + * listing becomes addressable - and therefore aliasable and documentable - + * which is what devtools offered through `commands`, `list` and `enumerate`. + */ +final class ListCommand extends Command +{ + public function define(): Definition + { + return Definition::for('list', 'List the available commands'); + } + + 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); + + return 0; + } +} diff --git a/src/Command/Make/ActionCommand.php b/src/Command/Make/ActionCommand.php index 45b19ba..f2b6c75 100644 --- a/src/Command/Make/ActionCommand.php +++ b/src/Command/Make/ActionCommand.php @@ -13,9 +13,9 @@ namespace Crest\Command\Make; -use Crest\Adr\Convention; -use Crest\Adr\PhalconRouterCandidates; -use Crest\Adr\Target; +use Crest\ADR\Convention; +use Crest\ADR\PhalconRouterResolver; +use Crest\ADR\Target; use Crest\Console\Command\Command; use Crest\Console\Exceptions\Exception; use Crest\Console\Input; @@ -70,7 +70,10 @@ public function handle(Input $input, Output $output): int ); } - $convention = new Convention($config->namespaceFor('action'), new PhalconRouterCandidates()); + $convention = new Convention( + $config->namespaceFor('action'), + new PhalconRouterResolver() + ); $target = $convention->target( $input->argumentString('method'), $input->argumentString('path') @@ -82,8 +85,6 @@ public function handle(Input $input, Output $output): int throw new Exception(sprintf('%s already exists; pass --force to overwrite', $file)); } - $this->warnAboutCandidates($convention, $target, $output); - $stub = new Stub(Paths::stubs(), $config->root()); $contents = $stub->render( @@ -93,6 +94,7 @@ public function handle(Input $input, Output $output): int 'namespace' => $target->namespace, 'class' => $target->class, 'attributes' => $this->attributeBlock($target), + 'params' => $this->paramsBlock($target), 'template' => $this->template($target), ] ); @@ -128,35 +130,42 @@ private function attributeBlock(Target $target): string return implode("\n", $lines) . "\n\n"; } + /** + * The params() declaration for an Action's trailing attributes. + * + * Routing does not read this - the convention places arguments after the + * static path regardless. It exists so the attributes arrive constrained, + * cast and converted rather than as raw strings, which is why the emitted + * block is a starting point the user is expected to tighten. + */ + private function paramsBlock(Target $target): string + { + if ([] === $target->attributes) { + return ''; + } + + $lines = []; + foreach ($target->attributes as $name) { + $lines[] = sprintf(" '%s' => ['type' => 'string'],", $name); + } + + return "\n" + . " /**\n" + . " * Trailing route attributes, in path order. Constrains, casts and\n" + . " * converts them after the route has matched.\n" + . " */\n" + . " public static function params(): array\n" + . " {\n" + . " return [\n" + . implode("\n", $lines) . "\n" + . " ];\n" + . " }\n"; + } + private function template(Target $target): string { $path = trim(str_replace('{', '', str_replace('}', '', $target->path)), '/'); return ('' === $path ? 'index' : $path) . '/index'; } - - /** - * The router returns the first class that exists, so a class other than - * the target already sitting on disk means this path's routing is already - * claimed - or that the new class will never be reached. - */ - private function warnAboutCandidates( - Convention $convention, - Target $target, - Output $output, - ): void { - foreach ($convention->candidates($target->method, $target->path) as $candidate) { - if ($candidate === $target->fqcn) { - continue; - } - - if (false === class_exists($candidate)) { - continue; - } - - $output->line( - sprintf('Note: %s also matches this route and may take precedence', $candidate) - ); - } - } } diff --git a/src/Command/Route/ListCommand.php b/src/Command/Route/ListCommand.php new file mode 100644 index 0000000..31a2723 --- /dev/null +++ b/src/Command/Route/ListCommand.php @@ -0,0 +1,180 @@ + + * + * 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\Route; + +use Crest\ADR\ActionResolver; +use Crest\ADR\PhalconRouterResolver; +use Crest\Console\Command\Command; +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; + +/** + * Every route the application answers. + * + * Under ADR there is no routes file: a route is a class name, so the only way + * to answer "what URLs does this application serve?" is to read the Action + * classes and ask the framework what each one answers. That is what this does - + * it derives nothing itself. + */ +final class ListCommand extends Command +{ + public function define(): Definition + { + return Definition::for('route:list', 'List the routes the application answers'); + } + + public function handle(Input $input, Output $output): int + { + $config = Config::discover( + $input->optionStringOrNull('directory'), + $input->optionStringOrNull('config') + ); + + $base = $config->namespaceFor('action'); + $directory = $config->path('action'); + $resolver = new PhalconRouterResolver(); + + $routes = []; + + foreach ($this->actionFiles($directory) as $file => $class) { + $fqcn = $base . '\\' . $class; + + // Loaded, not merely named: trailing attributes come from the + // Action's params(), and an unloaded class reports none. + $this->load($file, $fqcn); + + $path = $resolver->pathFor($base, $fqcn); + + if (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]; + } + + if ([] === $routes) { + $output->line('no actions found in ' . $directory); + + return 0; + } + + // The filesystem yields files in whatever order it likes, which is not + // the order anyone wants to read routes in. + ksort($routes); + + // Keyed by path for sorting; table() wants a list. + $output->table(['METHOD', 'PATH', 'ACTION'], array_values($routes)); + + return 0; + } + + /** + * Every Action file under the action directory, keyed by path, valued by + * the class name relative to the base namespace. + * + * @return array + */ + private function actionFiles(string $directory): array + { + if (false === is_dir($directory)) { + return []; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS) + ); + + $found = []; + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ('php' !== $file->getExtension()) { + continue; + } + + $relative = substr($file->getPathname(), strlen($directory) + 1); + + $found[$file->getPathname()] = str_replace( + ['/', '.php'], + ['\\', ''], + $relative + ); + } + + return $found; + } + + /** + * Bring the Action into memory so its params() can be read. + * + * The guard is on the class, not the file. require_once keys on the path, + * so the same class reached by a different path - an autoloader that + * already found it, a second project directory in one process - would be + * declared twice and fatal. + * + * User code, so it may also fail to declare: a missing parent class, a + * syntax error. That is the project's problem to fix and not a reason for + * the listing to die, so the route is still reported, just without whatever + * params() would have added. + */ + private function load(string $file, string $class): void + { + if (true === class_exists($class, false)) { + return; + } + + try { + require_once $file; + } catch (Throwable) { + // 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/Commands.php b/src/Commands.php index dbaaae0..0535b5f 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -14,7 +14,12 @@ 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\Make\ActionCommand; +use Crest\Command\Route\ListCommand as RouteListCommand; use Crest\Console\Registry; /** @@ -47,7 +52,12 @@ public static function registry(): Registry { return (new Registry()) ->add('about', AboutCommand::class, 'info', 'i') + ->add('config:show', ConfigShowCommand::class) + ->add('container:list', ContainerListCommand::class) + ->add('event:list', EventListCommand::class) + ->add('list', ListCommand::class, 'commands', 'enumerate') ->add('make:action', ActionCommand::class) + ->add('route:list', RouteListCommand::class) ->withDiscovery(self::KEY); } } diff --git a/src/Project/Bootstrap.php b/src/Project/Bootstrap.php new file mode 100644 index 0000000..153697e --- /dev/null +++ b/src/Project/Bootstrap.php @@ -0,0 +1,97 @@ + + * + * 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\Project; + +use Crest\Console\Exceptions\Exception; +use Throwable; + +use function class_exists; +use function is_object; +use function method_exists; +use function sprintf; + +/** + * Runs the project's own wiring and hands back its container. + * + * Routes and configuration can be read off the filesystem; services and + * listeners cannot. They exist only once the application has registered them, + * so a command that lists them has to boot the project. + * + * The project names its front controller in crest.php: + * + * 'bootstrap' => App\Front\ApiFront::class + * + * The front is already the single definition of how the application wires + * itself, so naming it is the whole contract. Crest deliberately does not + * accept a separate bootstrap file: that would mean restating the wiring + * somewhere else, and a restatement nothing checks eventually disagrees - at + * which point crest would report a container the application never runs with, + * which is worse than reporting nothing. + * + * The requirement is a `boot()` returning a container, not a particular parent + * class, so a project fronting its application its own way needs three lines + * rather than an exemption. + */ +final class Bootstrap +{ + public static function container(Config $config): object + { + $class = $config->bootstrap(); + + if (null === $class) { + throw new Exception( + 'this command needs the project container; name the front controller ' + . "in crest.php, e.g. 'bootstrap' => App\\Front\\ApiFront::class" + ); + } + + if (false === class_exists($class)) { + throw new Exception(sprintf('front controller %s was not found', $class)); + } + + if (false === method_exists($class, 'boot')) { + throw new Exception( + sprintf( + '%s has no boot(); without one it cannot be started without also ' + . 'serving a request', + $class + ) + ); + } + + $front = new $class($config->root()); + + // A boot that prints is a defect in the project, and it shows up in the + // middle of the report where the developer will see it. Buffering it + // away would hide someone else's bug and teach nobody anything. + try { + $container = $front->boot(); + } catch (Throwable $exception) { + // Reported in crest's terms rather than as a bare stack trace. This + // is not suppression: the message and the previous exception both + // survive, and --trace prints the whole thing. + throw new Exception( + 'the project failed to boot: ' . $exception->getMessage(), + 0, + $exception + ); + } + + if (false === is_object($container)) { + throw new Exception(sprintf('%s::boot() did not return a container', $class)); + } + + return $container; + } +} diff --git a/src/Project/Config.php b/src/Project/Config.php index 13d4cd5..f3932c9 100644 --- a/src/Project/Config.php +++ b/src/Project/Config.php @@ -15,8 +15,10 @@ use Crest\Console\Exceptions\Exception; +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; @@ -49,6 +51,9 @@ final class Config * @param array $paths * @param array $namespaces * @param array $psr4 + * @param list $declared Top-level keys the config file + * actually stated, so a reader can + * tell those from the defaults. */ private function __construct( private readonly Flavor $flavor, @@ -57,6 +62,9 @@ private function __construct( private readonly array $paths, private readonly array $namespaces, private readonly array $psr4, + private readonly ?string $source = null, + private readonly array $declared = [], + private readonly ?string $bootstrap = null, ) { } @@ -74,7 +82,7 @@ public static function discover(?string $directory = null, ?string $configFile = /** @var array $declared */ $declared = require $file; - return self::fromArray($declared, dirname($file)); + return self::fromArray($declared, dirname($file), $file); } return self::infer($directory); @@ -150,6 +158,55 @@ public function namespaceFor(string $key): string 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. + * + * @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 + { + return $this->source; + } + /** * Absolute path for a named location. */ @@ -170,9 +227,19 @@ public function root(): string /** * @param array $declared */ - private static function fromArray(array $declared, string $root): self + private static function fromArray(array $declared, string $root, string $source): self { - $flavor = Flavor::Adr; + $stated = []; + + // `namespaces` is deliberately absent: nothing reads its origin yet, and + // tracking a key no caller asks about is a claim with no way to be wrong. + foreach (['flavor', 'namespace', 'paths'] as $key) { + if (true === isset($declared[$key])) { + $stated[] = $key; + } + } + + $flavor = Flavor::ADR; if (true === isset($declared['flavor']) && true === is_string($declared['flavor'])) { $flavor = Flavor::tryFrom($declared['flavor']) ?? throw new Exception(sprintf("unknown flavor '%s'", $declared['flavor'])); @@ -188,6 +255,12 @@ private static function fromArray(array $declared, string $root): self /** @var array $supplied */ $supplied = $declared['paths']; $paths = [...$paths, ...$supplied]; + + // Per key, not just the block: declaring `views` leaves `action` on + // its default, and calling that one declared would be a lie. + foreach (array_keys($supplied) as $name) { + $stated[] = 'paths.' . $name; + } } $namespaces = []; @@ -198,7 +271,22 @@ private static function fromArray(array $declared, string $root): self // crest.php never restates the autoload map; namespaceFor() still needs // it whenever `namespaces` does not answer the question outright. - return new self($flavor, $namespace, $root, $paths, $namespaces, self::psr4Map($root)); + $bootstrap = null; + if (true === isset($declared['bootstrap']) && true === is_string($declared['bootstrap'])) { + $bootstrap = $declared['bootstrap']; + } + + return new self( + $flavor, + $namespace, + $root, + $paths, + $namespaces, + self::psr4Map($root), + $source, + $stated, + $bootstrap + ); } /** @@ -220,7 +308,7 @@ private static function infer(string $directory): self } return new self( - Flavor::Adr, + Flavor::ADR, trim($prefix, '\\'), $directory, self::DEFAULT_PATHS, diff --git a/src/Project/Flavor.php b/src/Project/Flavor.php index 5fb838a..156a3df 100644 --- a/src/Project/Flavor.php +++ b/src/Project/Flavor.php @@ -19,7 +19,10 @@ */ enum Flavor: string { - case Adr = 'adr'; - case Cli = 'cli'; - case Mvc = 'mvc'; + // Names are the acronyms they stand for; the backed values stay lowercase + // because they are what a project writes in crest.php and what names the + // stub directory under resources/stubs. + case ADR = 'adr'; + case CLI = 'cli'; + case MVC = 'mvc'; } diff --git a/tests/Support/ADR/StubActionResolver.php b/tests/Support/ADR/StubActionResolver.php new file mode 100644 index 0000000..222df21 --- /dev/null +++ b/tests/Support/ADR/StubActionResolver.php @@ -0,0 +1,44 @@ + + * + * 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\ADR; + +use Crest\ADR\ActionResolver; + +/** + * Returns a canned class name and records what it was asked. Deliberately not + * a second implementation of the routing convention - it computes nothing. + */ +final class StubActionResolver implements ActionResolver +{ + /** @var list */ + public array $calls = []; + + public function __construct( + private readonly string $class, + private readonly ?string $path = null, + ) { + } + + public function classFor(string $baseNamespace, string $method, string $path): string + { + $this->calls[] = [$baseNamespace, $method, $path]; + + return $this->class; + } + + public function pathFor(string $baseNamespace, string $class): ?string + { + return $this->path; + } +} diff --git a/tests/Support/Adr/StubCandidateSource.php b/tests/Support/Adr/StubCandidateSource.php deleted file mode 100644 index 3298523..0000000 --- a/tests/Support/Adr/StubCandidateSource.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * 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\Adr; - -use Crest\Adr\CandidateSource; - -/** - * Returns canned candidates and records what it was asked. Deliberately not a - * second implementation of the routing convention - it computes nothing. - */ -final class StubCandidateSource implements CandidateSource -{ - /** @var list */ - public array $calls = []; - - /** - * @param list $candidates - */ - public function __construct( - private readonly array $candidates, - ) { - } - - /** - * @return list - */ - public function candidatesFor(string $baseNamespace, string $method, string $path): array - { - $this->calls[] = [$baseNamespace, $method, $path]; - - return $this->candidates; - } -} diff --git a/tests/Support/Project/EmptyFront.php b/tests/Support/Project/EmptyFront.php new file mode 100644 index 0000000..b79cb86 --- /dev/null +++ b/tests/Support/Project/EmptyFront.php @@ -0,0 +1,27 @@ + + * + * 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\Project; + +use Phalcon\Container\Container; + +/** + * Boots a container with nothing registered. + */ +final class EmptyFront +{ + public function boot(): Container + { + return new Container(); + } +} diff --git a/tests/Support/Project/EventsFront.php b/tests/Support/Project/EventsFront.php new file mode 100644 index 0000000..7e351eb --- /dev/null +++ b/tests/Support/Project/EventsFront.php @@ -0,0 +1,38 @@ + + * + * 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\Project; + +use Phalcon\Container\Container; +use Phalcon\Events\Manager; +use Phalcon\Support\HelperFactory; + +/** + * Listeners attached out of alphabetical order, one an object and one a closure. + */ +final class EventsFront +{ + public function boot(): Container + { + $events = new Manager(); + + $events->attach('zebra:fired', new HelperFactory()); + $events->attach('alpha', new HelperFactory()); + $events->attach('boot', static fn () => null); + + $container = new Container(); + $container->setInstance(Manager::class, $events, 'shared'); + + return $container; + } +} diff --git a/tests/Support/Project/FailingFront.php b/tests/Support/Project/FailingFront.php new file mode 100644 index 0000000..66615ef --- /dev/null +++ b/tests/Support/Project/FailingFront.php @@ -0,0 +1,28 @@ + + * + * 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\Project; + +use Phalcon\Container\Container; +use RuntimeException; + +/** + * Fails to boot, the way a missing database or a bad env file would. + */ +final class FailingFront +{ + public function boot(): Container + { + throw new RuntimeException('no database'); + } +} diff --git a/tests/Support/Project/ManagerOnlyFront.php b/tests/Support/Project/ManagerOnlyFront.php new file mode 100644 index 0000000..9b5caee --- /dev/null +++ b/tests/Support/Project/ManagerOnlyFront.php @@ -0,0 +1,32 @@ + + * + * 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\Project; + +use Phalcon\Container\Container; +use Phalcon\Events\Manager; + +/** + * An events manager is registered but nothing is attached to it. + */ +final class ManagerOnlyFront +{ + public function boot(): Container + { + $container = new Container(); + + $container->set(Manager::class, Manager::class); + + return $container; + } +} diff --git a/tests/Support/Project/NoBootFront.php b/tests/Support/Project/NoBootFront.php new file mode 100644 index 0000000..8192292 --- /dev/null +++ b/tests/Support/Project/NoBootFront.php @@ -0,0 +1,26 @@ + + * + * 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\Project; + +/** + * A front that can only serve, not be started - what a project predating + * boot() looks like. + */ +final class NoBootFront +{ + public function run(): int + { + return 0; + } +} diff --git a/tests/Support/Project/NonContainerFront.php b/tests/Support/Project/NonContainerFront.php new file mode 100644 index 0000000..cfd5b61 --- /dev/null +++ b/tests/Support/Project/NonContainerFront.php @@ -0,0 +1,27 @@ + + * + * 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\Project; + +/** + * Returns something that is not an object at all. Untyped on purpose: a front + * whose boot() declares a return type cannot do this, so the check exists for + * the one that does not. + */ +final class NonContainerFront +{ + public function boot(): mixed + { + return 42; + } +} diff --git a/tests/Support/Project/ServicesFront.php b/tests/Support/Project/ServicesFront.php new file mode 100644 index 0000000..fe824b7 --- /dev/null +++ b/tests/Support/Project/ServicesFront.php @@ -0,0 +1,34 @@ + + * + * 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\Project; + +use Phalcon\Container\Container; +use Phalcon\Support\HelperFactory; + +/** + * Two services declared out of alphabetical order, one already resolved. + */ +final class ServicesFront +{ + public function boot(): Container + { + $container = new Container(); + + $container->set('zebra', HelperFactory::class); + $container->set('alpha', HelperFactory::class); + $container->get('alpha'); + + return $container; + } +} diff --git a/tests/Support/Project/WrongContainerFront.php b/tests/Support/Project/WrongContainerFront.php new file mode 100644 index 0000000..a7e3d26 --- /dev/null +++ b/tests/Support/Project/WrongContainerFront.php @@ -0,0 +1,28 @@ + + * + * 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\Project; + +use stdClass; + +/** + * Boots something that is an object but not a Phalcon container - a project + * fronting a different framework, or one that returned the wrong thing. + */ +final class WrongContainerFront +{ + public function boot(): object + { + return new stdClass(); + } +} diff --git a/tests/Support/Project/WrongManagerFront.php b/tests/Support/Project/WrongManagerFront.php new file mode 100644 index 0000000..e03b526 --- /dev/null +++ b/tests/Support/Project/WrongManagerFront.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\Tests\Support\Project; + +use Phalcon\Container\Container; +use Phalcon\Events\Manager; +use Phalcon\Support\HelperFactory; + +/** + * Registers something else under the events manager's name. The service is + * genuinely declared, so the registration check passes and only the type check + * catches it. + */ +final class WrongManagerFront +{ + public function boot(): Container + { + $container = new Container(); + + $container->set(Manager::class, HelperFactory::class); + + return $container; + } +} diff --git a/tests/Unit/ADR/ConventionRoutingTest.php b/tests/Unit/ADR/ConventionRoutingTest.php new file mode 100644 index 0000000..797a1d3 --- /dev/null +++ b/tests/Unit/ADR/ConventionRoutingTest.php @@ -0,0 +1,115 @@ + + * + * 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\ADR; + +use Crest\ADR\Convention; +use Crest\ADR\PhalconRouterResolver; +use Phalcon\ADR\Router\Router; +use PHPUnit\Framework\TestCase; + +/** + * Conformance: every mapping crest relies on, produced by the framework rather + * than by crest. + * + * One path names exactly one class and that class names the same path back, so + * each route is asserted in both directions. If either side moves, crest is + * told here rather than by silently writing a file where nothing will route. + */ +final class ConventionRoutingTest extends TestCase +{ + /** + * @return iterable + */ + public static function referenceRoutes(): iterable + { + yield 'root' => ['GET', '/', 'App\Action\Get', 'Get.php']; + + yield 'resource' => [ + 'GET', + '/profiles', + 'App\Action\Profiles\GetProfiles', + 'Profiles/GetProfiles.php', + ]; + + yield 'operation' => [ + 'GET', + '/profiles/create', + 'App\Action\Profiles\Create\GetProfilesCreate', + 'Profiles/Create/GetProfilesCreate.php', + ]; + + yield 'company all' => [ + 'GET', + '/company/all', + 'App\Action\Company\All\GetCompanyAll', + 'Company/All/GetCompanyAll.php', + ]; + + yield 'placeholder' => [ + 'GET', + '/company/{id}', + 'App\Action\Company\GetCompany', + 'Company/GetCompany.php', + ]; + + yield 'dashed' => [ + 'POST', + '/session/forgot-password', + 'App\Action\Session\ForgotPassword\PostSessionForgotPassword', + 'Session/ForgotPassword/PostSessionForgotPassword.php', + ]; + } + + /** + * @dataProvider referenceRoutes + */ + public function testReferenceRouteResolvesThroughTheRealRouter( + string $method, + string $path, + string $fqcn, + string $relativePath, + ): void { + $target = $this->convention()->target($method, $path); + + $this->assertSame($fqcn, $target->fqcn); + $this->assertSame($relativePath, $target->relativePath); + } + + /** + * The inverse has to agree, or a generated file answers a different URL + * from the one it was generated for. + * + * @dataProvider referenceRoutes + */ + public function testReferenceRouteRoundTripsBackToItsPath( + string $method, + string $path, + string $fqcn, + string $relativePath, + ): void { + $router = new Router(); + $router->setBaseNamespace('App\Action'); + + // A placeholder is not part of the class, so the canonical path is the + // static prefix - '/company' for '/company/{id}'. + $expected = '/company/{id}' === $path ? '/company' : $path; + + $this->assertSame($expected, $router->pathFor($fqcn)); + } + + private function convention(): Convention + { + return new Convention('App\Action', new PhalconRouterResolver()); + } +} diff --git a/tests/Unit/ADR/ConventionTest.php b/tests/Unit/ADR/ConventionTest.php new file mode 100644 index 0000000..81bcbe7 --- /dev/null +++ b/tests/Unit/ADR/ConventionTest.php @@ -0,0 +1,132 @@ + + * + * 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\ADR; + +use Crest\ADR\Convention; +use Crest\Console\Exceptions\Exception; +use Crest\Tests\Support\ADR\StubActionResolver; +use PHPUnit\Framework\TestCase; + +final class ConventionTest extends TestCase +{ + public function testEmptyPathIsPassedThroughAsRoot(): void + { + $resolver = new StubActionResolver('App\Action\Get'); + + (new Convention('App\Action', $resolver))->target('GET', '/'); + + $this->assertSame(['App\Action', 'GET', '/'], $resolver->calls[0]); + } + + public function testMethodIsUppercasedOnTheTarget(): void + { + $resolver = new StubActionResolver('App\Action\Company\PostCompany'); + + $target = (new Convention('App\Action', $resolver))->target('post', '/company'); + + $this->assertSame('POST', $target->method); + } + + public function testTrailingPlaceholdersAllBecomeAttributes(): void + { + $resolver = new StubActionResolver('App\Action\Company\Users\GetCompanyUsers'); + + $target = (new Convention('App\Action', $resolver)) + ->target('GET', '/company/users/{id}/{userId}'); + + $this->assertSame(['id', 'userId'], $target->attributes); + $this->assertSame(['App\Action', 'GET', '/company/users'], $resolver->calls[0]); + } + + 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 testTheSuggestionKeepsEverySegmentInOrder(): void + { + // Two statics and two placeholders, so the suggestion has to carry all + // of both rather than the first of each. + $resolver = new StubActionResolver('App\Action\Album\Edit\GetAlbumEdit'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("so write the route as '/album/edit/{id}/{slug}'"); + + (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 + { + $resolver = new StubActionResolver('App\Action\Company\All\GetCompanyAll'); + + $target = (new Convention('App\Action', $resolver))->target('GET', '/company/all'); + + $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 testTrailingBackslashesOnTheBaseNamespaceAreIgnored(): void + { + // Router::setBaseNamespace() rtrims too; if the two disagreed the + // relative path would lose or keep a leading separator. + $resolver = new StubActionResolver('App\Action\Company\GetCompany'); + + $target = (new Convention('App\Action\\\\', $resolver))->target('GET', '/company'); + + $this->assertSame('Company/GetCompany.php', $target->relativePath); + $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 + { + $resolver = new StubActionResolver('App\Action\Get'); + + $target = (new Convention('App\Action', $resolver))->target('GET', '/'); + + $this->assertSame('Get.php', $target->relativePath); + $this->assertSame('App\Action', $target->namespace); + } +} diff --git a/tests/Unit/Adr/ConventionRoutingTest.php b/tests/Unit/Adr/ConventionRoutingTest.php deleted file mode 100644 index 65eba90..0000000 --- a/tests/Unit/Adr/ConventionRoutingTest.php +++ /dev/null @@ -1,76 +0,0 @@ - - * - * 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\Adr; - -use Crest\Adr\Convention; -use Crest\Adr\PhalconRouterCandidates; -use PHPUnit\Framework\TestCase; - -/** - * Conformance: every mapping crest relies on, produced by the framework rather - * than by crest. - */ -final class ConventionRoutingTest extends TestCase -{ - /** - * @return iterable - */ - public static function referenceRoutes(): iterable - { - yield 'root' => ['GET', '/', 'App\Action\Get', 'Get.php']; - yield 'resource' => ['GET', '/profiles', 'App\Action\Profiles\GetProfiles', 'Profiles/GetProfiles.php']; - yield 'operation' => [ - 'GET', - '/profiles/create', - 'App\Action\Profiles\GetProfilesCreate', - 'Profiles/GetProfilesCreate.php', - ]; - yield 'company all' => ['GET', '/company/all', 'App\Action\Company\GetCompanyAll', 'Company/GetCompanyAll.php']; - yield 'placeholder' => ['GET', '/company/{id}', 'App\Action\Company\GetCompany', 'Company/GetCompany.php']; - yield 'dashed' => [ - 'POST', - '/session/forgot-password', - 'App\Action\Session\PostSessionForgotPassword', - 'Session/PostSessionForgotPassword.php', - ]; - } - - /** - * @dataProvider referenceRoutes - */ - public function testReferenceRouteResolvesThroughTheRealRouter( - string $method, - string $path, - string $fqcn, - string $relativePath, - ): void { - $target = $this->convention()->target($method, $path); - - $this->assertSame($fqcn, $target->fqcn); - $this->assertSame($relativePath, $target->relativePath); - } - - public function testCandidatesComeBackInRouterPrecedenceOrder(): void - { - $candidates = $this->convention()->candidates('GET', '/profiles/create'); - - $this->assertSame('App\Action\Profiles\GetProfilesCreate', $candidates[0]); - $this->assertContains('App\Action\Profiles\GetProfiles', $candidates); - } - - private function convention(): Convention - { - return new Convention('App\Action', new PhalconRouterCandidates()); - } -} diff --git a/tests/Unit/Adr/ConventionTest.php b/tests/Unit/Adr/ConventionTest.php deleted file mode 100644 index e4330ec..0000000 --- a/tests/Unit/Adr/ConventionTest.php +++ /dev/null @@ -1,128 +0,0 @@ - - * - * 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\Adr; - -use Crest\Adr\Convention; -use Crest\Console\Exceptions\Exception; -use Crest\Tests\Support\Adr\StubCandidateSource; -use PHPUnit\Framework\TestCase; - -final class ConventionTest extends TestCase -{ - public function testEmptyPathIsPassedThroughAsRoot(): void - { - $source = new StubCandidateSource(['App\Action\Get']); - - (new Convention('App\Action', $source))->target('GET', '/'); - - $this->assertSame(['App\Action', 'GET', '/'], $source->calls[0]); - } - - public function testMethodIsUppercasedOnTheTarget(): void - { - $source = new StubCandidateSource(['App\Action\Company\PostCompany']); - - $target = (new Convention('App\Action', $source))->target('post', '/company'); - - $this->assertSame('POST', $target->method); - } - - public function testMultiplePlaceholdersAllBecomeAttributes(): void - { - $source = new StubCandidateSource(['App\Action\Company\GetCompany']); - - $target = (new Convention('App\Action', $source)) - ->target('GET', '/company/{id}/users/{userId}'); - - $this->assertSame(['id', 'userId'], $target->attributes); - $this->assertSame(['App\Action', 'GET', '/company'], $source->calls[0]); - } - - public function testOnlyTheStaticPrefixReachesTheSource(): void - { - $source = new StubCandidateSource(['App\Action\Company\GetCompany']); - - (new Convention('App\Action', $source))->target('GET', '/company/{id}'); - - $this->assertSame(['App\Action', 'GET', '/company'], $source->calls[0]); - } - - public function testTargetSplitsTheFirstCandidateIntoNamespaceClassAndPath(): void - { - $source = new StubCandidateSource([ - 'App\Action\Company\GetCompanyAll', - 'App\Action\Company\All\GetAll', - ]); - - $target = (new Convention('App\Action', $source))->target('GET', '/company/all'); - - $this->assertSame('App\Action\Company\GetCompanyAll', $target->fqcn); - $this->assertSame('App\Action\Company', $target->namespace); - $this->assertSame('GetCompanyAll', $target->class); - $this->assertSame('Company/GetCompanyAll.php', $target->relativePath); - $this->assertSame([], $target->attributes); - } - - public function testNoCandidatesIsAnError(): void - { - $source = new StubCandidateSource([]); - - $this->expectException(Exception::class); - $this->expectExceptionMessage("no action class could be derived for 'GET /company'"); - - (new Convention('App\Action', $source))->target('GET', '/company'); - } - - public function testCandidatesPassesTheStaticPrefixThroughToo(): void - { - $source = new StubCandidateSource(['App\Action\Company\GetCompany']); - - $candidates = (new Convention('App\Action', $source))->candidates('GET', '/company/{id}'); - - $this->assertSame(['App\Action\Company\GetCompany'], $candidates); - $this->assertSame(['App\Action', 'GET', '/company'], $source->calls[0]); - } - - public function testTrailingBackslashesOnTheBaseNamespaceAreIgnored(): void - { - // Router::setBaseNamespace() rtrims too; if the two disagreed the - // relative path would lose or keep a leading separator. - $source = new StubCandidateSource(['App\Action\Company\GetCompany']); - - $target = (new Convention('App\Action\\\\', $source))->target('GET', '/company'); - - $this->assertSame('Company/GetCompany.php', $target->relativePath); - $this->assertSame(['App\Action', 'GET', '/company'], $source->calls[0]); - } - - public function testCandidateWithNoNamespaceYieldsAnEmptyNamespace(): void - { - $source = new StubCandidateSource(['Get']); - - $target = (new Convention('', $source))->target('GET', '/'); - - $this->assertSame('', $target->namespace); - $this->assertSame('Get', $target->class); - } - - public function testTopLevelCandidateYieldsAFlatPath(): void - { - $source = new StubCandidateSource(['App\Action\Get']); - - $target = (new Convention('App\Action', $source))->target('GET', '/'); - - $this->assertSame('Get.php', $target->relativePath); - $this->assertSame('App\Action', $target->namespace); - } -} diff --git a/tests/Unit/Command/Config/ShowCommandTest.php b/tests/Unit/Command/Config/ShowCommandTest.php new file mode 100644 index 0000000..82e91ec --- /dev/null +++ b/tests/Unit/Command/Config/ShowCommandTest.php @@ -0,0 +1,203 @@ + + * + * 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\Config; + +use Crest\Command\Config\ShowCommand; +use Crest\Commands; +use Crest\Console\Kernel; +use Crest\Console\Registry; +use Crest\Tests\Support\CapturesOutput; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function file_put_contents; +use function preg_replace; + +use const PHP_EOL; + +final class ShowCommandTest extends TestCase +{ + use CapturesOutput; + use ScratchDirectory; + + protected function setUp(): void + { + $this->makeScratchDirectory('config-show', 'src/Action'); + $this->writeComposerJson(['App\\' => 'src/']); + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + public function testDefinitionNamesItselfConfigShow(): void + { + $this->assertSame('config:show', (new ShowCommand())->define()->getName()); + } + + public function testAnInferredProjectSaysSoAndShowsWhatWasInferred(): void + { + $status = $this->runCommand(); + + $output = $this->readStdout(); + + $this->assertSame(0, $status); + $this->assertStringContainsString('inferred from composer.json', $output); + $this->assertStringContainsString('adr', $output); + $this->assertStringContainsString('App', $output); + $this->assertStringContainsString($this->root, $output); + } + + public function testEveryValueIsMarkedInferredWhenThereIsNoConfigFile(): void + { + $this->runCommand(); + + $output = $this->readStdout(); + + $this->assertStringContainsString('inferred', $output); + $this->assertStringNotContainsString('declared', $output); + } + + public function testDeclaredValuesAreDistinguishedFromInferredOnes(): void + { + // flavor and namespace are declared; paths is not, so it keeps the + // default and must still read as inferred. + file_put_contents( + $this->root . '/crest.php', + " 'mvc', 'namespace' => 'Shop'];\n" + ); + + $this->runCommand(); + + $output = $this->readStdout(); + + $this->assertStringContainsString('declared', $output); + $this->assertStringContainsString('inferred', $output); + } + + public function testTheConfigFileIsNamedWhenOneWasUsed(): void + { + file_put_contents($this->root . '/crest.php', "runCommand(); + + $this->assertStringContainsString($this->root . '/crest.php', $this->readStdout()); + } + + public function testDeclaredPathsAreListed(): 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); + } + + public function testADefaultPathIsNotReportedAsDeclared(): 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); + } + + 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; + + $this->assertSame($expected, $this->normalised()); + } + + 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. + file_put_contents( + $this->root . '/crest.php', + " 'mvc', 'namespace' => 'Shop', " + . "'paths' => ['views' => 'templates', 'admin' => 'backend']];\n" + ); + + $this->runCommand(); + + $expected = 'Source: ' . $this->root . '/crest.php' . PHP_EOL + . PHP_EOL + . 'ITEM VALUE ORIGIN' . PHP_EOL + . 'root ' . $this->root . ' inferred' . PHP_EOL + . 'flavor mvc declared' . PHP_EOL + . '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()); + } + + /** + * The report is column-aligned, and the scratch directory name varies per + * run, so the padding does too. Collapsing runs of spaces lets the content + * be asserted exactly without asserting the width. + */ + private function normalised(): string + { + return (string) preg_replace('/ {2,}/', ' ', $this->readStdout()); + } + + private function runCommand(): int + { + $registry = (new Registry())->add('config:show', ShowCommand::class); + $kernel = new Kernel( + Commands::NAME, + $registry, + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + return $kernel->handle(['crest', 'config:show', '--directory', $this->root]); + } +} diff --git a/tests/Unit/Command/Container/ListCommandTest.php b/tests/Unit/Command/Container/ListCommandTest.php new file mode 100644 index 0000000..0cb83a1 --- /dev/null +++ b/tests/Unit/Command/Container/ListCommandTest.php @@ -0,0 +1,181 @@ + + * + * 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\Container; + +use Crest\Command\Container\ListCommand; +use Crest\Commands; +use Crest\Console\Kernel; +use Crest\Console\Registry; +use Crest\Tests\Support\CapturesOutput; +use Crest\Tests\Support\Project\EmptyFront; +use Crest\Tests\Support\Project\FailingFront; +use Crest\Tests\Support\Project\NoBootFront; +use Crest\Tests\Support\Project\NonContainerFront; +use Crest\Tests\Support\Project\ServicesFront; +use Crest\Tests\Support\Project\WrongContainerFront; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function file_put_contents; +use function preg_replace; +use function str_replace; + +use const PHP_EOL; + +final class ListCommandTest extends TestCase +{ + use CapturesOutput; + use ScratchDirectory; + + protected function setUp(): void + { + $this->makeScratchDirectory('container-list', 'src/Action'); + $this->writeComposerJson(['App\\' => 'src/']); + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + public function testDefinitionNamesItselfContainerList(): void + { + $this->assertSame('container:list', (new ListCommand())->define()->getName()); + } + + 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", + $this->readStderr() + ); + } + + public function testAnUnknownFrontControllerIsReported(): void + { + $this->declareFront('App\Front\NoSuchFront'); + + $status = $this->runCommand(); + + $this->assertSame(1, $status); + $this->assertStringContainsString('was not found', $this->readStderr()); + } + + public function testAFrontWithNoBootIsReported(): void + { + $this->declareFront(NoBootFront::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', + $this->readStderr() + ); + } + + public function testABootReturningANonObjectIsReported(): void + { + $this->declareFront(NonContainerFront::class); + + $status = $this->runCommand(); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + NonContainerFront::class . '::boot() did not return a container', + $this->readStderr() + ); + } + + public function testABootReturningSomethingOtherThanAPhalconContainerIsReported(): void + { + $this->declareFront(WrongContainerFront::class); + + $status = $this->runCommand(); + + $this->assertSame(1, $status); + $this->assertStringContainsString('stdClass is not a Phalcon container', $this->readStderr()); + } + + public function testABootThatThrowsIsReportedAsABootFailure(): void + { + $this->declareFront(FailingFront::class); + + $status = $this->runCommand(); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + 'the project failed to boot: no database', + $this->readStderr() + ); + } + + public function testServicesAreListedSortedWithClassAndResolvedState(): void + { + $this->declareFront(ServicesFront::class); + + $status = $this->runCommand(); + + $expected = 'SERVICE CLASS RESOLVED' . PHP_EOL + . 'alpha Phalcon\Support\HelperFactory yes' . PHP_EOL + . 'zebra Phalcon\Support\HelperFactory no' . PHP_EOL; + + $this->assertSame(0, $status); + $this->assertSame($expected, $this->normalised()); + } + + public function testAnEmptyContainerSaysSo(): void + { + $this->declareFront(EmptyFront::class); + + $status = $this->runCommand(); + + $this->assertSame(0, $status); + $this->assertSame('no services registered' . PHP_EOL, $this->readStdout()); + } + + private function declareFront(string $class): void + { + file_put_contents( + $this->root . '/crest.php', + " '" . str_replace('\\', '\\\\', $class) . "'];\n" + ); + } + + private function normalised(): string + { + return (string) preg_replace('/ {2,}/', ' ', $this->readStdout()); + } + + private function runCommand(): int + { + $registry = (new Registry())->add('container:list', ListCommand::class); + $kernel = new Kernel( + Commands::NAME, + $registry, + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + return $kernel->handle(['crest', 'container:list', '--directory', $this->root]); + } +} diff --git a/tests/Unit/Command/Event/ListCommandTest.php b/tests/Unit/Command/Event/ListCommandTest.php new file mode 100644 index 0000000..9a52087 --- /dev/null +++ b/tests/Unit/Command/Event/ListCommandTest.php @@ -0,0 +1,152 @@ + + * + * 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\Event; + +use Crest\Command\Event\ListCommand; +use Crest\Commands; +use Crest\Console\Kernel; +use Crest\Console\Registry; +use Crest\Tests\Support\CapturesOutput; +use Crest\Tests\Support\Project\EmptyFront; +use Crest\Tests\Support\Project\EventsFront; +use Crest\Tests\Support\Project\ManagerOnlyFront; +use Crest\Tests\Support\Project\WrongContainerFront; +use Crest\Tests\Support\Project\WrongManagerFront; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function file_put_contents; +use function preg_replace; +use function str_replace; + +use const PHP_EOL; + +final class ListCommandTest extends TestCase +{ + use CapturesOutput; + use ScratchDirectory; + + protected function setUp(): void + { + $this->makeScratchDirectory('event-list', 'src/Action'); + $this->writeComposerJson(['App\\' => 'src/']); + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + $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 + // "no listeners attached" off that would read as a fact about the + // application rather than about its bootstrap. + $this->declareFront(EmptyFront::class); + + $status = $this->runCommand(); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + 'the bootstrap registers no Phalcon\Events\Manager; without one there are ' + . 'no listeners to list', + $this->readStderr() + ); + } + + public function testAManagerWithNoListenersSaysSo(): void + { + $this->declareFront(ManagerOnlyFront::class); + + $status = $this->runCommand(); + + $this->assertSame(0, $status); + $this->assertSame('no listeners attached' . PHP_EOL, $this->readStdout()); + } + + public function testListenersAreListedSortedByEvent(): void + { + $this->declareFront(EventsFront::class); + + $status = $this->runCommand(); + + $expected = 'EVENT LISTENER' . PHP_EOL + . 'alpha Phalcon\Support\HelperFactory' . PHP_EOL + . 'boot Closure' . PHP_EOL + . '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()); + } + + public function testSomethingElseRegisteredAsTheManagerIsReported(): void + { + // Declared under the right name, so the registration check passes and + // only the type check catches it. + $this->declareFront(WrongManagerFront::class); + + $status = $this->runCommand(); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + 'Phalcon\Events\Manager resolved to something else', + $this->readStderr() + ); + } + + private function declareFront(string $class): void + { + file_put_contents( + $this->root . '/crest.php', + " '" . str_replace('\\', '\\\\', $class) . "'];\n" + ); + } + + private function normalised(): string + { + return (string) preg_replace('/ {2,}/', ' ', $this->readStdout()); + } + + private function runCommand(): int + { + $registry = (new Registry())->add('event:list', ListCommand::class); + $kernel = new Kernel( + Commands::NAME, + $registry, + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + return $kernel->handle(['crest', 'event:list', '--directory', $this->root]); + } +} diff --git a/tests/Unit/Command/ListCommandTest.php b/tests/Unit/Command/ListCommandTest.php new file mode 100644 index 0000000..9893922 --- /dev/null +++ b/tests/Unit/Command/ListCommandTest.php @@ -0,0 +1,141 @@ + + * + * 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; + +use Composer\InstalledVersions; +use Crest\Command\ListCommand; +use Crest\Commands; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\PackageVersion; +use Crest\Console\Parsing\Bound; +use Crest\Tests\Support\CapturesOutput; +use Crest\Tests\Support\Console\FakeCommand; +use PHPUnit\Framework\TestCase; + +use function strpos; + +use const PHP_EOL; + +final class ListCommandTest extends TestCase +{ + use CapturesOutput; + + /** + * @var array{ + * root: array{ + * name: string, pretty_version: string, version: string, + * reference: string|null, type: string, install_path: string, + * aliases: string[], dev: bool + * }, + * versions: array}> + * } + */ + private array $installed; + + protected function setUp(): void + { + $this->captureStreams(); + + $this->installed = InstalledVersions::getAllRawData()[0]; + } + + protected function tearDown(): void + { + InstalledVersions::reload($this->installed); + + $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 + . '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(); + + $output = $this->readStdout(); + + $this->assertLessThan(strpos($output, 'make:action'), strpos($output, 'about')); + } + + public function testContributedCommandsAreSortedInAmongTheSeededOnes(): void + { + // Discovery appends, so a contributed command arrives after every + // seeded one no matter what it is called. Sorting is what puts it back + // where a reader expects it - and the seeded names are already + // alphabetical, so this is the only way to prove the sort happens. + InstalledVersions::reload([ + 'root' => $this->installed['root'], + 'versions' => [ + 'vendor/pkg' => [ + 'dev_requirement' => false, + 'extra' => [ + Commands::KEY => ['commands' => ['aaa:first' => FakeCommand::class]], + ], + ], + ], + ]); + + $this->listCommands(); + + $output = $this->readStdout(); + + $this->assertLessThan(strpos($output, 'about'), strpos($output, 'aaa:first')); + } + + private function listCommands(): int + { + $output = new Output($this->stdout, $this->stderr, false); + $input = new Input('list', new Bound([], [], [])); + + return (new ListCommand())->handle($input, $output); + } +} diff --git a/tests/Unit/Command/Make/ActionCommandTest.php b/tests/Unit/Command/Make/ActionCommandTest.php index 096652d..6dae034 100644 --- a/tests/Unit/Command/Make/ActionCommandTest.php +++ b/tests/Unit/Command/Make/ActionCommandTest.php @@ -19,6 +19,7 @@ use Crest\Console\Registry; use Crest\Tests\Support\CapturesOutput; use Crest\Tests\Support\ScratchDirectory; +use Phalcon\ADR\Router\Router; use PHPUnit\Framework\TestCase; use function chdir; @@ -97,14 +98,14 @@ public function testStaticTwoSegmentPathWritesTheOperationAction(): void { $status = $this->runCommand(['GET', '/company/all']); - $file = $this->root . '/src/Action/Company/GetCompanyAll.php'; + $file = $this->root . '/src/Action/Company/All/GetCompanyAll.php'; $this->assertSame(0, $status); $this->assertFileExists($file); $contents = (string) file_get_contents($file); - $this->assertStringContainsString('namespace App\Action\Company;', $contents); + $this->assertStringContainsString('namespace App\Action\Company\All;', $contents); $this->assertStringContainsString('final class GetCompanyAll implements Action', $contents); $this->assertStringContainsString('Responder $responder', $contents); } @@ -131,9 +132,11 @@ public function testWritesTheAttributeAccessorForPlaceholders(): void public function testAttributeAccessorsAreSeparatedFromTheBodyByABlankLine(): void { - $this->runCommand(['GET', '/company/{id}/users/{userId}']); + $this->runCommand(['GET', '/company/users/{id}/{userId}']); - $contents = (string) file_get_contents($this->root . '/src/Action/Company/GetCompany.php'); + $contents = (string) file_get_contents( + $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. @@ -145,6 +148,62 @@ public function testAttributeAccessorsAreSeparatedFromTheBodyByABlankLine(): voi $this->assertStringContainsString($expected, $contents); } + public function testPlaceholdersProduceAParamsDeclaration(): void + { + $this->runCommand(['GET', '/album/edit/{id}']); + + $contents = (string) file_get_contents( + $this->root . '/src/Action/Album/Edit/GetAlbumEdit.php' + ); + + // Asserted whole, through to the closing brace, because the block is + // built by concatenation - a substring check lets a dropped line or a + // missing separator through, and this is generated code nobody reviews. + $expected = " }\n" + . "\n" + . " /**\n" + . " * Trailing route attributes, in path order. Constrains, casts and\n" + . " * converts them after the route has matched.\n" + . " */\n" + . " public static function params(): array\n" + . " {\n" + . " return [\n" + . " 'id' => ['type' => 'string'],\n" + . " ];\n" + . " }\n" + . "}"; + + $this->assertStringContainsString($expected, $contents); + } + + public function testParamsAreDeclaredInPathOrder(): void + { + $this->runCommand(['GET', '/company/users/{id}/{userId}']); + + $contents = (string) file_get_contents( + $this->root . '/src/Action/Company/Users/GetCompanyUsers.php' + ); + + // 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 + ); + } + + public function testActionWithNoPlaceholdersDeclaresNoParams(): void + { + $this->runCommand(['GET', '/health']); + + $contents = (string) file_get_contents( + $this->root . '/src/Action/Health/GetHealth.php' + ); + + $this->assertStringNotContainsString('params()', $contents); + } + public function testViewTemplateStripsPlaceholderBraces(): void { $this->runCommand(['GET', '/company/{id}', '--responder=view']); @@ -216,34 +275,22 @@ public function testResponderNameIsCaseInsensitive(): void ); } - public function testExistingLowerPrecedenceCandidateIsReported(): void + public function testGeneratedActionIsTheOnlyClassThatAnswersItsRoute(): void { - // Crest\Tests\Support\Action\Company\GetCompany exists, and the router - // lists it as a candidate for GET /company/all behind GetCompanyAll. - // Generating that route must say so. - file_put_contents( - $this->root . '/crest.php', - " " - . "['action' => 'Crest\\\\Tests\\\\Support\\\\Action']];\n" - ); + // 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']); - $status = $this->runCommand(['GET', '/company/all']); + $router = new Router(); + $router->setBaseNamespace('App\Action'); - $this->assertSame(0, $status); - $this->assertStringContainsString( - 'Note: Crest\Tests\Support\Action\Company\GetCompany also matches this route', - $this->readStdout() + $this->assertSame( + '/company/all', + $router->pathFor('App\Action\Company\All\GetCompanyAll') ); } - public function testNothingIsReportedWhenNoOtherCandidateExists(): void - { - $status = $this->runCommand(['GET', '/company/all']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString('Note:', $this->readStdout()); - } - public function testCreatedPathAndRouteAreReported(): void { $this->runCommand(['GET', '/company/all']); @@ -251,7 +298,7 @@ public function testCreatedPathAndRouteAreReported(): void $output = $this->readStdout(); $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Action/Company/GetCompanyAll.php', + 'Created ' . $this->root . '/src/Action/Company/All/GetCompanyAll.php', $output ); $this->assertStringContainsString('Answers GET /company/all', $output); diff --git a/tests/Unit/Command/Route/ListCommandTest.php b/tests/Unit/Command/Route/ListCommandTest.php new file mode 100644 index 0000000..3338a3d --- /dev/null +++ b/tests/Unit/Command/Route/ListCommandTest.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\Command\Route; + +use Crest\Command\Route\ListCommand; +use Crest\Commands; +use Crest\Console\Kernel; +use Crest\Console\Registry; +use Crest\Tests\Support\CapturesOutput; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function file_put_contents; +use function is_dir; +use function mkdir; +use function strpos; + +use const PHP_EOL; + +final class ListCommandTest extends TestCase +{ + use CapturesOutput; + use ScratchDirectory; + + protected function setUp(): void + { + $this->makeScratchDirectory('route-list', 'src/Action'); + $this->writeComposerJson(['App\\' => 'src/']); + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + public function testDefinitionNamesItselfRouteList(): void + { + $this->assertSame('route:list', (new ListCommand())->define()->getName()); + } + + public function testListsAStaticRoute(): void + { + $this->writeAction('Health', 'GetHealth', 'App\Action\Health'); + + $status = $this->runCommand(); + + $output = $this->readStdout(); + + $this->assertSame(0, $status); + $this->assertStringContainsString('GET', $output); + $this->assertStringContainsString('/health', $output); + $this->assertStringContainsString('App\Action\Health\GetHealth', $output); + } + + public function testShowsTrailingAttributesFromParams(): 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->runCommand(); + + $this->assertStringContainsString('/album/edit/{id}', $this->readStdout()); + } + + public function testRoutesAreSortedByPath(): void + { + // A parent and its child: the shallower path sorts first, but the + // directory walk descends into the subdirectory whenever the filesystem + // hands it back first, so this only passes because the listing sorts. + $this->writeAction('Store/Items', 'GetStoreItems', 'App\Action\Store\Items'); + $this->writeAction('Store', 'GetStore', 'App\Action\Store'); + $this->writeAction('Zebra', 'GetZebra', 'App\Action\Zebra'); + $this->writeAction('Alpha', 'GetAlpha', 'App\Action\Alpha'); + + $this->runCommand(); + + $expected = 'METHOD PATH ACTION' . PHP_EOL + . 'GET /alpha App\Action\Alpha\GetAlpha' . PHP_EOL + . 'GET /store App\Action\Store\GetStore' . PHP_EOL + . 'GET /store/items App\Action\Store\Items\GetStoreItems' . PHP_EOL + . 'GET /zebra App\Action\Zebra\GetZebra' . PHP_EOL; + + $this->assertSame($expected, $this->readStdout()); + } + + public function testTheWholeTableIsRendered(): 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'); + + $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() + ); + } + + public function testNonPhpFilesAreIgnored(): void + { + $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(); + + $output = $this->readStdout(); + + $this->assertStringContainsString('/health', $output); + $this->assertStringNotContainsString('notes', $output); + } + + 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()); + } + + private function writeAction( + string $directory, + string $class, + string $namespace, + string $body = '' + ): void { + $target = $this->root . '/src/Action/' . $directory; + + if (false === is_dir($target)) { + mkdir($target, 0o775, true); + } + + file_put_contents( + $this->root . '/src/Action/' . $directory . '/' . $class . '.php', + "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/CommandsTest.php b/tests/Unit/CommandsTest.php index c6b9aff..1716c10 100644 --- a/tests/Unit/CommandsTest.php +++ b/tests/Unit/CommandsTest.php @@ -16,6 +16,7 @@ use Crest\Commands; use PHPUnit\Framework\TestCase; +use function array_keys; use function sprintf; final class CommandsTest extends TestCase @@ -51,4 +52,31 @@ public function testRegistryResolvesTheAboutAliases(): void $this->assertTrue($registry->has('info')); $this->assertTrue($registry->has('i')); } + + public function testRegistryResolvesTheListAliases(): void + { + // devtools offered all three spellings; retiring it means answering to + // each of them. + $registry = Commands::registry(); + + $this->assertTrue($registry->has('list')); + $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/IsolationTest.php b/tests/Unit/Console/IsolationTest.php index 8391ef7..667eb14 100644 --- a/tests/Unit/Console/IsolationTest.php +++ b/tests/Unit/Console/IsolationTest.php @@ -38,7 +38,7 @@ final class IsolationTest extends TestCase private const FORBIDDEN_IN_CONSOLE = [ 'Crest\\Command', 'Crest\\Project', - 'Crest\\Adr', + 'Crest\\ADR', 'Crest\\Generator', 'crest', 'Crest ', diff --git a/tests/Unit/Generator/StubTest.php b/tests/Unit/Generator/StubTest.php index 2d5355d..8f88b45 100644 --- a/tests/Unit/Generator/StubTest.php +++ b/tests/Unit/Generator/StubTest.php @@ -63,6 +63,7 @@ public function testShippedActionStubsExistAndAreValidPhp(): void 'namespace' => 'App\Action\Health', 'class' => 'GetHealth', 'attributes' => '', + 'params' => '', 'template' => 'health/index', ]); diff --git a/tests/Unit/Project/ConfigTest.php b/tests/Unit/Project/ConfigTest.php index de0aed9..5fd7bc9 100644 --- a/tests/Unit/Project/ConfigTest.php +++ b/tests/Unit/Project/ConfigTest.php @@ -49,7 +49,7 @@ public function testCrestPhpMayDeclareTheNamespaceExplicitly(): void $config = Config::discover($this->root); - $this->assertSame(Flavor::Mvc, $config->flavor()); + $this->assertSame(Flavor::MVC, $config->flavor()); $this->assertSame('Shop', $config->namespace()); $this->assertSame($this->root . '/app/Handlers', $config->path('action')); $this->assertSame('Shop\Handlers', $config->namespaceFor('action')); @@ -74,7 +74,7 @@ public function testInfersNamespaceAndActionPathFromComposerJson(): void $config = Config::discover($this->root); - $this->assertSame(Flavor::Adr, $config->flavor()); + $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());