diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 250e8ae..50e8e7f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,13 +21,16 @@ env: LANGUAGE: en_US.UTF-8 LC_ALL: en_US.UTF-8 - # PHP extensions required by Composer and the suite. No Phalcon extension: - # this workflow runs the v6 variant, where Phalcon arrives as the - # phalcon/phalcon composer dev dependency. When ext-phalcon 5.18 ships, the - # v5 variant is added by extending the matrix and installing the extension - # through setup-php's `extensions` input. + # PHP extensions required by Composer and the suite. The Phalcon extension is + # not here: the unit-tests job installs it for the v5 variant only. The v6 + # variant, and the other jobs, get Phalcon from the phalcon/phalcon composer + # dev dependency. EXTENSIONS: mbstring, zip + # The cphalcon constraint for the v5 variant. 5.18 is the first release with + # Phalcon\ADR. + PHALCON_CONSTRAINT: "^5.18" + permissions: { } concurrency: @@ -64,6 +67,9 @@ jobs: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: "Validate composer" + run: composer validate --no-check-all --no-check-publish + # No --ignore-platform-reqs: composer.json pins config.platform.php to # 8.1.0, so the lock resolves against the floor and installs cleanly on # every matrix cell. @@ -78,8 +84,11 @@ jobs: - name: "PHPStan" run: composer analyze + - name: "PHP CS Fixer (dry-run)" + run: composer cs-fixer + unit-tests: - name: "Unit tests" + name: "Unit tests (PHP ${{ matrix.php }}, Phalcon ${{ matrix.variant }})" permissions: contents: read @@ -100,6 +109,10 @@ jobs: - '8.3' - '8.4' - '8.5' + variant: + # v5 = cphalcon C extension; v6 = phalcon/phalcon composer package + - 'v5' + - 'v6' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -117,6 +130,20 @@ jobs: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # v5 only, with the same PIE install as resources/docker/Dockerfile. + # PIE writes to the extension directory, so it needs sudo. sudo resets + # PATH, so "$(which php)" names the PHP that setup-php installed. + # phalcon/phalcon stays in vendor/, as in the v5 dev container. The + # extension classes load first, so the package does not replace them. + - name: "Install the Phalcon extension" + if: matrix.variant == 'v5' + env: + PIE_VERSION: '1.5.0' + run: | + curl -fsSL "https://github.com/php/pie/releases/download/${PIE_VERSION}/pie.phar" -o "${RUNNER_TEMP}/pie.phar" + sudo "$(which php)" "${RUNNER_TEMP}/pie.phar" install --no-interaction "phalcon/cphalcon:${PHALCON_CONSTRAINT}" + php -m | grep -i phalcon + # No --ignore-platform-reqs: composer.json pins config.platform.php to # 8.1.0, so the lock resolves against the floor and installs cleanly on # every matrix cell. @@ -177,7 +204,10 @@ jobs: - name: "Tests with coverage" run: composer test-coverage + # Push only: a pull request from a fork gets no SONAR_TOKEN. SonarQube + # runs before octocov, so a failed octocov gate cannot skip the upload. - name: "SonarQube Scan" + if: github.event_name == 'push' uses: SonarSource/sonarqube-scan-action@ba9859eae8dd6bd29e412f25ddbbef3d032000f4 # v8.2.2 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bafe2f6..3c4d50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `Crest\Command\ProjectCommand::writer()`, assembling the stub writer once instead of repeating the same three-argument construction in five `make:*` commands. [#5](https://github.com/phalcon/crest/issues/5) - Added `methodFor()` to `Crest\ADR\ActionResolver`, so the HTTP method an Action answers is asked of the framework rather than derived from the class name. [#1](https://github.com/phalcon/crest/issues/1) - Added `Crest\Console\Registry::descriptions()` and `Crest\Console\Output::commandTable()`, so a bare `crest` and `crest list` render the command listing through one path instead of two copies kept in agreement by hand. +- Added `new`, creating an ADR project from stubs: front controller, web entry point, `crest.php`, an action for `GET /`, `composer.json` and docker files. It runs nothing - no composer, no docker, no network. `--namespace`, `--php` and `--phalcon` set the root namespace, the PHP version and the Phalcon variant. The project requires `phalcon/crest` as a dev dependency: the commands that work on the project need its autoloader and its Phalcon, so they run as `vendor/bin/crest`, not with the crest that created the project. The generated `crest.php` states `paths.action`, so a later crest default does not move the actions. `new` renders every file before it writes the first one, so a published stub that does not render leaves no files. [#8](https://github.com/phalcon/crest/issues/8) +- Added `up`, `down` and `install`, running `docker compose up -d`, `docker compose down` and `composer install` in the `app` container of a project that `new` created. `--directory` names the project. [#8](https://github.com/phalcon/crest/issues/8) +- Added `Crest\Process\Runner`, the seam through which commands run external programs, with `ShellRunner` as the default. A missing program or working directory is reported as a crest error. [#8](https://github.com/phalcon/crest/issues/8) +- Added `Crest\Generator\ClassName::namespace()`, validating a namespace with the same identifier rule as a class name. [#8](https://github.com/phalcon/crest/issues/8) +- Added `Crest\Command\Make\NamedArtifactCommand`, the base of `make:command`, `make:middleware`, `make:provider` and `make:responder`. The four commands repeated the same `handle()` and `define()`; each now gives only its key, its suffix, its description, an example name and the instructions it prints after the file is written. The base declares the `name` argument and the `--force` option, because `handle()` reads both, so a new generator cannot leave them out. ### Changed @@ -40,6 +45,8 @@ All notable changes are documented here. The format is based on [Keep a Changelo - `route:list` and `make:action` now accept an `ActionResolver`, defaulted so the kernel still constructs them with no arguments. This is what lets a test prove the routing answers come from the framework rather than from crest. [#5](https://github.com/phalcon/crest/issues/5) - `event:list` now reads every listener in a single `getListenerMap()` call instead of one call per event type. [#1](https://github.com/phalcon/crest/issues/1) - `phalcon/talon` moved from `^0.8` to `^0.9`. +- `stub:publish` with no name leaves out the `project-*` stubs that `new` renders. They have an effect only in the directory that `new` puts the project into; publish one by name. A `project-*` name goes where `new` reads it - the working directory, or `--directory` - with the `adr` flavor, and needs no `crest.php`. [#8](https://github.com/phalcon/crest/issues/8) +- Rendering a stub now fails when a placeholder has no value, and the error names the stub file. A published copy that kept a placeholder crest no longer sends put the raw `{{ name }}` into the generated file. [#8](https://github.com/phalcon/crest/issues/8) ### Fixed diff --git a/README.md b/README.md index 9e589d1..cba3115 100644 --- a/README.md +++ b/README.md @@ -20,25 +20,40 @@ Command line application for Phalcon - generators, introspection and project too ## Requirements - PHP `^8.1` -- Phalcon, either the `ext-phalcon` C extension (`^5`) or the `phalcon/phalcon` PHP +- Phalcon, either the `ext-phalcon` C extension (`^5.18`) or the `phalcon/phalcon` PHP implementation (`^6`) - crest itself needs neither to run ## Install +In a project: + composer require --dev phalcon/crest +To create projects, also install crest globally. The global composer `vendor/bin` +directory must be in your `PATH`: + + composer global require phalcon/crest + ## Usage +In a project, use the crest in `vendor/`. The commands that work on the project need +the project autoloader and its Phalcon: + vendor/bin/crest list available commands vendor/bin/crest about environment and version report vendor/bin/crest make:action GET /company/all +To create a project, use the global crest. The new project requires `phalcon/crest`, so +after `composer install` it has its own `vendor/bin/crest`: + + crest new my-app create an ADR project + ## Global options | Option | Purpose | |---|---| | `--config=` | explicit path to `crest.php` | -| `--directory=` | project root override | +| `--directory=` | project root override; for `new`, the directory the project is created in | | `--trace` | full exception trace | | `--help`, `-h` | usage for the current command | | `--quiet`, `-q` | suppress non-essential output | @@ -93,6 +108,9 @@ filesystem and keeps working on a project that does not currently run. Copy a stub into `resources/stubs//` in your project and crest uses yours instead of the packaged one. +The `project-*` stubs that `new` renders are published by name only. See +[docs/index.md](docs/index.md#creating-a-project). + ## Development docker compose up -d diff --git a/composer.json b/composer.json index d39dd05..f5f28df 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,6 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", - "infection/infection": "^0.29", "pds/composer-script-names": "^1", "pds/skeleton": "^1", "phalcon/phalcon": "v6.0.x-dev", diff --git a/composer.lock b/composer.lock index dc00cb3..e3b270c 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": "7efce124bb3f06a2344761735f8ab716", + "content-hash": "9749fe461704aa68db6fc261f94c41e2", "packages": [], "packages-dev": [ { @@ -71,94 +71,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "colinodell/json5", - "version": "v3.0.0", - "source": { - "type": "git", - "url": "https://github.com/colinodell/json5.git", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/colinodell/json5/zipball/5724d21bc5c910c2560af1b8915f0cc0163579c8", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^8.0" - }, - "require-dev": { - "mikehaertl/php-shellcommand": "^1.7.0", - "phpstan/phpstan": "^1.10.57", - "scrutinizer/ocular": "^1.9", - "squizlabs/php_codesniffer": "^3.8.1", - "symfony/finder": "^6.0|^7.0", - "symfony/phpunit-bridge": "^7.0.3" - }, - "bin": [ - "bin/json5" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "files": [ - "src/global.php" - ], - "psr-4": { - "ColinODell\\Json5\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com", - "role": "Developer" - } - ], - "description": "UTF-8 compatible JSON5 parser for PHP", - "homepage": "https://github.com/colinodell/json5", - "keywords": [ - "JSON5", - "json", - "json5_decode", - "json_decode" - ], - "support": { - "issues": "https://github.com/colinodell/json5/issues", - "source": "https://github.com/colinodell/json5/tree/v3.0.0" - }, - "funding": [ - { - "url": "https://www.colinodell.com/sponsor", - "type": "custom" - }, - { - "url": "https://www.paypal.me/colinpodell/10.00", - "type": "custom" - }, - { - "url": "https://github.com/colinodell", - "type": "github" - }, - { - "url": "https://www.patreon.com/colinodell", - "type": "patreon" - } - ], - "time": "2024-02-09T13:06:12+00:00" - }, { "name": "composer/pcre", "version": "3.4.0", @@ -237,16 +149,16 @@ }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", "shasum": "" }, "require": { @@ -288,7 +200,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -298,7 +210,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/composer/semver/tree/3.5.0" }, "funding": [ { @@ -310,7 +222,7 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-09-24T14:38:51+00:00" }, { "name": "composer/xdebug-handler", @@ -755,430 +667,6 @@ ], "time": "2026-09-22T09:54:55+00:00" }, - { - "name": "infection/abstract-testframework-adapter", - "version": "0.5.0", - "source": { - "type": "git", - "url": "https://github.com/infection/abstract-testframework-adapter.git", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/abstract-testframework-adapter/zipball/18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^2.17", - "phpunit/phpunit": "^9.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\AbstractTestFramework\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Abstract Test Framework Adapter for Infection", - "support": { - "issues": "https://github.com/infection/abstract-testframework-adapter/issues", - "source": "https://github.com/infection/abstract-testframework-adapter/tree/0.5.0" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-08-17T18:49:12+00:00" - }, - { - "name": "infection/extension-installer", - "version": "0.1.2", - "source": { - "type": "git", - "url": "https://github.com/infection/extension-installer.git", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/extension-installer/zipball/9b351d2910b9a23ab4815542e93d541e0ca0cdcf", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.1 || ^2.0" - }, - "require-dev": { - "composer/composer": "^1.9 || ^2.0", - "friendsofphp/php-cs-fixer": "^2.18, <2.19", - "infection/infection": "^0.15.2", - "php-coveralls/php-coveralls": "^2.4", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.10", - "phpstan/phpstan-phpunit": "^0.12.6", - "phpstan/phpstan-strict-rules": "^0.12.2", - "phpstan/phpstan-webmozart-assert": "^0.12.2", - "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.8" - }, - "type": "composer-plugin", - "extra": { - "class": "Infection\\ExtensionInstaller\\Plugin" - }, - "autoload": { - "psr-4": { - "Infection\\ExtensionInstaller\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Infection Extension Installer", - "support": { - "issues": "https://github.com/infection/extension-installer/issues", - "source": "https://github.com/infection/extension-installer/tree/0.1.2" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-10-20T22:08:34+00:00" - }, - { - "name": "infection/include-interceptor", - "version": "0.2.5", - "source": { - "type": "git", - "url": "https://github.com/infection/include-interceptor.git", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/include-interceptor/zipball/0cc76d95a79d9832d74e74492b0a30139904bdf7", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7", - "shasum": "" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.16", - "infection/infection": "^0.15.0", - "phan/phan": "^2.4 || ^3", - "php-coveralls/php-coveralls": "^2.2", - "phpstan/phpstan": "^0.12.8", - "phpunit/phpunit": "^8.5", - "vimeo/psalm": "^3.8" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\StreamWrapper\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Stream Wrapper: Include Interceptor. Allows to replace included (autoloaded) file with another one.", - "support": { - "issues": "https://github.com/infection/include-interceptor/issues", - "source": "https://github.com/infection/include-interceptor/tree/0.2.5" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-08-09T10:03:57+00:00" - }, - { - "name": "infection/infection", - "version": "0.29.9", - "source": { - "type": "git", - "url": "https://github.com/infection/infection.git", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/beac2ca971b37dd7feb92fe2d3e705c175b2360b", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b", - "shasum": "" - }, - "require": { - "colinodell/json5": "^2.2 || ^3.0", - "composer-runtime-api": "^2.0", - "composer/xdebug-handler": "^2.0 || ^3.0", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "fidry/cpu-core-counter": "^0.4.0 || ^0.5.0 || ^1.0", - "infection/abstract-testframework-adapter": "^0.5.0", - "infection/extension-installer": "^0.1.0", - "infection/include-interceptor": "^0.2.5", - "infection/mutator": "^0.4", - "justinrainbow/json-schema": "^5.3", - "nikic/php-parser": "^5.3", - "ondram/ci-detector": "^4.1.0", - "php": "^8.1", - "sanmai/later": "^0.1.1", - "sanmai/pipeline": "^5.1 || ^6", - "sebastian/diff": "^3.0.2 || ^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/safe": "dev-master as 2.5.0", - "webmozart/assert": "^1.11" - }, - "conflict": { - "antecedent/patchwork": "<2.1.25", - "dg/bypass-finals": "<1.4.1", - "phpunit/php-code-coverage": ">9,<9.1.4 || >9.2.17,<9.2.21" - }, - "require-dev": { - "ext-simplexml": "*", - "fidry/makefile": "^1.0", - "helmich/phpunit-json-assert": "^3.0", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.10.15", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpstan/phpstan-strict-rules": "^1.1.0", - "phpstan/phpstan-webmozart-assert": "^1.0.2", - "phpunit/phpunit": "^10.5", - "rector/rector": "^1.0", - "sidz/phpstan-rules": "^0.4", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/phpstan-safe-rule": "^1.2.0" - }, - "bin": [ - "bin/infection" - ], - "type": "library", - "autoload": { - "psr-4": { - "Infection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com", - "homepage": "https://twitter.com/maks_rafalko" - }, - { - "name": "Oleg Zhulnev", - "homepage": "https://github.com/sidz" - }, - { - "name": "Gert de Pagter", - "homepage": "https://github.com/BackEndTea" - }, - { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com", - "homepage": "https://twitter.com/tfidry" - }, - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com", - "homepage": "https://www.alexeykopytko.com" - }, - { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" - } - ], - "description": "Infection is a Mutation Testing framework for PHP. The mutation adequacy score can be used to measure the effectiveness of a test set in terms of its ability to detect faults.", - "keywords": [ - "coverage", - "mutant", - "mutation framework", - "mutation testing", - "testing", - "unit testing" - ], - "support": { - "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.29.9" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2024-12-08T22:23:44+00:00" - }, - { - "name": "infection/mutator", - "version": "0.4.1", - "source": { - "type": "git", - "url": "https://github.com/infection/mutator.git", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/mutator/zipball/3c976d721b02b32f851ee4e15d553ef1e9186d1d", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^10" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\Mutator\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Mutator interface to implement custom mutators (mutation operators) for Infection", - "support": { - "issues": "https://github.com/infection/mutator/issues", - "source": "https://github.com/infection/mutator/tree/0.4.1" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2025-04-29T08:19:52+00:00" - }, - { - "name": "justinrainbow/json-schema", - "version": "5.3.4", - "source": { - "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" - }, - "bin": [ - "bin/validate-json" - ], - "type": "library", - "autoload": { - "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" - } - ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], - "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.4" - }, - "time": "2026-05-04T18:54:58+00:00" - }, { "name": "masterminds/html5", "version": "2.11.0", @@ -1486,84 +974,6 @@ }, "time": "2026-09-13T18:51:52+00:00" }, - { - "name": "ondram/ci-detector", - "version": "4.2.0", - "source": { - "type": "git", - "url": "https://github.com/OndraM/ci-detector.git", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.13.2", - "lmc/coding-standard": "^3.0.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.2.0", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpunit/phpunit": "^9.6.13" - }, - "type": "library", - "autoload": { - "psr-4": { - "OndraM\\CiDetector\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ondřej Machulda", - "email": "ondrej.machulda@gmail.com" - } - ], - "description": "Detect continuous integration environment and provide unified access to properties of current build", - "keywords": [ - "CircleCI", - "Codeship", - "Wercker", - "adapter", - "appveyor", - "aws", - "aws codebuild", - "azure", - "azure devops", - "azure pipelines", - "bamboo", - "bitbucket", - "buddy", - "ci-info", - "codebuild", - "continuous integration", - "continuousphp", - "devops", - "drone", - "github", - "gitlab", - "interface", - "jenkins", - "pipelines", - "sourcehut", - "teamcity", - "travis" - ], - "support": { - "issues": "https://github.com/OndraM/ci-detector/issues", - "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" - }, - "time": "2024-03-12T13:22:30+00:00" - }, { "name": "pds/composer-script-names", "version": "1.0.0", @@ -1771,12 +1181,12 @@ "source": { "type": "git", "url": "https://github.com/phalcon/phalcon.git", - "reference": "e353b8a926cd022f06ac0f115bd5ddc1489ce311" + "reference": "76cdd1807b68210a0ecb574218af90114eb087f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phalcon/phalcon/zipball/e353b8a926cd022f06ac0f115bd5ddc1489ce311", - "reference": "e353b8a926cd022f06ac0f115bd5ddc1489ce311", + "url": "https://api.github.com/repos/phalcon/phalcon/zipball/76cdd1807b68210a0ecb574218af90114eb087f0", + "reference": "76cdd1807b68210a0ecb574218af90114eb087f0", "shasum": "" }, "require": { @@ -1849,7 +1259,7 @@ "type": "open_collective" } ], - "time": "2026-09-22T20:11:19+00:00" + "time": "2026-09-24T21:56:14+00:00" }, { "name": "phalcon/talon", @@ -2131,11 +1541,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.15", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", - "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -2191,7 +1601,7 @@ "type": "github" } ], - "time": "2026-09-23T12:23:07+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3286,135 +2696,6 @@ ], "time": "2024-06-11T12:45:25+00:00" }, - { - "name": "sanmai/later", - "version": "0.1.5", - "source": { - "type": "git", - "url": "https://github.com/sanmai/later.git", - "reference": "cf5164557d19930295892094996f049ea12ba14d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/later/zipball/cf5164557d19930295892094996f049ea12ba14d", - "reference": "cf5164557d19930295892094996f049ea12ba14d", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.35.1", - "infection/infection": ">=0.27.6", - "phan/phan": ">=2", - "php-coveralls/php-coveralls": "^2.0", - "phpstan/phpstan": ">=1.4.5", - "phpunit/phpunit": ">=9.5 <10", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "0.1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Later\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "Later: deferred wrapper object", - "support": { - "issues": "https://github.com/sanmai/later/issues", - "source": "https://github.com/sanmai/later/tree/0.1.5" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-12-06T02:36:26+00:00" - }, - { - "name": "sanmai/pipeline", - "version": "6.12", - "source": { - "type": "git", - "url": "https://github.com/sanmai/pipeline.git", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.17", - "infection/infection": ">=0.10.5", - "league/pipeline": "^0.3 || ^1.0", - "phan/phan": ">=1.1", - "php-coveralls/php-coveralls": "^2.4.1", - "phpstan/phpstan": ">=0.10", - "phpunit/phpunit": ">=9.4", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "v6.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Pipeline\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "General-purpose collections pipeline", - "support": { - "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/6.12" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-10-17T02:22:57+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -6459,151 +5740,6 @@ ], "time": "2026-09-11T09:03:57+00:00" }, - { - "name": "thecodingmachine/safe", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "e527bb2d39d1dc81de6e23cdb6b18be0bd7dd99b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/e527bb2d39d1dc81de6e23cdb6b18be0bd7dd99b", - "reference": "e527bb2d39d1dc81de6e23cdb6b18be0bd7dd99b", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "default-branch": true, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/litespeed.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/master" - }, - "funding": [ - { - "url": "https://github.com/OskarStark", - "type": "github" - }, - { - "url": "https://github.com/shish", - "type": "github" - }, - { - "url": "https://github.com/silasjoisten", - "type": "github" - }, - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2026-08-17T23:42:47+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -6653,64 +5789,6 @@ } ], "time": "2025-11-17T20:03:58+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.12.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^7.2 || ^8.0" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" - }, - "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], diff --git a/docs/index.md b/docs/index.md index 6610a97..0e70eb1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,19 +14,76 @@ for the same listing from the tool itself. | `about` (`info`, `i`) | environment and version report | | `config:show` | the project configuration crest resolved, and where each value came from | | `container:list` | services registered in the project container | +| `down` | stop and remove the project containers | | `event:list` | listeners attached to the project events manager | +| `install` | install composer dependencies in the project container | | `list` (`commands`, `enumerate`) | the available commands | | `make:action` | create an ADR action for a route | | `make:command` | create a crest command | | `make:middleware` | create an ADR middleware | | `make:provider` | create a service provider | | `make:responder` | create an ADR responder | +| `new` | create an ADR project | | `route:list` | every route the application answers | | `stub:publish` | copy packaged stubs into the project for editing | +| `up` | start the project containers | Only the `adr` flavor has generators. A `cli` or `mvc` project can still run `about`, `config:show` and `list`. +## Creating a project + +`new` writes an ADR project that runs: + + crest new my-app + +| Option | Purpose | +|---|---| +| `--namespace=` | root namespace for the generated code; defaults to `App` | +| `--php=` | PHP version for `composer.json` and the Dockerfile; defaults to `8.4`, and must be 8.1 or later | +| `--phalcon=v5\|v6` | `v5` requires the C extension, 5.18 or later; `v6` the `phalcon/phalcon` package; defaults to `v5` | +| `--force` | write into a directory that is not empty, and overwrite files with the same names | + +The project goes into the working directory, or into `--directory` if you give +one. `new` runs nothing: no composer, no docker, no network. It prints the +next steps: + + cd my-app + crest up docker compose up -d + crest install composer install in the app container + +`crest down` stops and removes the containers. `up --build` rebuilds the image +first, and `down --volumes` also removes the named volumes. + +`new`, `up`, `down` and `install` run before the project has a `vendor/`. Run +them with a crest outside the project, for example one that you install with +`composer global require phalcon/crest`. + +The generated project requires `phalcon/crest` as a dev dependency. The +commands that work on the project, for example `make:action` and +`route:list`, need the autoloader and the Phalcon of the project. After +`crest install` or `composer install`, run them with the crest in `vendor/`: + + vendor/bin/crest make:action GET /hello + docker compose exec app vendor/bin/crest make:action GET /hello + +The files come from the `project-*` stubs. To change them, publish them by +name in the directory that the project goes into (the working directory, or +`--directory`), then edit the copies: + + crest stub:publish project-front + +A project stub needs no `crest.php`, and it always uses the `adr` flavor. A +publish with no name leaves the project stubs out, because they do nothing +inside a project. + +You can change each project stub on its own, but `project-front`, +`project-config` and `project-index` must agree with each other. Do not change +the name of the front controller class `AppFront` or the paths of the +generated files, because `new` does not read them from the stubs. A published +copy must use only the placeholders of the packaged copy. If a placeholder has +no value, `new` stops before it writes a file. + ## Commands that boot the project `container:list` and `event:list` read state that exists only once the diff --git a/resources/infection.json5 b/resources/infection.json5 index 804fc15..e5be8d9 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -37,6 +37,10 @@ // getcwd() returns false only when the working directory is // unreadable, which the suite cannot produce. "Crest\\Project\\Config::discover", + // Same as Config::discover: getcwd() returns false only when + // the working directory cannot be read, which the suite + // cannot cause. + "Crest\\Command\\NewCommand::parent", // Environment branch. See the note on Identical below. "Crest\\Command\\AboutCommand::phalcon" ] @@ -108,7 +112,9 @@ // 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" + "Crest\\Console\\Output::detectDecoration", + // The guard that the suite cannot reach. See Throw_. + "Crest\\Process\\ShellRunner::start" ] }, "FunctionCallRemoval": { @@ -126,14 +132,36 @@ "Crest\\Generator\\ArtifactWriter::write", // Widening the slice by one includes the '--' token itself, // which is never '--trace', '--help' or '-h'. - "Crest\\Console\\Kernel::beforeLiteral" + "Crest\\Console\\Kernel::beforeLiteral", + // The guard that the suite cannot reach. See Throw_. + "Crest\\Process\\ShellRunner::start" ] }, "NotIdentical": { "ignore": ["Crest\\Console\\Output::detectDecoration"] }, + "ProtectedVisibility": { + // The default hooks run only for the commands that do not override + // them, so only the tests of those commands cover these lines, and + // they cannot see an override. The full suite kills both mutants: + // with a private hook, handle() calls the default and not the + // override, and the make:command, make:middleware and + // make:provider tests fail. + "ignore": [ + "Crest\\Command\\Make\\NamedArtifactCommand::guidance", + "Crest\\Command\\Make\\NamedArtifactCommand::replacements" + ] + }, "Throw_": { - "ignore": ["Crest\\ADR\\PhalconRouterResolver::router"] + "ignore": [ + "Crest\\ADR\\PhalconRouterResolver::router", + // proc_open() returns false only when it cannot create the + // process. ShellRunner::run() checks the program and the + // directory first, so the suite cannot make it fail. The same + // reason covers the FalseValue and IncrementInteger entries + // for this method. + "Crest\\Process\\ShellRunner::start" + ] }, "UnwrapArrayValues": { // The routes are keyed by path so they can be sorted; table() diff --git a/resources/octocov.yml b/resources/octocov.yml index 53a6bd7..6ef9d1c 100644 --- a/resources/octocov.yml +++ b/resources/octocov.yml @@ -24,8 +24,8 @@ # installed" fallback (2 lines) # - Registry::discover, the "Composer\InstalledVersions is missing" return # (1 line) -# The ext-phalcon branch starts being covered once the v5 matrix cell is added -# (blocked on ext-phalcon 5.18). +# The ext-phalcon branch stays uncovered: the unit-tests job runs the v5 +# variant, but the coverage job runs v6 only. # # The gate is deliberately set well below the measured value while the suite is # still growing, so ordinary work is not blocked by the floor. Ratchet it up as diff --git a/resources/stubs/adr/project-compose.stub b/resources/stubs/adr/project-compose.stub new file mode 100644 index 0000000..ebe29d3 --- /dev/null +++ b/resources/stubs/adr/project-compose.stub @@ -0,0 +1,16 @@ +services: + {{ service }}: + build: + context: . + dockerfile: resources/docker/Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-1000} + container_name: ${PROJECT_PREFIX:-{{ project }}}-app + hostname: ${PROJECT_PREFIX:-{{ project }}}-app + working_dir: /srv + ports: + - "${APP_PORT:-8080}:8080" + volumes: + - .:/srv + env_file: .env diff --git a/resources/stubs/adr/project-composer.stub b/resources/stubs/adr/project-composer.stub new file mode 100644 index 0000000..5ef8ac7 --- /dev/null +++ b/resources/stubs/adr/project-composer.stub @@ -0,0 +1,18 @@ +{ + "type": "project", + "require": { + "php": ">={{ phpVersion }}", + "{{ phalconPackage }}": "{{ phalconConstraint }}" + }, + "require-dev": { + "phalcon/crest": "{{ crestConstraint }}" + }, + "autoload": { + "psr-4": { + "{{ jsonNamespace }}\\": "src/" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/resources/stubs/adr/project-config.stub b/resources/stubs/adr/project-config.stub new file mode 100644 index 0000000..143f7a4 --- /dev/null +++ b/resources/stubs/adr/project-config.stub @@ -0,0 +1,15 @@ + 'adr', + 'namespace' => '{{ namespace }}', + 'bootstrap' => {{ namespace }}\AppFront::class, + 'paths' => ['action' => '{{ actionPath }}'], +]; diff --git a/resources/stubs/adr/project-dockerfile.stub b/resources/stubs/adr/project-dockerfile.stub new file mode 100644 index 0000000..7c512c2 --- /dev/null +++ b/resources/stubs/adr/project-dockerfile.stub @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1 +ARG PHP_VERSION={{ phpVersion }} + +FROM php:${PHP_VERSION}-cli + +ARG UID=1000 +ARG GID=1000 +ARG USER=app +ARG GROUP=app + +# System packages and a user with the host UID and GID. Files that the +# container writes through the bind mount then belong to you. -o accepts an ID +# that the image already uses, for example GID 20 on macOS. +RUN <setBaseNamespace('{{ actionNamespace }}') + ->setActionDirectory($this->projectRoot . '/{{ actionPath }}'); + } +} diff --git a/resources/stubs/adr/project-gitignore.stub b/resources/stubs/adr/project-gitignore.stub new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/resources/stubs/adr/project-gitignore.stub @@ -0,0 +1 @@ +/vendor/ diff --git a/resources/stubs/adr/project-htrouter.stub b/resources/stubs/adr/project-htrouter.stub new file mode 100644 index 0000000..c98bbb5 --- /dev/null +++ b/resources/stubs/adr/project-htrouter.stub @@ -0,0 +1,17 @@ +run()); diff --git a/resources/stubs/adr/project-readme.stub b/resources/stubs/adr/project-readme.stub new file mode 100644 index 0000000..305af7b --- /dev/null +++ b/resources/stubs/adr/project-readme.stub @@ -0,0 +1,39 @@ +# {{ project }} + +A Phalcon ADR application. + +## Run it + +With docker: + + crest up + crest install + +With PHP and composer on the host: + + composer install + php -S localhost:8080 -t public .htrouter.php + +Then open http://localhost:8080/. + +## Layout + +| Path | Contents | +|---|---| +| `public/index.php` | The web entry point | +| `src/AppFront.php` | The front controller: container, providers, application | +| `{{ actionPath }}/` | One class for each route. `GET /` is `{{ actionPath }}/{{ seed }}.php` | +| `crest.php` | The crest configuration | + +## Next steps + +Use the crest in `vendor/` for these commands. It uses the autoloader and +the Phalcon of this project. On the host: + + vendor/bin/crest make:action GET /hello + vendor/bin/crest route:list + +With docker: + + docker compose exec {{ service }} vendor/bin/crest make:action GET /hello + docker compose exec {{ service }} vendor/bin/crest route:list diff --git a/src/Command/ComposeCommand.php b/src/Command/ComposeCommand.php new file mode 100644 index 0000000..dc3e7cf --- /dev/null +++ b/src/Command/ComposeCommand.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Command\Command; +use Crest\Console\Input; +use Crest\Process\Runner; +use Crest\Process\ShellRunner; + +/** + * The base for the commands that control the project containers. + * + * They are thin, but they are not aliases. crest wrote docker-compose.yml, so + * crest owns the `app` service name. docker compose finds the file when it + * goes up from the working directory, as crest finds crest.php. Thus no path + * is recorded. + * + * They read no project configuration, because they run before the project + * has a vendor/ directory. + */ +abstract class ComposeCommand extends Command +{ + private readonly Runner $runner; + + /** + * The default lets the kernel's `new $class()` work. A test gives a fake + * runner, and checks the exact argv without docker. + */ + public function __construct(?Runner $runner = null) + { + $this->runner = $runner ?? new ShellRunner(); + } + + /** + * Runs `docker compose` with the arguments, in --directory if the user + * gave one. An empty value reads as absent, as it does for `new`. + * + * @param list $arguments + */ + protected function compose(Input $input, array $arguments): int + { + return $this->runner->run( + ['docker', 'compose', ...$arguments], + $input->optionStringOrNull('directory') ?: null + ); + } +} diff --git a/src/Command/DownCommand.php b/src/Command/DownCommand.php new file mode 100644 index 0000000..f49ec5c --- /dev/null +++ b/src/Command/DownCommand.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +/** + * Stops the project containers and removes them. + */ +final class DownCommand extends ComposeCommand +{ + public function define(): Definition + { + return Definition::for('down', 'Stop and remove the project containers') + ->option('volumes', 'Remove the named volumes too'); + } + + public function handle(Input $input, Output $output): int + { + $arguments = ['down']; + + if (true === $input->option('volumes')) { + $arguments[] = '--volumes'; + } + + return $this->compose($input, $arguments); + } +} diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php new file mode 100644 index 0000000..8459eb9 --- /dev/null +++ b/src/Command/InstallCommand.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +/** + * Runs composer install in the project container. On the host, use + * `composer install` directly. This command exists because crest knows the + * container. + */ +final class InstallCommand extends ComposeCommand +{ + /** + * The service in docker-compose.yml. crest wrote that file + * (project-compose.stub), so the name belongs to crest. NewCommand writes + * this value into the stub. + */ + public const SERVICE = 'app'; + + public function define(): Definition + { + return Definition::for('install', 'Install composer dependencies in the project container'); + } + + public function handle(Input $input, Output $output): int + { + return $this->compose($input, ['exec', self::SERVICE, 'composer', 'install']); + } +} diff --git a/src/Command/Make/CommandCommand.php b/src/Command/Make/CommandCommand.php index c30319a..132fac6 100644 --- a/src/Command/Make/CommandCommand.php +++ b/src/Command/Make/CommandCommand.php @@ -13,11 +13,9 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; use Crest\Commands; -use Crest\Console\Input; use Crest\Console\Output; -use Crest\Console\Parsing\Definition; +use Crest\Generator\Placement; use function sprintf; use function str_replace; @@ -41,39 +39,20 @@ * `migration:run` from a class name, so the generated definition is a starting * point either way. */ -final class CommandCommand extends ProjectCommand +final class CommandCommand extends NamedArtifactCommand { - private const KEY = 'command'; - - private const SUFFIX = 'Command'; - - public function define(): Definition + protected function description(): string { - return Definition::for('make:command', 'Create a crest command') - ->argument('name', true, 'Command name, e.g. Greet') - ->option('force', 'Overwrite an existing command'); + return 'Create a crest command'; } - public function handle(Input $input, Output $output): int + protected function example(): string { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - $name = $this->registryName($placement->class); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - 'command' => $name, - ], - true === $input->option('force') - ); + return 'Greet'; + } - $output->success(sprintf('Created %s', $placement->file)); + protected function guidance(Placement $placement, Output $output): void + { $output->line('Nothing lists it yet. Declare it in the package composer.json:'); $output->line(); $output->line(' "extra": {'); @@ -82,15 +61,28 @@ public function handle(Input $input, Output $output): int $output->line( sprintf( ' "%s": "%s"', - $name, + $this->registryName($placement->class), str_replace('\\', '\\\\', $placement->namespace . '\\' . $placement->class) ) ); $output->line(' }'); $output->line(' }'); $output->line(' }'); + } - return 0; + protected function key(): string + { + return 'command'; + } + + protected function replacements(Placement $placement): array + { + return ['command' => $this->registryName($placement->class)]; + } + + protected function suffix(): string + { + return 'Command'; } /** @@ -100,6 +92,6 @@ public function handle(Input $input, Output $output): int */ private function registryName(string $class): string { - return strtolower(substr($class, 0, -strlen(self::SUFFIX))) ?: strtolower($class); + return strtolower(substr($class, 0, -strlen($this->suffix()))) ?: strtolower($class); } } diff --git a/src/Command/Make/MiddlewareCommand.php b/src/Command/Make/MiddlewareCommand.php index f33df9a..b4c12ba 100644 --- a/src/Command/Make/MiddlewareCommand.php +++ b/src/Command/Make/MiddlewareCommand.php @@ -13,10 +13,8 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; -use Crest\Console\Input; use Crest\Console\Output; -use Crest\Console\Parsing\Definition; +use Crest\Generator\Placement; use function sprintf; @@ -30,37 +28,20 @@ * the registration instead: the file is crest's to write, the wiring is the * developer's to place. */ -final class MiddlewareCommand extends ProjectCommand +final class MiddlewareCommand extends NamedArtifactCommand { - private const KEY = 'middleware'; - - private const SUFFIX = 'Middleware'; - - public function define(): Definition + protected function description(): string { - return Definition::for('make:middleware', 'Create an ADR middleware') - ->argument('name', true, 'Middleware name, e.g. Auth') - ->option('force', 'Overwrite an existing middleware'); + return 'Create an ADR middleware'; } - public function handle(Input $input, Output $output): int + protected function example(): string { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - ], - true === $input->option('force') - ); + return 'Auth'; + } - $output->success(sprintf('Created %s', $placement->file)); + protected function guidance(Placement $placement, Output $output): void + { $output->line('Nothing runs it yet. Add it to the router\'s middleware map:'); $output->line(); $output->line( @@ -75,7 +56,15 @@ public function handle(Input $input, Output $output): int "The key is a namespace suffix under the base namespace: '' guards every " . "action, '\\Album' only the actions beneath it." ); + } + + protected function key(): string + { + return 'middleware'; + } - return 0; + protected function suffix(): string + { + return 'Middleware'; } } diff --git a/src/Command/Make/NamedArtifactCommand.php b/src/Command/Make/NamedArtifactCommand.php new file mode 100644 index 0000000..808d609 --- /dev/null +++ b/src/Command/Make/NamedArtifactCommand.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command\Make; + +use Crest\Command\ProjectCommand; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; +use Crest\Generator\Placement; + +use function sprintf; +use function ucfirst; + +/** + * Base for a make:* command that writes one class from a user-given name. + * + * A subclass gives key(), suffix(), description() and example(). This class + * declares the `name` argument and the `--force` option, because it reads + * both. + * + * A subclass that must tell the developer how to wire the class overrides + * guidance(). A subclass whose stub needs more values overrides replacements(). + */ +abstract class NamedArtifactCommand extends ProjectCommand +{ + public function define(): Definition + { + $key = $this->key(); + + return Definition::for('make:' . $key, $this->description()) + ->argument('name', true, sprintf('%s name, e.g. %s', ucfirst($key), $this->example())) + // No declared default: resolveOptions() supplies false for a flag + // without consulting one, so passing it would state something that + // is never read. + ->option('force', sprintf('Overwrite an existing %s', $key)); + } + + public function handle(Input $input, Output $output): int + { + $key = $this->key(); + $config = $this->config($input); + $placement = $this->placement($config, $input->argumentString('name'), $key, $this->suffix()); + + $this->writer($config)->render( + $placement->file, + $key, + [ + 'namespace' => $placement->namespace, + 'class' => $placement->class, + ] + $this->replacements($placement), + true === $input->option('force') + ); + + $output->success(sprintf('Created %s', $placement->file)); + $this->guidance($placement, $output); + + return 0; + } + + /** + * The command description, for example `Create an ADR middleware`. + */ + abstract protected function description(): string; + + /** + * An example name for the help text, for example `Auth`. + */ + abstract protected function example(): string; + + /** + * Prints what the developer must do to make the class run. The default + * prints nothing. + */ + protected function guidance(Placement $placement, Output $output): void + { + } + + /** + * The configuration key. It selects the configured path, the namespace and + * the stub. + */ + abstract protected function key(): string; + + /** + * Stub values in addition to `namespace` and `class`. + * + * @return array + */ + protected function replacements(Placement $placement): array + { + return []; + } + + /** + * The suffix of the class name, for example `Middleware`. + */ + abstract protected function suffix(): string; +} diff --git a/src/Command/Make/ProviderCommand.php b/src/Command/Make/ProviderCommand.php index e7aa471..ef29cc2 100644 --- a/src/Command/Make/ProviderCommand.php +++ b/src/Command/Make/ProviderCommand.php @@ -13,10 +13,8 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; -use Crest\Console\Input; use Crest\Console\Output; -use Crest\Console\Parsing\Definition; +use Crest\Generator\Placement; use function sprintf; @@ -33,37 +31,20 @@ * instead, including the parent:: line - omitting that one is a silent failure * that takes the ADR services down with it. */ -final class ProviderCommand extends ProjectCommand +final class ProviderCommand extends NamedArtifactCommand { - private const KEY = 'provider'; - - private const SUFFIX = 'Provider'; - - public function define(): Definition + protected function description(): string { - return Definition::for('make:provider', 'Create a service provider') - ->argument('name', true, 'Provider name, e.g. Cache') - ->option('force', 'Overwrite an existing provider'); + return 'Create a service provider'; } - public function handle(Input $input, Output $output): int + protected function example(): string { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - ], - true === $input->option('force') - ); + return 'Cache'; + } - $output->success(sprintf('Created %s', $placement->file)); + protected function guidance(Placement $placement, Output $output): void + { $output->line('Nothing registers it yet. Call it from your front controller:'); $output->line(); $output->line(' protected function registerProviders(Container $container): void'); @@ -80,7 +61,15 @@ public function handle(Input $input, Output $output): int $output->line(' }'); $output->line(); $output->line('Keep the parent call: it is what registers the ADR services.'); + } + + protected function key(): string + { + return 'provider'; + } - return 0; + protected function suffix(): string + { + return 'Provider'; } } diff --git a/src/Command/Make/ResponderCommand.php b/src/Command/Make/ResponderCommand.php index 91140a4..a5916ee 100644 --- a/src/Command/Make/ResponderCommand.php +++ b/src/Command/Make/ResponderCommand.php @@ -13,13 +13,6 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; -use Crest\Console\Input; -use Crest\Console\Output; -use Crest\Console\Parsing\Definition; - -use function sprintf; - /** * Generates an ADR Responder - the one layer that speaks HTTP, turning a domain * payload into a response. @@ -32,41 +25,25 @@ * Boots nothing - it reads config and writes a file, so it keeps working on a * project that does not currently run. */ -final class ResponderCommand extends ProjectCommand +final class ResponderCommand extends NamedArtifactCommand { - private const KEY = 'responder'; - - private const SUFFIX = 'Responder'; - - public function define(): Definition + protected function description(): string { - return Definition::for('make:responder', 'Create an ADR responder') - ->argument('name', true, 'Responder name, e.g. Album') - // No declared default: resolveOptions() supplies false for a flag - // without consulting one, so passing it would state something that - // is never read. - ->option('force', 'Overwrite an existing responder'); + return 'Create an ADR responder'; } - public function handle(Input $input, Output $output): int + protected function example(): string { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - ], - true === $input->option('force') - ); + return 'Album'; + } - $output->success(sprintf('Created %s', $placement->file)); + protected function key(): string + { + return 'responder'; + } - return 0; + protected function suffix(): string + { + return 'Responder'; } } diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php new file mode 100644 index 0000000..7aa6d46 --- /dev/null +++ b/src/Command/NewCommand.php @@ -0,0 +1,328 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Command\Command; +use Crest\Console\Exceptions\Exception; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; +use Crest\Generator\ArtifactWriter; +use Crest\Generator\ClassName; +use Crest\Generator\Stub; +use Crest\Paths; +use Crest\Project\Flavor; +use FilesystemIterator; + +use function escapeshellarg; +use function file_exists; +use function getcwd; +use function is_dir; +use function preg_match; +use function rtrim; +use function sprintf; +use function str_contains; +use function str_replace; +use function strtolower; +use function version_compare; + +/** + * Creates a new ADR project from stubs. + * + * This command runs where no project exists yet: no crest.php, no + * composer.json, no vendor/. Thus it extends the console Command, not + * ProjectCommand. It gets all values from its arguments, and it writes + * crest.php. It does not read it. + * + * It runs nothing: no composer, no docker, no network. It checks all its + * input before it writes the first file. Only a write error, for example a + * full disk, can stop it after it has written some of the files. + */ +final class NewCommand extends Command +{ + /** + * Where the actions go, relative to the project root. The seed action and + * the front controller use it. + */ + private const ACTION_PATH = 'src/Action'; + + /** + * The crest that the new project requires. This crest creates the project, + * and the project crest (vendor/bin/crest) runs the project commands, + * because they need the project autoloader and its Phalcon. + */ + private const CREST = 'dev-master'; + + /** + * Stub name => path in the new project. The seed action is not here: it + * uses the action stub with its own placeholders. + */ + private const FILES = [ + Stub::PROJECT_PREFIX . 'composer' => 'composer.json', + Stub::PROJECT_PREFIX . 'config' => 'crest.php', + Stub::PROJECT_PREFIX . 'env' => '.env', + Stub::PROJECT_PREFIX . 'gitignore' => '.gitignore', + Stub::PROJECT_PREFIX . 'htrouter' => '.htrouter.php', + Stub::PROJECT_PREFIX . 'readme' => 'README.md', + Stub::PROJECT_PREFIX . 'compose' => 'docker-compose.yml', + Stub::PROJECT_PREFIX . 'dockerfile' => 'resources/docker/Dockerfile', + Stub::PROJECT_PREFIX . 'index' => 'public/index.php', + Stub::PROJECT_PREFIX . 'front' => 'src/AppFront.php', + ]; + + /** + * A project name: letters, digits, '-' and '_', with a letter or digit + * first. It is a directory name, not a path. It is also the docker + * container prefix, which must start with a letter or digit. + */ + private const NAME = '/^[A-Za-z0-9][A-Za-z0-9_-]*$/'; + + /** + * Variant => the composer requirement and its constraint. v5 needs 5.18, + * because Phalcon\ADR first ships in cphalcon 5.18.0; an older extension + * installs and then fails on every request. v6 has @RC because + * phalcon/phalcon has no stable 6.0 release yet. Composer still selects a + * stable release when one exists. + */ + private const PHALCON = [ + 'v5' => ['ext-phalcon', '^5.18'], + 'v6' => ['phalcon/phalcon', '^6.0@RC'], + ]; + + /** + * major.minor only. The Dockerfile base image `php:-cli` has no + * patch tags, and composer.json uses the same value. + */ + private const PHP = '/^\d+\.\d+$/'; + + /** + * The oldest PHP that runs the generated code. It uses readonly promoted + * properties, which need PHP 8.1. + */ + private const PHP_FLOOR = '8.1'; + + /** + * The class of the seed action, which answers GET /. This is a copy of the + * framework routing rule, because there is no vendor/ to ask yet. + * GeneratedProjectTest sends GET / through the generated application to + * make sure that the copy is correct. + */ + private const SEED = 'Get'; + + /** + * The directory that the project goes into. + * + * --directory is the global project-root option. This command has no + * project yet, so for it the option names where the project goes. + * + * The project stub overrides are also read from here. stub:publish calls + * this method, so that it writes the overrides where this command reads + * them. + * + * An empty value reads as absent, as optionString() reads every other + * option. Otherwise `--directory="$DIR"` with an unset variable puts the + * project in the filesystem root. + */ + public static function parent(Input $input): string + { + $directory = $input->optionString('directory'); + + return rtrim('' === $directory ? (string) getcwd() : $directory, '/'); + } + + public function define(): Definition + { + return Definition::for('new', 'Create an ADR project') + ->argument('name', true, 'Project directory, e.g. my-app') + ->option('namespace=s', 'Root namespace for the generated code', 'App') + ->option('php=s', 'PHP version the project targets, major.minor', '8.4') + ->option('phalcon=s', 'Phalcon: v5 (extension) or v6 (package)', 'v5') + ->option('force', 'Write into a directory that is not empty, and overwrite files with the same names'); + } + + public function handle(Input $input, Output $output): int + { + $name = $this->name($input->argumentString('name')); + $namespace = ClassName::namespace($input->optionString('namespace')); + $php = $this->php($input->optionString('php')); + $variant = strtolower($input->optionString('phalcon')); + + if (false === isset(self::PHALCON[$variant])) { + throw new Exception( + sprintf("unknown Phalcon version '%s'; expected v5 or v6", $variant) + ); + } + + [$package, $constraint] = self::PHALCON[$variant]; + + $parent = self::parent($input); + $target = $parent . '/' . $name; + $force = true === $input->option('force'); + + $this->guard($target, $force); + + // Overrides come from the directory that the project goes into. A + // team that publishes the project stubs there gets its own + // conventions in each project that it creates there. + $stub = new Stub(Paths::stubs(), $parent); + $flavor = Flavor::ADR->value; + + $replacements = [ + 'actionNamespace' => $namespace . '\\Action', + 'actionPath' => self::ACTION_PATH, + 'crestConstraint' => self::CREST, + 'jsonNamespace' => str_replace('\\', '\\\\', $namespace), + 'namespace' => $namespace, + 'phalconConstraint' => $constraint, + 'phalconPackage' => $package, + 'phalconVariant' => $variant, + 'phpVersion' => $php, + 'project' => $name, + 'seed' => self::SEED, + 'service' => InstallCommand::SERVICE, + // The prefix of each line of the extension install in the + // Dockerfile: active for v5, commented out for v6. + 'v5' => 'v5' === $variant ? '' : '# ', + ]; + + // All files render before the first write. A published stub that does + // not render then stops the command before it writes a file. + $files = []; + + foreach (self::FILES as $stubName => $path) { + $files[$path] = $stub->render($flavor, $stubName, $replacements); + } + + // The seed action uses the usual action stub. Convention cannot name + // it, because Convention asks the router, and there is no vendor/ yet. + $files[self::ACTION_PATH . '/' . self::SEED . '.php'] = $stub->render( + $flavor, + 'action', + [ + 'attributes' => '', + 'class' => self::SEED, + 'namespace' => $namespace . '\\Action', + 'params' => '', + ] + ); + + // guard() has refused a directory that is not empty, unless --force + // is given. Thus a file that exists here can be overwritten. + foreach ($files as $path => $contents) { + ArtifactWriter::write($target . '/' . $path, $contents); + } + + $this->report( + $output, + '' === $input->optionString('directory') ? $name : $target + ); + + return 0; + } + + /** + * Refuses a target that would mix the new project into other files, + * unless --force says that this is the intent. A missing or empty + * directory is always correct. + */ + private function guard(string $target, bool $force): void + { + if (true === file_exists($target) && false === is_dir($target)) { + throw new Exception(sprintf('%s exists and is not a directory', $target)); + } + + if (true === $force || false === is_dir($target)) { + return; + } + + // FilesystemIterator skips . and .., so valid() is true only when the + // directory contains something. + if (true === (new FilesystemIterator($target))->valid()) { + throw new Exception( + sprintf('%s exists and is not empty; pass --force to write into it', $target) + ); + } + } + + /** + * A name, not a path: `crest new ../elsewhere` must not write outside the + * directory it runs in. + */ + private function name(string $name): string + { + if (0 === preg_match(self::NAME, $name)) { + throw new Exception( + sprintf( + "'%s' is not a usable project name; expected letters, digits, '-' and '_', " + . 'starting with a letter or digit', + $name + ) + ); + } + + return $name; + } + + /** + * major.minor, and not older than the generated code needs. + */ + private function php(string $version): string + { + if (0 === preg_match(self::PHP, $version)) { + throw new Exception( + sprintf("'%s' is not a PHP version; expected major.minor, e.g. 8.4", $version) + ); + } + + if (true === version_compare($version, self::PHP_FLOOR, '<')) { + throw new Exception( + sprintf( + 'PHP %s is too old; the generated code needs %s or later', + $version, + self::PHP_FLOOR + ) + ); + } + + return $version; + } + + /** + * Both ways to run the project, always. The generated files are the same + * on every host. Only this text names the two ways, so nothing here + * examines the environment. + */ + private function report(Output $output, string $shown): void + { + // Quoted when the path has a space, so that the line works when pasted. + $cd = sprintf(' cd %s', true === str_contains($shown, ' ') ? escapeshellarg($shown) : $shown); + + $output->success(sprintf('Created %s/', $shown)); + $output->line(); + $output->line('Nothing runs it yet. With docker:'); + $output->line(); + $output->line($cd); + $output->line(' crest up'); + $output->line(' crest install'); + $output->line(); + $output->line('Or with PHP and composer on the host:'); + $output->line(); + $output->line($cd); + $output->line(' composer install'); + // Until `crest serve` exists, the host way names the server directly. + $output->line(' php -S localhost:8080 -t public .htrouter.php'); + $output->line(); + $output->line(sprintf('Then GET / answers from %s/%s.php', self::ACTION_PATH, self::SEED)); + } +} diff --git a/src/Command/Stub/PublishCommand.php b/src/Command/Stub/PublishCommand.php index 2078412..8e76fe9 100644 --- a/src/Command/Stub/PublishCommand.php +++ b/src/Command/Stub/PublishCommand.php @@ -13,6 +13,7 @@ namespace Crest\Command\Stub; +use Crest\Command\NewCommand; use Crest\Command\ProjectCommand; use Crest\Console\Exceptions\Exception; use Crest\Console\Input; @@ -21,13 +22,16 @@ use Crest\Generator\ArtifactWriter; use Crest\Generator\Stub; use Crest\Paths; +use Crest\Project\Flavor; use function basename; use function file_get_contents; use function glob; use function is_file; use function preg_match; +use function sort; use function sprintf; +use function str_starts_with; /** * Copies packaged stubs into the project so they can be edited. @@ -56,14 +60,22 @@ public function define(): Definition public function handle(Input $input, Output $output): int { - $config = $this->config($input); - - $flavor = $config->flavor()->value; - $name = $input->argumentString('name'); - $force = true === $input->option('force'); + $name = $input->argumentString('name'); + $force = true === $input->option('force'); + + // A project stub has an effect only where `new` reads it. That + // directory is not a project, and `new` creates only ADR projects. + if (true === str_starts_with($name, Stub::PROJECT_PREFIX)) { + $root = NewCommand::parent($input); + $flavor = Flavor::ADR->value; + } else { + $config = $this->config($input); + $root = $config->root(); + $flavor = $config->flavor()->value; + } foreach ($this->sources($flavor, $name) as $source) { - $target = Stub::overridePath($config->root(), $flavor, basename($source, '.stub')); + $target = Stub::overridePath($root, $flavor, basename($source, '.stub')); if (true === is_file($target) && false === $force) { $output->line( @@ -110,9 +122,22 @@ private function sources(string $flavor, string $name): array return [$single]; } - // glob() sorts alphabetically unless told not to, so the listing is - // stable without a sort of its own. - $found = glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: []; + $found = []; + + // The project stubs have an effect only in the directory that the + // project goes into, not in a project. Thus a publish with no name + // leaves them out. A publish by name still copies them. + foreach (glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: [] as $path) { + if (true === str_starts_with(basename($path), Stub::PROJECT_PREFIX)) { + continue; + } + + $found[] = $path; + } + + // glob() sorts with the collation of the locale, so its order changes + // from machine to machine. sort() gives byte order everywhere. + sort($found); if ([] === $found) { throw new Exception(sprintf("no stubs are packaged for the '%s' flavor", $flavor)); diff --git a/src/Command/UpCommand.php b/src/Command/UpCommand.php new file mode 100644 index 0000000..bcdbb10 --- /dev/null +++ b/src/Command/UpCommand.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +/** + * Starts the project containers in the background. + */ +final class UpCommand extends ComposeCommand +{ + public function define(): Definition + { + return Definition::for('up', 'Start the project containers') + ->option('build', 'Rebuild the images first'); + } + + public function handle(Input $input, Output $output): int + { + $arguments = ['up', '-d']; + + if (true === $input->option('build')) { + $arguments[] = '--build'; + } + + return $this->compose($input, $arguments); + } +} diff --git a/src/Commands.php b/src/Commands.php index e0b209c..d801364 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -16,15 +16,19 @@ use Crest\Command\AboutCommand; use Crest\Command\Config\ShowCommand as ConfigShowCommand; use Crest\Command\Container\ListCommand as ContainerListCommand; +use Crest\Command\DownCommand; use Crest\Command\Event\ListCommand as EventListCommand; +use Crest\Command\InstallCommand; use Crest\Command\ListCommand; use Crest\Command\Make\ActionCommand; use Crest\Command\Make\CommandCommand; use Crest\Command\Make\MiddlewareCommand; use Crest\Command\Make\ProviderCommand; use Crest\Command\Make\ResponderCommand; +use Crest\Command\NewCommand; use Crest\Command\Route\ListCommand as RouteListCommand; use Crest\Command\Stub\PublishCommand as StubPublishCommand; +use Crest\Command\UpCommand; use Crest\Console\Registry; /** @@ -58,15 +62,19 @@ public static function registry(): Registry ->add('about', AboutCommand::class, 'info', 'i') ->add('config:show', ConfigShowCommand::class) ->add('container:list', ContainerListCommand::class) + ->add('down', DownCommand::class) ->add('event:list', EventListCommand::class) + ->add('install', InstallCommand::class) ->add('list', ListCommand::class, 'commands', 'enumerate') ->add('make:action', ActionCommand::class) ->add('make:command', CommandCommand::class) ->add('make:middleware', MiddlewareCommand::class) ->add('make:provider', ProviderCommand::class) ->add('make:responder', ResponderCommand::class) + ->add('new', NewCommand::class) ->add('route:list', RouteListCommand::class) ->add('stub:publish', StubPublishCommand::class) + ->add('up', UpCommand::class) ->withDiscovery(self::KEY); } } diff --git a/src/Generator/ClassName.php b/src/Generator/ClassName.php index 4e4e220..48c052d 100644 --- a/src/Generator/ClassName.php +++ b/src/Generator/ClassName.php @@ -15,9 +15,11 @@ use Crest\Console\Exceptions\Exception; +use function explode; use function preg_match; use function sprintf; use function str_ends_with; +use function trim; /** * Turns the name a user typed into the class name a generator writes. @@ -47,6 +49,29 @@ final class ClassName */ private const PATTERN = '/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$/'; + /** + * A namespace: one name, or more names that backslashes join. Each name + * follows the identifier rule above. Leading and trailing backslashes are + * removed, because `\App` and `App\` both mean App. + */ + public static function namespace(string $namespace): string + { + $trimmed = trim($namespace, '\\'); + + foreach (explode('\\', $trimmed) as $segment) { + if (0 === preg_match(self::PATTERN, $segment)) { + throw new Exception( + sprintf( + "'%s' is not a usable namespace; expected something like 'App' or 'Acme\\Shop'", + $namespace + ) + ); + } + } + + return $trimmed; + } + public static function suffixed(string $name, string $suffix): string { if (0 === preg_match(self::PATTERN, $name)) { diff --git a/src/Generator/Stub.php b/src/Generator/Stub.php index 8e0e48b..f15cb3d 100644 --- a/src/Generator/Stub.php +++ b/src/Generator/Stub.php @@ -17,6 +17,7 @@ use function file_get_contents; use function is_file; +use function preg_match; use function rtrim; use function sprintf; use function str_replace; @@ -31,6 +32,13 @@ */ final class Stub { + /** + * The name prefix of the stubs that `crest new` renders into a new + * project. They are not artifact stubs, so stub:publish with no name + * leaves them out. + */ + public const PROJECT_PREFIX = 'project-'; + /** * Where a project keeps the stubs it has taken over, relative to its root. * @@ -41,6 +49,11 @@ final class Stub */ private const OVERRIDE_DIRECTORY = 'resources/stubs'; + /** + * A placeholder: a name between `{{ ` and ` }}`. + */ + private const PLACEHOLDER = '/\{\{ \w+ \}\}/'; + private string $packagedRoot; private ?string $projectRoot; @@ -87,16 +100,25 @@ public static function packagedPath(string $packagedRoot, string $flavor, string } /** + * Fails when a placeholder has no value. A published copy can keep a + * placeholder that crest no longer sends, and the raw placeholder must not + * go into the generated file. + * * @param array $replacements */ public function render(string $flavor, string $name, array $replacements): string { - $template = (string) file_get_contents($this->resolve($flavor, $name)); + $path = $this->resolve($flavor, $name); + $template = (string) file_get_contents($path); foreach ($replacements as $key => $value) { $template = str_replace('{{ ' . $key . ' }}', $value, $template); } + if (1 === preg_match(self::PLACEHOLDER, $template, $matches)) { + throw new Exception(sprintf('%s has no value for %s', $path, $matches[0])); + } + return $template; } diff --git a/src/Process/Runner.php b/src/Process/Runner.php new file mode 100644 index 0000000..45352c8 --- /dev/null +++ b/src/Process/Runner.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\Process; + +/** + * Runs an external program. + * + * This is the seam for the commands that run programs (`up`, `down`, + * `install`). Tests replace it with a fake and check the exact argv, so they + * need no docker. + */ +interface Runner +{ + /** + * Runs the program and returns its exit status. The output of the program + * goes directly to the terminal, not through crest's Output. + * + * @param non-empty-list $command The program, then its + * arguments. No shell. + * @param string|null $directory Where the program runs; null + * for the working directory. + */ + public function run(array $command, ?string $directory = null): int; +} diff --git a/src/Process/ShellRunner.php b/src/Process/ShellRunner.php new file mode 100644 index 0000000..1f3770a --- /dev/null +++ b/src/Process/ShellRunner.php @@ -0,0 +1,103 @@ + + * + * 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\Process; + +use Crest\Console\Exceptions\Exception; + +use function explode; +use function getenv; +use function is_dir; +use function is_executable; +use function is_file; +use function proc_close; +use function proc_open; +use function sprintf; +use function str_contains; + +use const PATH_SEPARATOR; + +/** + * Runs a program as a child process that shares crest's terminal. + * + * The child gets crest's own stdin, stdout and stderr: an empty descriptor + * list does that. Thus `docker compose exec` sees a terminal, and composer + * shows its progress. crest captures nothing. + * + * Two checks come first, because proc_open() does neither. A missing program + * gives no clear error. A missing working directory is ignored, and the child + * runs in crest's own directory. + * + * The command is an argv list, not a shell string. Nothing in it is + * interpreted. + */ +final class ShellRunner implements Runner +{ + public function run(array $command, ?string $directory = null): int + { + if (false === $this->exists($command[0])) { + throw new Exception( + sprintf("'%s' was not found; install it or add it to the PATH", $command[0]) + ); + } + + if (null !== $directory && false === is_dir($directory)) { + throw new Exception(sprintf('%s is not a directory', $directory)); + } + + return proc_close($this->start($command, $directory)); + } + + /** + * Whether the program can run. A path must name an executable file. A + * bare name must be an executable file in a directory on the PATH. + */ + private function exists(string $program): bool + { + if (true === str_contains($program, '/')) { + return true === is_file($program) && true === is_executable($program); + } + + foreach (explode(PATH_SEPARATOR, (string) getenv('PATH')) as $directory) { + $candidate = $directory . '/' . $program; + + if (true === is_file($candidate) && true === is_executable($candidate)) { + return true; + } + } + + return false; + } + + /** + * Starts the program with crest's own stdin, stdout and stderr. + * + * proc_open() returns false only when it cannot create the process. run() + * checks the program and the directory first, so the guard is a last + * defense that the suite cannot reach. + * + * @param non-empty-list $command + * + * @return resource + */ + private function start(array $command, ?string $directory) + { + $process = proc_open($command, [], $pipes, $directory); + + if (false === $process) { + throw new Exception(sprintf('could not start %s', $command[0])); // @codeCoverageIgnore + } + + return $process; + } +} diff --git a/tests/Support/GeneratesInAScratchProject.php b/tests/Support/GeneratesInAScratchProject.php index ca448d8..daf85e8 100644 --- a/tests/Support/GeneratesInAScratchProject.php +++ b/tests/Support/GeneratesInAScratchProject.php @@ -13,10 +13,7 @@ namespace Crest\Tests\Support; -use Crest\Commands; use Crest\Console\Command\Command; -use Crest\Console\Kernel; -use Crest\Console\Registry; use function chdir; use function getcwd; @@ -33,7 +30,7 @@ */ trait GeneratesInAScratchProject { - use CapturesOutput; + use RunsThroughTheKernel; use ScratchDirectory; private string $previousCwd = ''; @@ -52,17 +49,7 @@ protected function endScratchProject(): void */ protected function runProjectCommand(string $name, string $class, array $arguments): int { - $registry = (new Registry())->add($name, $class); - $kernel = new Kernel( - Commands::NAME, - $registry, - Commands::PACKAGE, - $this->stdout, - $this->stderr, - false - ); - - return $kernel->handle(['crest', $name, ...$arguments, '--directory', $this->root]); + return $this->runThroughKernel($name, $class, [...$arguments, '--directory', $this->root]); } protected function startScratchProject(string $prefix, string ...$subdirectories): void diff --git a/tests/Support/NamedArtifactCommandTestCase.php b/tests/Support/NamedArtifactCommandTestCase.php new file mode 100644 index 0000000..d1a5ba4 --- /dev/null +++ b/tests/Support/NamedArtifactCommandTestCase.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\Support; + +use Crest\Console\Command\Command; +use PHPUnit\Framework\TestCase; + +use function file_get_contents; +use function file_put_contents; +use function str_replace; + +/** + * The tests that every Crest\Command\Make\NamedArtifactCommand must pass. + * + * A subclass gives the values for its command through the abstract methods + * and keeps only the tests that are specific to that command. PHPUnit reports + * each test with the name of the subclass, so a failure still identifies the + * command. + */ +abstract class NamedArtifactCommandTestCase extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject(str_replace(':', '-', $this->commandName()), $this->directory()); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testAnUnusableNameIsReported(): void + { + $status = $this->runCommand(['Admin/Sample']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'Admin/Sample' is not a usable class name", + $this->readStderr() + ); + } + + public function testCreatedPathIsReported(): void + { + $this->runCommand(['Sample']); + + $this->assertStringContainsString( + 'Created ' . $this->file('Sample' . $this->suffix()), + $this->readStdout() + ); + } + + public function testDefinitionNamesItself(): void + { + $class = $this->command(); + + $this->assertSame($this->commandName(), (new $class())->define()->getName()); + } + + public function testForceOverwritesAnExistingFile(): void + { + $file = $this->file('Sample' . $this->suffix()); + + $this->runCommand(['Sample']); + file_put_contents($file, 'stale'); + + $status = $this->runCommand(['Sample', '--force']); + + $this->assertSame(0, $status); + $this->assertStringNotContainsString('stale', (string) file_get_contents($file)); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testRefusesToOverwriteWithoutForce(): void + { + $this->runCommand(['Sample']); + + $status = $this->runCommand(['Sample']); + + $this->assertSame(1, $status); + $this->assertStringContainsString('already exists', $this->readStderr()); + } + + public function testTheDirectoryIsCreatedWhenItIsAbsent(): void + { + // The configured path is only a default. A project that never had this + // artifact does not have the directory. + $this->safeDeleteDirectory($this->root . '/' . $this->directory()); + + $status = $this->runCommand(['Sample']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->file('Sample' . $this->suffix())); + } + + public function testTheImportIsAliasedSoTheNameCanNeverCollide(): void + { + // The name that is only the suffix is the pathological case: the class + // gets the same name as the contract or base class that the stub + // imports. Without the alias, the generated file does not compile. + $status = $this->runCommand([$this->suffix()]); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + $this->declaration(), + (string) file_get_contents($this->file($this->suffix())) + ); + } + + public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + { + $status = $this->runCommand(['Sample' . $this->suffix()]); + + $this->assertSame(0, $status); + $this->assertFileExists($this->file('Sample' . $this->suffix())); + $this->assertFileDoesNotExist($this->file('Sample' . $this->suffix() . $this->suffix())); + } + + /** + * The command under test. + * + * @return class-string + */ + abstract protected function command(): string; + + /** + * The name that the command registers, for example `make:middleware`. + * Not name(): PHPUnit's TestCase declares that method final. + */ + abstract protected function commandName(): string; + + /** + * The class declaration that the stub writes when the name is only the + * suffix, for example `final class Middleware implements + * MiddlewareContract`. + */ + abstract protected function declaration(): string; + + /** + * Where the command writes, relative to the project root. + */ + abstract protected function directory(): string; + + /** + * @param list $arguments + */ + protected function runCommand(array $arguments): int + { + return $this->runProjectCommand($this->commandName(), $this->command(), $arguments); + } + + /** + * The suffix of the class name, for example `Middleware`. + */ + abstract protected function suffix(): string; + + private function file(string $class): string + { + return $this->root . '/' . $this->directory() . '/' . $class . '.php'; + } +} diff --git a/tests/Support/Process/FakeRunner.php b/tests/Support/Process/FakeRunner.php new file mode 100644 index 0000000..640dbb4 --- /dev/null +++ b/tests/Support/Process/FakeRunner.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\Process; + +use Crest\Process\Runner; + +/** + * Records each command and directory, and returns a set exit status. Runs + * nothing. + */ +final class FakeRunner implements Runner +{ + /** @var list, string|null}> */ + public array $calls = []; + + public function __construct( + private readonly int $status = 0, + ) { + } + + public function run(array $command, ?string $directory = null): int + { + $this->calls[] = [$command, $directory]; + + return $this->status; + } +} diff --git a/tests/Support/RunsACommandDirectly.php b/tests/Support/RunsACommandDirectly.php new file mode 100644 index 0000000..bef9330 --- /dev/null +++ b/tests/Support/RunsACommandDirectly.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; + +use Crest\Console\Command\Command; +use Crest\Console\Input; +use Crest\Console\Kernel; +use Crest\Console\Output; + +/** + * Runs one command without the kernel. The kernel creates commands with + * `new $class()`, so a test cannot give them a fake collaborator. This trait + * binds argv the same way as the kernel does, with the global options. + * + * @mixin \PHPUnit\Framework\TestCase + */ +trait RunsACommandDirectly +{ + use CapturesOutput; + + /** + * @param list $tokens + */ + protected function handleDirectly(Command $command, array $tokens): int + { + $definition = $command->define(); + + return $command->handle( + new Input($definition->getName(), $definition->merge(Kernel::globals())->bind($tokens)), + new Output($this->stdout, $this->stderr, false) + ); + } +} diff --git a/tests/Support/RunsThroughTheKernel.php b/tests/Support/RunsThroughTheKernel.php new file mode 100644 index 0000000..d06c299 --- /dev/null +++ b/tests/Support/RunsThroughTheKernel.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Support; + +use Crest\Commands; +use Crest\Console\Command\Command; +use Crest\Console\Kernel; +use Crest\Console\Registry; + +/** + * Runs one command through the real kernel, with the captured streams. + * + * The kernel creates the command with `new $class()`. A test that must give + * the command a fake collaborator uses RunsACommandDirectly instead. + * + * @mixin \PHPUnit\Framework\TestCase + */ +trait RunsThroughTheKernel +{ + use CapturesOutput; + + /** + * @param class-string $class + * @param list $tokens The arguments and options, as a user types them + */ + protected function runThroughKernel(string $name, string $class, array $tokens): int + { + $kernel = new Kernel( + Commands::NAME, + (new Registry())->add($name, $class), + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + return $kernel->handle(['crest', $name, ...$tokens]); + } +} diff --git a/tests/Unit/Command/DownCommandTest.php b/tests/Unit/Command/DownCommandTest.php new file mode 100644 index 0000000..214da2f --- /dev/null +++ b/tests/Unit/Command/DownCommandTest.php @@ -0,0 +1,58 @@ + + * + * 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 Crest\Command\DownCommand; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\RunsACommandDirectly; +use PHPUnit\Framework\TestCase; + +final class DownCommandTest extends TestCase +{ + use RunsACommandDirectly; + + protected function setUp(): void + { + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + } + + public function testDefinitionNamesItselfDown(): void + { + $this->assertSame('down', (new DownCommand())->define()->getName()); + } + + public function testTheContainersStopAndAreRemoved(): void + { + $runner = new FakeRunner(); + + $status = $this->handleDirectly(new DownCommand($runner), []); + + $this->assertSame(0, $status); + $this->assertSame([[['docker', 'compose', 'down'], null]], $runner->calls); + } + + public function testVolumesAreRemovedToo(): void + { + $runner = new FakeRunner(); + + $this->handleDirectly(new DownCommand($runner), ['--volumes']); + + $this->assertSame([[['docker', 'compose', 'down', '--volumes'], null]], $runner->calls); + } +} diff --git a/tests/Unit/Command/GeneratedProjectTest.php b/tests/Unit/Command/GeneratedProjectTest.php new file mode 100644 index 0000000..4852fcc --- /dev/null +++ b/tests/Unit/Command/GeneratedProjectTest.php @@ -0,0 +1,242 @@ + + * + * 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 Closure; +use Crest\Command\Make\ActionCommand; +use Crest\Command\NewCommand; +use Crest\Console\Input; +use Crest\Console\Kernel; +use Crest\Console\Output; +use Crest\Console\PackageVersion; +use Crest\Project\Bootstrap; +use Crest\Project\Config; +use Crest\Project\Flavor; +use Crest\Tests\Support\GeneratesInAScratchProject; +use ParseError; +use Phalcon\ADR\Front\AbstractHttpFront; +use Phalcon\Container\Container; +use Phalcon\Contracts\ADR\Application; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\Http\ResponseInterface; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +use function class_exists; +use function extension_loaded; +use function file_get_contents; +use function interface_exists; +use function is_file; +use function preg_match_all; +use function spl_autoload_register; +use function spl_autoload_unregister; +use function sprintf; +use function str_replace; +use function str_starts_with; +use function strlen; +use function substr; +use function token_get_all; + +use const TOKEN_PARSE; + +/** + * The generated project must run, not only exist. + * + * `new` runs nothing, so no other test proves the wiring: crest.php names a + * front controller that crest can boot, and the front sends GET / to the seed + * action. Boot alone does not prove the second part: + * AbstractHttpFront::boot() does not call getApplication(), and + * getApplication() sets the base namespace and the action directory. Thus + * the last test sends GET / through the generated application in this + * process. There is no docker and no HTTP. The full generate, install and + * request run is a release check. + * + * The generated classes are declared in this process. Thus they use a + * namespace that no other test uses. + */ +final class GeneratedProjectTest extends TestCase +{ + use GeneratesInAScratchProject; + + private const ROOT_NAMESPACE = 'Scaffolded'; + + private ?Closure $autoloader = null; + + private string $project = ''; + + /** @var array */ + private array $server = []; + + protected function setUp(): void + { + $this->startScratchProject('generated-project'); + $this->server = $_SERVER; + + if ( + false === PackageVersion::isInstalled('phalcon/phalcon') + && false === extension_loaded('phalcon') + ) { + $this->markTestSkipped('running the generated project needs Phalcon present'); + } + + $this->runProjectCommand('new', NewCommand::class, ['shop', '--namespace', self::ROOT_NAMESPACE]); + + $this->project = $this->root . '/shop'; + + // The project has no vendor/ yet. Load its classes as its own + // composer.json does: the root namespace maps to src/. + $source = $this->project . '/src/'; + + $this->autoloader = static function (string $class) use ($source): void { + $prefix = self::ROOT_NAMESPACE . '\\'; + + if (false === str_starts_with($class, $prefix)) { + return; + } + + $file = $source . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + + if (true === is_file($file)) { + require $file; + } + }; + + spl_autoload_register($this->autoloader); + } + + protected function tearDown(): void + { + if (null !== $this->autoloader) { + spl_autoload_unregister($this->autoloader); + } + + $_SERVER = $this->server; + + $this->endScratchProject(); + } + + public function testCrestBootsTheGeneratedFrontController(): void + { + $this->assertInstanceOf( + Container::class, + Bootstrap::container(Config::discover($this->project)) + ); + } + + public function testEveryGeneratedPhpFileParsesAndItsImportsResolve(): void + { + $files = ['crest.php', '.htrouter.php', 'public/index.php', 'src/AppFront.php', 'src/Action/Get.php']; + + foreach ($files as $path) { + $code = (string) file_get_contents($this->project . '/' . $path); + + try { + $this->assertNotEmpty(token_get_all($code, TOKEN_PARSE)); + } catch (ParseError $error) { + $this->fail(sprintf('%s does not parse: %s', $path, $error->getMessage())); + } + + preg_match_all('/^use\s+(?!function\s|const\s)([\w\\\\]+)/m', $code, $matches); + + foreach ($matches[1] as $import) { + $this->assertTrue( + class_exists($import) || interface_exists($import), + sprintf('%s imports %s, which does not exist', $path, $import) + ); + } + } + } + + public function testTheGeneratedApplicationAnswersGetSlash(): void + { + $response = $this->get('/'); + + // The content is the message: on failure, the error responder puts + // the reason there. + $this->assertSame(200, $response->getStatusCode(), $response->getContent()); + } + + public function testTheGeneratedApplicationFindsActionsInItsDirectory(): void + { + // GET / names a class directly under the base namespace, so the router + // does not read the action directory for it. A deeper route does: the + // router goes into src/Action/Hello only when that directory exists. + $command = new ActionCommand(); + + $command->handle( + new Input( + 'make:action', + $command->define() + ->merge(Kernel::globals()) + ->bind(['GET', '/hello', '--directory', $this->project]) + ), + new Output($this->stdout, $this->stderr, false) + ); + + $response = $this->get('/hello'); + + $this->assertSame(200, $response->getStatusCode(), $response->getContent()); + } + + public function testTheGeneratedConfigurationIsReadByCrest(): void + { + $config = Config::discover($this->project); + + $this->assertSame(Flavor::ADR, $config->flavor()); + $this->assertSame(self::ROOT_NAMESPACE, $config->namespace()); + $this->assertSame(self::ROOT_NAMESPACE . '\\AppFront', $config->bootstrap()); + $this->assertSame(self::ROOT_NAMESPACE . '\\Action', $config->namespaceFor('action')); + $this->assertSame($this->project . '/src/Action', $config->path('action')); + } + + /** + * Sends a GET request through the generated application, as + * public/index.php does, but in this process and with no emitter. + */ + private function get(string $uri): ResponseInterface + { + // The class that crest.php names, as Bootstrap reads it. A literal + // class name here would make PHPStan look for a class that exists only + // at run time. + $class = Config::discover($this->project)->bootstrap(); + + if (null === $class) { + $this->fail('crest.php names no front controller'); + } + + $front = new $class($this->project); + + if (false === $front instanceof AbstractHttpFront) { + $this->fail(sprintf('%s is not a front controller', $class)); + } + + $container = $front->boot(); + $application = (new ReflectionMethod($front, 'getApplication'))->invoke($front, $container); + + if (false === $application instanceof Application) { + $this->fail('getApplication() did not return an ADR application'); + } + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = $uri; + + $request = $container->get(AttributeRequest::class); + + if (false === $request instanceof AttributeRequest) { + $this->fail('the container did not supply an AttributeRequest'); + } + + return $application->handle($request); + } +} diff --git a/tests/Unit/Command/InstallCommandTest.php b/tests/Unit/Command/InstallCommandTest.php new file mode 100644 index 0000000..1010107 --- /dev/null +++ b/tests/Unit/Command/InstallCommandTest.php @@ -0,0 +1,52 @@ + + * + * 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 Crest\Command\InstallCommand; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\RunsACommandDirectly; +use PHPUnit\Framework\TestCase; + +final class InstallCommandTest extends TestCase +{ + use RunsACommandDirectly; + + protected function setUp(): void + { + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + } + + public function testComposerInstallRunsInTheAppService(): void + { + $runner = new FakeRunner(); + + $status = $this->handleDirectly(new InstallCommand($runner), []); + + $this->assertSame(0, $status); + $this->assertSame( + [[['docker', 'compose', 'exec', 'app', 'composer', 'install'], null]], + $runner->calls + ); + } + + public function testDefinitionNamesItselfInstall(): void + { + $this->assertSame('install', (new InstallCommand())->define()->getName()); + } +} diff --git a/tests/Unit/Command/Make/CommandCommandTest.php b/tests/Unit/Command/Make/CommandCommandTest.php index 26dd685..84bd89d 100644 --- a/tests/Unit/Command/Make/CommandCommandTest.php +++ b/tests/Unit/Command/Make/CommandCommandTest.php @@ -15,111 +15,14 @@ use Crest\Command\Make\CommandCommand; use Crest\Commands; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; use const PHP_EOL; -final class CommandCommandTest extends TestCase +final class CommandCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; - - protected function setUp(): void - { - $this->startScratchProject('make-command', 'src/Command'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Greet']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Greet' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Greet']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Command/GreetCommand.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeCommand(): void - { - $this->assertSame('make:command', (new CommandCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingCommand(): void - { - $this->runCommand(['Greet']); - file_put_contents($this->root . '/src/Command/GreetCommand.php', 'stale'); - - $status = $this->runCommand(['Greet', '--force']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Command/GreetCommand.php') - ); - } - - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); - - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } - - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Greet']); - - $status = $this->runCommand(['Greet']); - - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } - - public function testTheBaseClassIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:command Command` is the pathological case: without the alias the - // stub would emit `final class Command extends Command`, which does not - // compile. The registry name falls back to the whole class rather than - // the empty string stripping the suffix would leave. - $status = $this->runCommand(['Command']); - - $contents = (string) file_get_contents($this->root . '/src/Command/Command.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString('final class Command extends CrestCommand', $contents); - $this->assertStringContainsString("Definition::for('command',", $contents); - } - - public function testTheCommandDirectoryIsCreatedWhenItIsAbsent(): void - { - $this->safeDeleteDirectory($this->root . '/src/Command'); - - $status = $this->runCommand(['Greet']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Command/GreetCommand.php'); - } - public function testTheExtraBlockIsPrintedWithEscapedBackslashes(): void { // The registry has no other way in, so the block is the deliverable. @@ -141,16 +44,27 @@ public function testTheExtraBlockIsPrintedWithEscapedBackslashes(): void $this->assertSame($expected, $this->readStdout()); } - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + public function testTheRegistryNameFallsBackToTheWholeClass(): void { - $status = $this->runCommand(['GreetCommand']); + // The registry name falls back to the whole class rather than the empty + // string stripping the suffix would leave. + $status = $this->runCommand(['Command']); - $contents = (string) file_get_contents($this->root . '/src/Command/GreetCommand.php'); + $contents = (string) file_get_contents($this->root . '/src/Command/Command.php'); $this->assertSame(0, $status); - $this->assertFileDoesNotExist($this->root . '/src/Command/GreetCommandCommand.php'); + $this->assertStringContainsString("Definition::for('command',", $contents); + } + + public function testTheSuffixDoesNotLeakIntoTheRegistryName(): void + { // The registry name is derived from the class minus its suffix, so // spelling the suffix out must not leak into it as 'greetcommand'. + $status = $this->runCommand(['GreetCommand']); + + $contents = (string) file_get_contents($this->root . '/src/Command/GreetCommand.php'); + + $this->assertSame(0, $status); $this->assertStringContainsString("Definition::for('greet',", $contents); } @@ -194,11 +108,28 @@ public function testTheWholeCommandIsRendered(): void ); } - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int + protected function command(): string + { + return CommandCommand::class; + } + + protected function commandName(): string + { + return 'make:command'; + } + + protected function declaration(): string + { + return 'final class Command extends CrestCommand'; + } + + protected function directory(): string + { + return 'src/Command'; + } + + protected function suffix(): string { - return $this->runProjectCommand('make:command', CommandCommand::class, $arguments); + return 'Command'; } } diff --git a/tests/Unit/Command/Make/MiddlewareCommandTest.php b/tests/Unit/Command/Make/MiddlewareCommandTest.php index 8452833..a322990 100644 --- a/tests/Unit/Command/Make/MiddlewareCommandTest.php +++ b/tests/Unit/Command/Make/MiddlewareCommandTest.php @@ -14,113 +14,14 @@ namespace Crest\Tests\Unit\Command\Make; use Crest\Command\Make\MiddlewareCommand; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; use const PHP_EOL; -final class MiddlewareCommandTest extends TestCase +final class MiddlewareCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; - - protected function setUp(): void - { - $this->startScratchProject('make-middleware', 'src/Middleware'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Auth']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Auth' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Auth']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Middleware/AuthMiddleware.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeMiddleware(): void - { - $this->assertSame('make:middleware', (new MiddlewareCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingMiddleware(): void - { - $this->runCommand(['Auth']); - file_put_contents($this->root . '/src/Middleware/AuthMiddleware.php', 'stale'); - - $status = $this->runCommand(['Auth', '--force']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Middleware/AuthMiddleware.php') - ); - } - - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); - - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } - - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Auth']); - - $status = $this->runCommand(['Auth']); - - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } - - public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:middleware Middleware` is the pathological case: the suffix is - // already there, so the class is named Middleware - and without the - // alias the stub would emit `implements Middleware` beside - // `use ...\Middleware;`, which does not compile. - $status = $this->runCommand(['Middleware']); - - $contents = (string) file_get_contents($this->root . '/src/Middleware/Middleware.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString( - 'final class Middleware implements MiddlewareContract', - $contents - ); - } - - public function testTheMiddlewareDirectoryIsCreatedWhenItIsAbsent(): void - { - $this->safeDeleteDirectory($this->root . '/src/Middleware'); - - $status = $this->runCommand(['Auth']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Middleware/AuthMiddleware.php'); - } - public function testTheRegistrationSnippetIsPrintedWithTheFullClassName(): void { // The generated class is inert until the router names it, and crest will @@ -142,15 +43,6 @@ public function testTheRegistrationSnippetIsPrintedWithTheFullClassName(): void $this->assertSame($expected, $this->readStdout()); } - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void - { - $status = $this->runCommand(['AuthMiddleware']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Middleware/AuthMiddleware.php'); - $this->assertFileDoesNotExist($this->root . '/src/Middleware/AuthMiddlewareMiddleware.php'); - } - public function testTheWholeMiddlewareIsRendered(): void { // Asserted whole rather than by substring: this is generated code nobody @@ -184,11 +76,28 @@ public function testTheWholeMiddlewareIsRendered(): void ); } - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int + protected function command(): string + { + return MiddlewareCommand::class; + } + + protected function commandName(): string + { + return 'make:middleware'; + } + + protected function declaration(): string + { + return 'final class Middleware implements MiddlewareContract'; + } + + protected function directory(): string + { + return 'src/Middleware'; + } + + protected function suffix(): string { - return $this->runProjectCommand('make:middleware', MiddlewareCommand::class, $arguments); + return 'Middleware'; } } diff --git a/tests/Unit/Command/Make/NamedArtifactCommandTest.php b/tests/Unit/Command/Make/NamedArtifactCommandTest.php new file mode 100644 index 0000000..d5efc91 --- /dev/null +++ b/tests/Unit/Command/Make/NamedArtifactCommandTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Make; + +use Crest\Command\Make\NamedArtifactCommand; +use Crest\Console\Parsing\Definition; +use PHPUnit\Framework\TestCase; + +final class NamedArtifactCommandTest extends TestCase +{ + public function testTheBaseDeclaresTheForceOption(): void + { + $option = $this->definition()->findOption('force'); + + $this->assertNotNull($option); + $this->assertSame('Overwrite an existing widget', $option->description); + } + + public function testTheBaseDeclaresTheNameArgument(): void + { + $arguments = $this->definition()->getArguments(); + + $this->assertCount(1, $arguments); + $this->assertSame('name', $arguments[0]->name); + $this->assertTrue($arguments[0]->required); + $this->assertSame('Widget name, e.g. Blue', $arguments[0]->description); + } + + public function testTheCommandNameComesFromTheKey(): void + { + $definition = $this->definition(); + + $this->assertSame('make:widget', $definition->getName()); + $this->assertSame('Create a widget', $definition->getDescription()); + } + + /** + * A generator that gives only what the base cannot know. handle() reads + * the `name` argument and the --force option, so the base must declare + * them. + */ + private function definition(): Definition + { + $command = new class () extends NamedArtifactCommand { + protected function description(): string + { + return 'Create a widget'; + } + + protected function example(): string + { + return 'Blue'; + } + + protected function key(): string + { + return 'widget'; + } + + protected function suffix(): string + { + return 'Widget'; + } + }; + + return $command->define(); + } +} diff --git a/tests/Unit/Command/Make/ProviderCommandTest.php b/tests/Unit/Command/Make/ProviderCommandTest.php index 2c3cc28..35f2035 100644 --- a/tests/Unit/Command/Make/ProviderCommandTest.php +++ b/tests/Unit/Command/Make/ProviderCommandTest.php @@ -14,114 +14,14 @@ namespace Crest\Tests\Unit\Command\Make; use Crest\Command\Make\ProviderCommand; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; use const PHP_EOL; -final class ProviderCommandTest extends TestCase +final class ProviderCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; - - protected function setUp(): void - { - $this->startScratchProject('make-provider', 'src/Provider'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Cache']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Cache' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Cache']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Provider/CacheProvider.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeProvider(): void - { - $this->assertSame('make:provider', (new ProviderCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingProvider(): void - { - $this->runCommand(['Cache']); - file_put_contents($this->root . '/src/Provider/CacheProvider.php', 'stale'); - - $status = $this->runCommand(['Cache', '--force']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Provider/CacheProvider.php') - ); - } - - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); - - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } - - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Cache']); - - $status = $this->runCommand(['Cache']); - - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } - - public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:provider Provider` is the pathological case: the suffix is - // already there, so the class is named Provider - and without the alias - // the stub would emit `implements Provider` beside `use ...\Provider;`, - // which does not compile. Collection is left unaliased: no artifact - // suffix can produce that name. - $status = $this->runCommand(['Provider']); - - $contents = (string) file_get_contents($this->root . '/src/Provider/Provider.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString( - 'final class Provider implements ProviderContract', - $contents - ); - } - - public function testTheProviderDirectoryIsCreatedWhenItIsAbsent(): void - { - $this->safeDeleteDirectory($this->root . '/src/Provider'); - - $status = $this->runCommand(['Cache']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Provider/CacheProvider.php'); - } - public function testTheRegistrationSnippetIsPrintedWithTheParentCall(): void { // The whole hint is the deliverable, asserted as one block: the blank @@ -144,15 +44,6 @@ public function testTheRegistrationSnippetIsPrintedWithTheParentCall(): void $this->assertSame($expected, $this->readStdout()); } - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void - { - $status = $this->runCommand(['CacheProvider']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Provider/CacheProvider.php'); - $this->assertFileDoesNotExist($this->root . '/src/Provider/CacheProviderProvider.php'); - } - public function testTheWholeProviderIsRendered(): void { // Asserted whole rather than by substring: this is generated code nobody @@ -188,11 +79,29 @@ public function testTheWholeProviderIsRendered(): void ); } - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int + protected function command(): string + { + return ProviderCommand::class; + } + + protected function commandName(): string + { + return 'make:provider'; + } + + protected function declaration(): string + { + // Collection is left unaliased: no artifact suffix can produce that name. + return 'final class Provider implements ProviderContract'; + } + + protected function directory(): string + { + return 'src/Provider'; + } + + protected function suffix(): string { - return $this->runProjectCommand('make:provider', ProviderCommand::class, $arguments); + return 'Provider'; } } diff --git a/tests/Unit/Command/Make/ResponderCommandTest.php b/tests/Unit/Command/Make/ResponderCommandTest.php index 1c86835..6a6f331 100644 --- a/tests/Unit/Command/Make/ResponderCommandTest.php +++ b/tests/Unit/Command/Make/ResponderCommandTest.php @@ -14,122 +14,12 @@ namespace Crest\Tests\Unit\Command\Make; use Crest\Command\Make\ResponderCommand; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; -final class ResponderCommandTest extends TestCase +final class ResponderCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; - - protected function setUp(): void - { - $this->startScratchProject('make-responder', 'src/Responder'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Album']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Album' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Album']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Responder/AlbumResponder.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeResponder(): void - { - $this->assertSame('make:responder', (new ResponderCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingResponder(): void - { - $this->runCommand(['Album']); - file_put_contents($this->root . '/src/Responder/AlbumResponder.php', 'stale'); - - $status = $this->runCommand(['Album', '--force']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Responder/AlbumResponder.php') - ); - } - - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); - - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } - - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Album']); - - $status = $this->runCommand(['Album']); - - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } - - public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:responder Responder` is the pathological case: the suffix is - // already there, so the class is named Responder - and without the alias - // the stub would emit `implements Responder` beside - // `use ...\Responder;`, which does not compile. - $status = $this->runCommand(['Responder']); - - $contents = (string) file_get_contents($this->root . '/src/Responder/Responder.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString( - 'final class Responder implements ResponderContract', - $contents - ); - } - - public function testTheResponderDirectoryIsCreatedWhenItIsAbsent(): void - { - // A project that has never had a responder has no src/Responder, and the - // default path is only a default - nothing guarantees it exists. - $this->safeDeleteDirectory($this->root . '/src/Responder'); - - $status = $this->runCommand(['Album']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Responder/AlbumResponder.php'); - } - - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void - { - $status = $this->runCommand(['AlbumResponder']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Responder/AlbumResponder.php'); - $this->assertFileDoesNotExist($this->root . '/src/Responder/AlbumResponderResponder.php'); - } - public function testTheWholeResponderIsRendered(): void { // Asserted whole rather than by substring: this is generated code nobody @@ -168,11 +58,28 @@ public function testTheWholeResponderIsRendered(): void ); } - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int + protected function command(): string + { + return ResponderCommand::class; + } + + protected function commandName(): string + { + return 'make:responder'; + } + + protected function declaration(): string + { + return 'final class Responder implements ResponderContract'; + } + + protected function directory(): string + { + return 'src/Responder'; + } + + protected function suffix(): string { - return $this->runProjectCommand('make:responder', ResponderCommand::class, $arguments); + return 'Responder'; } } diff --git a/tests/Unit/Command/NewCommandTest.php b/tests/Unit/Command/NewCommandTest.php new file mode 100644 index 0000000..7aea690 --- /dev/null +++ b/tests/Unit/Command/NewCommandTest.php @@ -0,0 +1,703 @@ + + * + * 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 Crest\Command\NewCommand; +use Crest\Command\Stub\PublishCommand; +use Crest\Generator\Stub; +use Crest\Paths; +use Crest\Tests\Support\GeneratesInAScratchProject; +use FilesystemIterator; +use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; + +use function dirname; +use function file_get_contents; +use function file_put_contents; +use function json_decode; +use function mkdir; +use function preg_match; +use function sprintf; + +use const JSON_THROW_ON_ERROR; +use const PHP_EOL; + +final class NewCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('new'); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + /** + * @return iterable + */ + public static function variants(): iterable + { + yield 'v5' => ['v5']; + yield 'v6' => ['v6']; + } + + public function testANamespacedRootReachesEveryFile(): void + { + $status = $this->runCommand(['my-app', '--namespace', 'Acme\\Shop']); + + $this->assertSame(0, $status); + $this->assertSame(['psr-4' => ['Acme\\Shop\\' => 'src/']], $this->composer()['autoload']); + $this->assertStringContainsString("'namespace' => 'Acme\\Shop',", $this->read('crest.php')); + $this->assertStringContainsString( + "'bootstrap' => Acme\\Shop\\AppFront::class,", + $this->read('crest.php') + ); + $this->assertStringContainsString("use Acme\\Shop\\AppFront;\n", $this->read('public/index.php')); + $this->assertStringContainsString("namespace Acme\\Shop;\n", $this->read('src/AppFront.php')); + $this->assertStringContainsString( + "->setBaseNamespace('Acme\\Shop\\Action')", + $this->read('src/AppFront.php') + ); + $this->assertStringContainsString( + "namespace Acme\\Shop\\Action;\n", + $this->read('src/Action/Get.php') + ); + } + + public function testAnEmptyDirectoryOptionMeansTheWorkingDirectory(): void + { + // `--directory="$DIR"` with an unset variable. As with every other + // option, empty reads as absent. Not: the project in the filesystem + // root. + $status = $this->runInWorkingDirectory(['my-app', '--directory=']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/my-app/composer.json'); + $this->assertStringStartsWith('Created my-app/' . PHP_EOL, $this->readStdout()); + } + + public function testAnEmptyExistingDirectoryIsUsed(): void + { + mkdir($this->root . '/my-app'); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/my-app/composer.json'); + } + + public function testANonEmptyDirectoryIsRefused(): void + { + mkdir($this->root . '/my-app'); + file_put_contents($this->root . '/my-app/notes.txt', 'mine'); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + $this->root . '/my-app exists and is not empty; pass --force to write into it', + $this->readStderr() + ); + $this->assertFileDoesNotExist($this->root . '/my-app/composer.json'); + } + + public function testAnUnknownPhalconVersionIsRejected(): void + { + $status = $this->runCommand(['my-app', '--phalcon', 'v7']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "unknown Phalcon version 'v7'; expected v5 or v6", + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + + public function testAnUnusableNamespaceIsRejected(): void + { + $status = $this->runCommand(['my-app', '--namespace', 'my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'my-app' is not a usable namespace; expected something like 'App' or 'Acme\\Shop'", + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + + public function testAPhpVersionBelowTheFloorIsRejected(): void + { + $status = $this->runCommand(['my-app', '--php', '8.0']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + 'PHP 8.0 is too old; the generated code needs 8.1 or later', + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + + public function testAPhpVersionWithAPatchIsRejected(): void + { + $status = $this->runCommand(['my-app', '--php', '8.4.1']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'8.4.1' is not a PHP version; expected major.minor, e.g. 8.4", + $this->readStderr() + ); + } + + public function testAProjectNameMustStartWithALetterOrDigit(): void + { + $status = $this->runCommand(['_app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'_app' is not a usable project name; expected letters, digits, '-' and '_', " + . 'starting with a letter or digit', + $this->readStderr() + ); + } + + public function testAProjectNameThatIsAPathIsRejected(): void + { + $status = $this->runCommand(['../elsewhere']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'../elsewhere' is not a usable project name", + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist(dirname($this->root) . '/elsewhere'); + } + + public function testAProjectStubFromStubPublishIsUsed(): void + { + // stub:publish must write where `new` reads, with the same + // --directory. That directory is not a project: it has no crest.php + // and no composer.json. + $directory = $this->root . '/work'; + + mkdir($directory); + + $published = $this->runThroughKernel( + 'stub:publish', + PublishCommand::class, + ['project-readme', '--directory', $directory] + ); + + $this->assertSame(0, $published); + + file_put_contents(Stub::overridePath($directory, 'adr', 'project-readme'), "custom {{ project }}\n"); + + $status = $this->runThroughKernel('new', NewCommand::class, ['my-app', '--directory', $directory]); + + $this->assertSame(0, $status); + $this->assertSame("custom my-app\n", (string) file_get_contents($directory . '/my-app/README.md')); + } + + public function testAPublishedProjectStubInTheParentDirectoryIsUsed(): void + { + $override = Stub::overridePath($this->root, 'adr', 'project-readme'); + + mkdir(dirname($override), 0o775, true); + file_put_contents($override, "custom {{ project }}\n"); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(0, $status); + $this->assertSame("custom my-app\n", $this->read('README.md')); + } + + public function testAPublishedStubWithAnUnknownPlaceholderStopsBeforeAnyWrite(): void + { + // project-front renders last. Without the render pass before the first + // write, the files before it are already on disk when it fails. + $override = Stub::overridePath($this->root, 'adr', 'project-front'); + + mkdir(dirname($override), 0o775, true); + file_put_contents($override, "{{ old }}\n"); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString($override . ' has no value for {{ old }}', $this->readStderr()); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + + public function testATargetThatIsAFileIsRefused(): void + { + file_put_contents($this->root . '/my-app', 'not a directory'); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + $this->root . '/my-app exists and is not a directory', + $this->readStderr() + ); + } + + public function testComposerJsonRequiresTheExtensionByDefault(): void + { + $this->runCommand(['my-app']); + + $this->assertSame( + [ + 'type' => 'project', + 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5.18'], + 'require-dev' => ['phalcon/crest' => 'dev-master'], + 'autoload' => ['psr-4' => ['App\\' => 'src/']], + 'config' => ['sort-packages' => true], + ], + $this->composer() + ); + } + + public function testComposerJsonRequiresThePackageForV6(): void + { + $this->runCommand(['my-app', '--phalcon', 'v6']); + + $this->assertSame( + ['php' => '>=8.4', 'phalcon/phalcon' => '^6.0@RC'], + $this->composer()['require'] + ); + } + + public function testDefinitionNamesItselfNew(): void + { + $this->assertSame('new', (new NewCommand())->define()->getName()); + } + + public function testEveryProjectFileIsWritten(): void + { + $status = $this->runCommand(['my-app']); + + $this->assertSame(0, $status); + + foreach ( + [ + 'composer.json', + 'crest.php', + '.env', + '.gitignore', + '.htrouter.php', + 'README.md', + 'docker-compose.yml', + 'resources/docker/Dockerfile', + 'public/index.php', + 'src/AppFront.php', + 'src/Action/Get.php', + ] as $path + ) { + $this->assertFileExists($this->root . '/my-app/' . $path); + } + } + + public function testForceOverwritesAPreviouslyGeneratedProject(): void + { + $this->runCommand(['my-app']); + file_put_contents($this->root . '/my-app/composer.json', 'stale'); + + $status = $this->runCommand(['my-app', '--force']); + + $this->assertSame(0, $status); + $this->assertSame('project', $this->composer()['type']); + } + + public function testForceWritesIntoANonEmptyDirectory(): void + { + mkdir($this->root . '/my-app'); + file_put_contents($this->root . '/my-app/notes.txt', 'mine'); + + $status = $this->runCommand(['my-app', '--force']); + + $this->assertSame(0, $status); + $this->assertSame('mine', $this->read('notes.txt')); + $this->assertFileExists($this->root . '/my-app/composer.json'); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + /** + * @dataProvider variants + */ + public function testNoPlaceholderIsLeftInAnyWrittenFile(string $variant): void + { + // ProjectStubsTest renders the stubs with its own values. This test + // uses the values that NewCommand supplies, so a key that NewCommand + // does not supply fails here. + $this->runCommand(['my-app', '--phalcon', $variant]); + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->root . '/my-app', FilesystemIterator::SKIP_DOTS) + ); + + $scanned = 0; + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + $scanned++; + + $this->assertStringNotContainsString( + '{{', + (string) file_get_contents($file->getPathname()), + sprintf('%s still has a placeholder', $file->getPathname()) + ); + } + + $this->assertGreaterThan(0, $scanned); + } + + public function testSurroundingBackslashesAreDropped(): void + { + $this->runCommand(['my-app', '--namespace', '\\Acme\\']); + + $this->assertSame(['psr-4' => ['Acme\\' => 'src/']], $this->composer()['autoload']); + } + + public function testTheClosingOutputShowsBothWaysToRunIt(): void + { + $this->runCommand(['my-app']); + + $target = $this->root . '/my-app'; + + $this->assertSame( + 'Created ' . $target . '/' . PHP_EOL + . PHP_EOL + . 'Nothing runs it yet. With docker:' . PHP_EOL + . PHP_EOL + . ' cd ' . $target . PHP_EOL + . ' crest up' . PHP_EOL + . ' crest install' . PHP_EOL + . PHP_EOL + . 'Or with PHP and composer on the host:' . PHP_EOL + . PHP_EOL + . ' cd ' . $target . PHP_EOL + . ' composer install' . PHP_EOL + . ' php -S localhost:8080 -t public .htrouter.php' . PHP_EOL + . PHP_EOL + . 'Then GET / answers from src/Action/Get.php' . PHP_EOL, + $this->readStdout() + ); + } + + public function testTheDirectoryOptionNamesWhereTheProjectGoes(): void + { + // Not the working directory, and with a trailing slash: the project + // must land in nested/, and the report must not show a double slash. + $status = $this->runInWorkingDirectory(['my-app', '--directory', $this->root . '/nested/']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/nested/my-app/composer.json'); + $this->assertStringStartsWith( + 'Created ' . $this->root . '/nested/my-app/' . PHP_EOL, + $this->readStdout() + ); + } + + public function testTheDockerfileAcceptsIdsThatTheImageAlreadyUses(): void + { + // GID 20 is the main group on macOS, and the Debian image already has + // it. Without -o, groupadd fails and the build stops. + $this->runCommand(['my-app']); + + $dockerfile = $this->read('resources/docker/Dockerfile'); + + $this->assertStringContainsString('groupadd -o -g "${GID}" "${GROUP}"', $dockerfile); + $this->assertStringContainsString( + 'useradd -l -m -o -u "${UID}" -g "${GID}" -s /bin/bash "${USER}"', + $dockerfile + ); + } + + public function testTheDockerfileCommentsTheExtensionOutForV6(): void + { + $this->runCommand(['my-app', '--phalcon', 'v6']); + + $dockerfile = $this->read('resources/docker/Dockerfile'); + + $this->assertStringContainsString("# Phalcon v6.\n", $dockerfile); + $this->assertStringContainsString("\n# pie install --no-interaction phalcon/cphalcon:^5.18\n", $dockerfile); + $this->assertStringNotContainsString("\npie install", $dockerfile); + } + + public function testTheDockerfileInstallsTheExtensionForV5(): void + { + $this->runCommand(['my-app']); + + $dockerfile = $this->read('resources/docker/Dockerfile'); + + $this->assertStringContainsString("# Phalcon v5.\n", $dockerfile); + $this->assertStringContainsString("\npie install --no-interaction phalcon/cphalcon:^5.18\n", $dockerfile); + $this->assertStringContainsString( + 'CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", ".htrouter.php"]', + $dockerfile + ); + } + + public function testTheDockerfileInstallsTheExtensionVersionThatComposerRequires(): void + { + // The constraint is in two places: NewCommand writes it into + // composer.json, and the Dockerfile stub has its own copy. They must + // agree. + $this->runCommand(['my-app']); + + preg_match( + '#^pie install --no-interaction phalcon/cphalcon:(\S+)$#m', + $this->read('resources/docker/Dockerfile'), + $matches + ); + + $this->assertSame( + ['php' => '>=8.4', 'ext-phalcon' => $matches[1] ?? ''], + $this->composer()['require'] + ); + } + + public function testTheDockerFilesCarryTheProjectName(): void + { + $this->runCommand(['my-app']); + + $this->assertStringContainsString("PROJECT_PREFIX=my-app\n", $this->read('.env')); + $this->assertStringContainsString( + 'container_name: ${PROJECT_PREFIX:-my-app}-app', + $this->read('docker-compose.yml') + ); + $this->assertStringStartsWith("# my-app\n", $this->read('README.md')); + } + + public function testTheFrontControllerIsRendered(): void + { + // Asserted whole: this is generated code nobody reviews. + $this->runCommand(['my-app']); + + $this->assertSame( + "setBaseNamespace('App\\Action')\n" + . " ->setActionDirectory(\$this->projectRoot . '/src/Action');\n" + . " }\n" + . "}\n", + $this->read('src/AppFront.php') + ); + } + + public function testTheGeneratedConfigNamesTheFrontController(): void + { + $this->runCommand(['my-app']); + + // The docblock comes before declare(): PSR-12 puts the file docblock + // first in the header. + $this->assertSame( + " 'adr',\n" + . " 'namespace' => 'App',\n" + . " 'bootstrap' => App\\AppFront::class,\n" + . " 'paths' => ['action' => 'src/Action'],\n" + . "];\n", + $this->read('crest.php') + ); + } + + public function testTheGitignoreKeepsVendorOut(): void + { + $this->runCommand(['my-app']); + + $this->assertSame("/vendor/\n", $this->read('.gitignore')); + } + + public function testThePhalconVersionIsCaseInsensitive(): void + { + $this->runCommand(['my-app', '--phalcon', 'V6']); + + $this->assertSame( + ['php' => '>=8.4', 'phalcon/phalcon' => '^6.0@RC'], + $this->composer()['require'] + ); + } + + public function testThePhpFloorItselfIsAccepted(): void + { + $status = $this->runCommand(['my-app', '--php', '8.1']); + + $this->assertSame(0, $status); + } + + public function testThePhpVersionReachesComposerAndTheDockerfile(): void + { + $this->runCommand(['my-app', '--php', '8.3']); + + $this->assertSame(['php' => '>=8.3', 'ext-phalcon' => '^5.18'], $this->composer()['require']); + $this->assertStringContainsString( + "ARG PHP_VERSION=8.3\n", + $this->read('resources/docker/Dockerfile') + ); + } + + public function testTheRouterScriptIsRendered(): void + { + $this->runCommand(['my-app']); + + $this->assertSame( + "read('.htrouter.php') + ); + } + + public function testTheSeedActionIsTheRootAction(): void + { + // The packaged action stub, as make:action would render GET /. + $this->runCommand(['my-app']); + + $expected = (new Stub(Paths::stubs()))->render( + 'adr', + 'action', + [ + 'attributes' => '', + 'class' => 'Get', + 'namespace' => 'App\\Action', + 'params' => '', + ] + ); + + $this->assertSame($expected, $this->read('src/Action/Get.php')); + $this->assertStringContainsString('final class Get implements Action', $expected); + } + + public function testTheWebEntryPointIsRendered(): void + { + $this->runCommand(['my-app']); + + $this->assertSame( + "run());\n", + $this->read('public/index.php') + ); + } + + public function testWithoutDirectoryTheProjectLandsInTheWorkingDirectory(): void + { + // startScratchProject() moved the working directory into the scratch + // root, as a user would cd into the directory they want. + $status = $this->runInWorkingDirectory(['my-app']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/my-app/composer.json'); + $this->assertStringStartsWith('Created my-app/' . PHP_EOL, $this->readStdout()); + $this->assertStringContainsString(' cd my-app' . PHP_EOL, $this->readStdout()); + } + + /** + * @return array + */ + private function composer(): array + { + /** @var array $decoded */ + $decoded = json_decode($this->read('composer.json'), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + + private function read(string $path): string + { + return (string) file_get_contents($this->root . '/my-app/' . $path); + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('new', NewCommand::class, $arguments); + } + + /** + * Runs `new` with no --directory added, the way a user types it. + * + * @param list $arguments + */ + private function runInWorkingDirectory(array $arguments): int + { + return $this->runThroughKernel('new', NewCommand::class, $arguments); + } +} diff --git a/tests/Unit/Command/Stub/PublishCommandTest.php b/tests/Unit/Command/Stub/PublishCommandTest.php index ee6f109..a3091c0 100644 --- a/tests/Unit/Command/Stub/PublishCommandTest.php +++ b/tests/Unit/Command/Stub/PublishCommandTest.php @@ -20,9 +20,18 @@ use PHPUnit\Framework\TestCase; use function basename; +use function chdir; use function file_get_contents; use function file_put_contents; use function glob; +use function mkdir; +use function preg_match_all; +use function setlocale; +use function sort; +use function str_starts_with; +use function unlink; + +use const LC_COLLATE; final class PublishCommandTest extends TestCase { @@ -72,6 +81,69 @@ public function testAnAlreadyPublishedStubIsSkippedNotOverwritten(): void ); } + public function testAProjectStubGoesIntoTheDirectoryOptionAndNotAParentProject(): void + { + // `new --directory work` reads the overrides from work/. A crest.php + // above it must not take the stub somewhere else. + file_put_contents($this->root . '/crest.php', "root . '/work'); + + $status = $this->runThroughKernel( + 'stub:publish', + PublishCommand::class, + ['project-front', '--directory', $this->root . '/work'] + ); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root . '/work', 'adr', 'project-front')); + $this->assertFileDoesNotExist(Stub::overridePath($this->root, 'adr', 'project-front')); + } + + public function testAProjectStubGoesIntoTheWorkingDirectoryAndNotAParentProject(): void + { + // `new` with no --directory reads the overrides from the working + // directory. endScratchProject() restores the working directory. + file_put_contents($this->root . '/crest.php', "root . '/work'); + chdir($this->root . '/work'); + + $status = $this->runThroughKernel('stub:publish', PublishCommand::class, ['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root . '/work', 'adr', 'project-front')); + $this->assertFileDoesNotExist(Stub::overridePath($this->root, 'adr', 'project-front')); + } + + public function testAProjectStubIgnoresTheConfiguredFlavor(): void + { + // `new` creates only ADR projects, so it reads only ADR overrides. + file_put_contents($this->root . '/crest.php', " 'mvc'];\n"); + + $status = $this->runCommand(['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'project-front')); + } + + public function testAProjectStubMayBePublishedByName(): void + { + $status = $this->runCommand(['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'project-front')); + } + + public function testAProjectStubNeedsNoProjectConfiguration(): void + { + // The directory that `new` runs in is not a project. + unlink($this->root . '/composer.json'); + + $status = $this->runCommand(['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'project-front')); + } + public function testAPublishedStubIsAByteForByteCopy(): void { $this->runCommand(['action']); @@ -169,6 +241,23 @@ public function testForceOverwritesAPublishedStub(): void ); } + public function testProjectStubsAreNotPublishedInBulk(): void + { + // They have an effect only in the directory that `crest new` puts the + // project into. In a project they do nothing. + $projectStubs = glob(Paths::stubs() . '/adr/' . Stub::PROJECT_PREFIX . '*.stub') ?: []; + + $this->assertNotEmpty($projectStubs); + + $this->runCommand([]); + + foreach ($projectStubs as $path) { + $this->assertFileDoesNotExist( + Stub::overridePath($this->root, 'adr', basename($path, '.stub')) + ); + } + } + public function testPublishedPathsAreReported(): void { $this->runCommand(['action']); @@ -195,7 +284,39 @@ public function testPublishingContinuesPastAStubTheProjectAlreadyHas(): void } } + public function testStubsArePublishedInByteOrderOnEveryMachine(): void + { + // glob() sorts with the collation of the locale. Under en_US.UTF-8, + // `action.stub` comes before `action-view.stub`; in byte order it is + // the other way round. The listing must be the same on every machine. + $previous = (string) setlocale(LC_COLLATE, '0'); + + if (false === setlocale(LC_COLLATE, 'en_US.UTF-8', 'en_US.utf8')) { + $this->markTestSkipped('the en_US.UTF-8 locale is not installed'); + } + + try { + $this->runCommand([]); + } finally { + setlocale(LC_COLLATE, $previous); + } + + // Whole file names: without `.stub`, `action` sorts before + // `action-view` in byte order too, and the test would prove nothing. + preg_match_all('#/([a-z-]+\.stub)$#m', $this->readStdout(), $matches); + + $published = $matches[1]; + $sorted = $published; + sort($sorted); + + $this->assertNotEmpty($published); + $this->assertSame($sorted, $published); + } + /** + * The stubs a publish with no name copies: every packaged ADR stub except + * the project stubs. + * * @return list */ private function packagedStubs(): array @@ -203,7 +324,13 @@ private function packagedStubs(): array $names = []; foreach (glob(Paths::stubs() . '/adr/*.stub') ?: [] as $path) { - $names[] = basename($path, '.stub'); + $name = basename($path, '.stub'); + + if (true === str_starts_with($name, Stub::PROJECT_PREFIX)) { + continue; + } + + $names[] = $name; } return $names; diff --git a/tests/Unit/Command/UpCommandTest.php b/tests/Unit/Command/UpCommandTest.php new file mode 100644 index 0000000..eabbd6f --- /dev/null +++ b/tests/Unit/Command/UpCommandTest.php @@ -0,0 +1,114 @@ + + * + * 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 Crest\Command\UpCommand; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\RunsACommandDirectly; +use Crest\Tests\Support\RunsThroughTheKernel; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function getenv; +use function putenv; + +use const PHP_EOL; + +final class UpCommandTest extends TestCase +{ + use RunsACommandDirectly; + use RunsThroughTheKernel; + use ScratchDirectory; + + private false | string $savedPath = false; + + protected function setUp(): void + { + $this->captureStreams(); + $this->makeScratchDirectory('up', 'bin'); + $this->savedPath = getenv('PATH'); + } + + protected function tearDown(): void + { + putenv(false === $this->savedPath ? 'PATH' : 'PATH=' . $this->savedPath); + + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + public function testAMissingDockerIsReportedAsACrestError(): void + { + // The real runner, through the real kernel, with no docker on the + // PATH. The user sees one crest line, not a PHP warning. + putenv('PATH=' . $this->root . '/bin'); + + $status = $this->runThroughKernel('up', UpCommand::class, []); + + $this->assertSame(1, $status); + $this->assertSame( + "crest: 'docker' was not found; install it or add it to the PATH" . PHP_EOL, + $this->readStderr() + ); + } + + public function testAnEmptyDirectoryOptionMeansTheWorkingDirectory(): void + { + // `--directory="$DIR"` with an unset variable. As for `new`, empty + // reads as absent, not as a directory named ''. + $runner = new FakeRunner(); + + $this->handleDirectly(new UpCommand($runner), ['--directory=']); + + $this->assertSame([[['docker', 'compose', 'up', '-d'], null]], $runner->calls); + } + + public function testBuildRebuildsTheImagesFirst(): void + { + $runner = new FakeRunner(); + + $this->handleDirectly(new UpCommand($runner), ['--build']); + + $this->assertSame([[['docker', 'compose', 'up', '-d', '--build'], null]], $runner->calls); + } + + public function testDefinitionNamesItselfUp(): void + { + $this->assertSame('up', (new UpCommand())->define()->getName()); + } + + public function testTheContainersStartDetached(): void + { + $runner = new FakeRunner(); + + $status = $this->handleDirectly(new UpCommand($runner), []); + + $this->assertSame(0, $status); + $this->assertSame([[['docker', 'compose', 'up', '-d'], null]], $runner->calls); + } + + public function testTheDirectoryOptionIsWhereComposeRuns(): void + { + $runner = new FakeRunner(); + + $this->handleDirectly(new UpCommand($runner), ['--directory', $this->root]); + + $this->assertSame([[['docker', 'compose', 'up', '-d'], $this->root]], $runner->calls); + } + + public function testTheExitStatusOfComposeIsReturned(): void + { + $this->assertSame(5, $this->handleDirectly(new UpCommand(new FakeRunner(5)), [])); + } +} diff --git a/tests/Unit/CommandsTest.php b/tests/Unit/CommandsTest.php index fd587a4..66ce51e 100644 --- a/tests/Unit/CommandsTest.php +++ b/tests/Unit/CommandsTest.php @@ -28,15 +28,19 @@ public function testAliasesAreNotListedAsCommands(): void 'about', 'config:show', 'container:list', + 'down', 'event:list', + 'install', 'list', 'make:action', 'make:command', 'make:middleware', 'make:provider', 'make:responder', + 'new', 'route:list', 'stub:publish', + 'up', ], array_keys(Commands::registry()->all()) ); diff --git a/tests/Unit/Generator/ClassNameTest.php b/tests/Unit/Generator/ClassNameTest.php index e14dfff..37b57fc 100644 --- a/tests/Unit/Generator/ClassNameTest.php +++ b/tests/Unit/Generator/ClassNameTest.php @@ -57,6 +57,34 @@ public function testANamespacedNameIsRejected(): void ClassName::suffixed('Admin/Album', 'Responder'); } + public function testANamespaceLosesItsSurroundingBackslashes(): void + { + $this->assertSame('Acme\\Shop', ClassName::namespace('\\Acme\\Shop\\')); + } + + public function testANamespaceMayHaveSeveralSegments(): void + { + $this->assertSame('Acme\\Shop', ClassName::namespace('Acme\\Shop')); + } + + public function testANamespaceWithAHyphenIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "'my-app' is not a usable namespace; expected something like 'App' or 'Acme\\Shop'" + ); + + ClassName::namespace('my-app'); + } + + public function testANamespaceWithAnEmptySegmentIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'Acme\\\\Shop' is not a usable namespace"); + + ClassName::namespace('Acme\\\\Shop'); + } + public function testANameWithASpaceIsRejected(): void { $this->expectException(Exception::class); @@ -73,6 +101,14 @@ public function testAnEmptyNameIsRejected(): void ClassName::suffixed('', 'Responder'); } + public function testAnEmptyNamespaceIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'' is not a usable namespace"); + + ClassName::namespace(''); + } + public function testANonLatinNameIsAccepted(): void { // PHP's own identifier rule allows the high-byte range, so a class named diff --git a/tests/Unit/Generator/ProjectStubsTest.php b/tests/Unit/Generator/ProjectStubsTest.php new file mode 100644 index 0000000..86ddc4d --- /dev/null +++ b/tests/Unit/Generator/ProjectStubsTest.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Generator; + +use Crest\Console\PackageVersion; +use Crest\Generator\Stub; +use Crest\Paths; +use ParseError; +use PHPUnit\Framework\TestCase; + +use function basename; +use function class_exists; +use function extension_loaded; +use function glob; +use function interface_exists; +use function json_decode; +use function preg_match_all; +use function sort; +use function sprintf; +use function str_contains; +use function token_get_all; + +use const JSON_THROW_ON_ERROR; +use const TOKEN_PARSE; + +/** + * The project-* stubs render a whole application for `crest new`, not one + * artifact class. StubContractsTest skips them, and this test holds their + * contract. + */ +final class ProjectStubsTest extends TestCase +{ + private const FLAVOR = 'adr'; + + /** + * Every placeholder that the project stubs use. NewCommand supplies + * exactly these keys. + */ + private const REPLACEMENTS = [ + 'actionNamespace' => 'App\\Action', + 'actionPath' => 'src/Action', + 'crestConstraint' => '^1.0', + 'jsonNamespace' => 'App', + 'namespace' => 'App', + 'phalconConstraint' => '^5', + 'phalconPackage' => 'ext-phalcon', + 'phalconVariant' => 'v5', + 'phpVersion' => '8.4', + 'project' => 'my-app', + 'seed' => 'Get', + 'service' => 'app', + 'v5' => '', + ]; + + /** + * @return iterable + */ + public static function phpStubs(): iterable + { + foreach (['project-config', 'project-front', 'project-htrouter', 'project-index'] as $name) { + yield $name => [$name]; + } + } + + /** + * @return iterable + */ + public static function projectStubs(): iterable + { + foreach (self::packagedNames() as $name) { + yield $name => [$name]; + } + } + + /** + * @return list + */ + private static function packagedNames(): array + { + $names = []; + $found = glob(Stub::packagedDirectory(Paths::stubs(), self::FLAVOR) . '/' . Stub::PROJECT_PREFIX . '*.stub'); + + foreach ($found ?: [] as $file) { + $names[] = basename($file, '.stub'); + } + + // glob() sorts with the collation of the locale, so its order changes + // from machine to machine. sort() gives byte order everywhere. + sort($names); + + return $names; + } + + /** + * @dataProvider projectStubs + */ + public function testNoPlaceholderIsLeftUnrendered(string $name): void + { + $this->assertFalse( + str_contains($this->render($name), '{{'), + sprintf("stub '%s' left a placeholder unrendered", $name) + ); + } + + /** + * @dataProvider phpStubs + */ + public function testPhpStubsRenderToParseablePhp(string $name): void + { + try { + // TOKEN_PARSE makes this a syntax check, not only a tokenizer run. + $this->assertNotEmpty(token_get_all($this->render($name), TOKEN_PARSE)); + } catch (ParseError $error) { + $this->fail( + sprintf("stub '%s' does not render to valid PHP: %s", $name, $error->getMessage()) + ); + } + } + + public function testTheComposerStubRendersToValidJson(): void + { + $this->assertSame( + [ + 'type' => 'project', + 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5'], + 'require-dev' => ['phalcon/crest' => '^1.0'], + 'autoload' => ['psr-4' => ['App\\' => 'src/']], + 'config' => ['sort-packages' => true], + ], + json_decode($this->render('project-composer'), true, 512, JSON_THROW_ON_ERROR) + ); + } + + public function testTheFrontControllerImportsResolve(): void + { + if ( + false === PackageVersion::isInstalled('phalcon/phalcon') + && false === extension_loaded('phalcon') + ) { + $this->markTestSkipped('resolving the front controller imports needs Phalcon present'); + } + + preg_match_all('/^use\s+([\w\\\\]+)/m', $this->render('project-front'), $matches); + + $this->assertNotEmpty($matches[1]); + + foreach ($matches[1] as $import) { + $this->assertTrue( + class_exists($import) || interface_exists($import), + sprintf('project-front imports %s, which does not exist', $import) + ); + } + } + + public function testTheProjectStubsArePackaged(): void + { + $this->assertSame( + [ + 'project-compose', + 'project-composer', + 'project-config', + 'project-dockerfile', + 'project-env', + 'project-front', + 'project-gitignore', + 'project-htrouter', + 'project-index', + 'project-readme', + ], + self::packagedNames() + ); + } + + private function render(string $name): string + { + return (new Stub(Paths::stubs()))->render(self::FLAVOR, $name, self::REPLACEMENTS); + } +} diff --git a/tests/Unit/Generator/StubContractsTest.php b/tests/Unit/Generator/StubContractsTest.php index f4dbab0..ed407f2 100644 --- a/tests/Unit/Generator/StubContractsTest.php +++ b/tests/Unit/Generator/StubContractsTest.php @@ -40,6 +40,7 @@ use function preg_match_all; use function sprintf; use function str_contains; +use function str_starts_with; use function token_get_all; use function trait_exists; @@ -115,6 +116,12 @@ public static function packagedStubs(): iterable foreach (glob($directory . '/*.stub') ?: [] as $file) { $name = basename($file, '.stub'); + // The project stubs render a whole application, not one artifact + // class. ProjectStubsTest holds their contract. + if (true === str_starts_with($name, Stub::PROJECT_PREFIX)) { + continue; + } + yield $name => [$name]; } } diff --git a/tests/Unit/Generator/StubTest.php b/tests/Unit/Generator/StubTest.php index 3254ed1..bc8a7ce 100644 --- a/tests/Unit/Generator/StubTest.php +++ b/tests/Unit/Generator/StubTest.php @@ -35,6 +35,20 @@ protected function tearDown(): void $this->removeScratchDirectory(); } + public function testAPlaceholderWithNoValueThrows(): void + { + // A published copy can keep a placeholder that crest no longer sends. + // Without this, the raw placeholder goes into the generated file. + file_put_contents($this->root . '/packaged/adr/action.stub', '{{ a }}|{{ b }}'); + + $stub = new Stub($this->root . '/packaged'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage($this->root . '/packaged/adr/action.stub has no value for {{ b }}'); + + $stub->render('adr', 'action', ['a' => 'X']); + } + public function testPackagedRootIsAlsoStrippedOfATrailingSlash(): void { file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged'); @@ -134,13 +148,4 @@ public function testUnknownStubThrows(): void $stub->resolve('adr', 'nope'); } - - public function testUnreplacedPlaceholdersAreLeftAlone(): void - { - file_put_contents($this->root . '/packaged/adr/action.stub', '{{ a }}|{{ b }}'); - - $stub = new Stub($this->root . '/packaged'); - - $this->assertSame('X|{{ b }}', $stub->render('adr', 'action', ['a' => 'X'])); - } } diff --git a/tests/Unit/Process/ShellRunnerTest.php b/tests/Unit/Process/ShellRunnerTest.php new file mode 100644 index 0000000..cf0b026 --- /dev/null +++ b/tests/Unit/Process/ShellRunnerTest.php @@ -0,0 +1,127 @@ + + * + * 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\Process; + +use Crest\Console\Exceptions\Exception; +use Crest\Process\ShellRunner; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function chmod; +use function file_put_contents; +use function getenv; +use function putenv; + +use const PHP_BINARY; + +final class ShellRunnerTest extends TestCase +{ + use ScratchDirectory; + + private false | string $savedPath = false; + + protected function setUp(): void + { + $this->makeScratchDirectory('shell-runner', 'bin', 'work'); + $this->savedPath = getenv('PATH'); + } + + protected function tearDown(): void + { + putenv(false === $this->savedPath ? 'PATH' : 'PATH=' . $this->savedPath); + + $this->removeScratchDirectory(); + } + + public function testABareNameIsFoundOnThePath(): void + { + $this->executable('bin/hello', "#!/bin/sh\nexit 4\n"); + putenv('PATH=' . $this->root . '/bin'); + + $this->assertSame(4, (new ShellRunner())->run(['hello'])); + } + + public function testAMissingDirectoryIsReportedNotIgnored(): void + { + // proc_open() ignores a working directory that does not exist, and + // the child runs in crest's own directory. For `down`, that is the + // wrong project. + $this->expectException(Exception::class); + $this->expectExceptionMessage($this->root . '/missing is not a directory'); + + (new ShellRunner())->run([PHP_BINARY, '-r', 'exit(0);'], $this->root . '/missing'); + } + + public function testAMissingProgramIsReportedByName(): void + { + putenv('PATH=' . $this->root . '/bin'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'docker' was not found; install it or add it to the PATH"); + + (new ShellRunner())->run(['docker', 'compose', 'up', '-d']); + } + + public function testANonExecutableFileIsNotRun(): void + { + file_put_contents($this->root . '/bin/plain', "#!/bin/sh\nexit 0\n"); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'" . $this->root . "/bin/plain' was not found"); + + (new ShellRunner())->run([$this->root . '/bin/plain']); + } + + public function testANonExecutableFileOnThePathIsNotFound(): void + { + file_put_contents($this->root . '/bin/plain', "#!/bin/sh\nexit 0\n"); + putenv('PATH=' . $this->root . '/bin'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'plain' was not found"); + + (new ShellRunner())->run(['plain']); + } + + public function testAnUnsetPathFindsNothing(): void + { + putenv('PATH'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'sh' was not found"); + + (new ShellRunner())->run(['sh']); + } + + public function testTheExitStatusIsReturned(): void + { + $this->assertSame(3, (new ShellRunner())->run([PHP_BINARY, '-r', 'exit(3);'])); + } + + public function testTheProgramRunsInTheGivenDirectory(): void + { + (new ShellRunner())->run( + [PHP_BINARY, '-r', 'file_put_contents("marker", "here");'], + $this->root . '/work' + ); + + $this->assertFileExists($this->root . '/work/marker'); + } + + private function executable(string $path, string $contents): void + { + file_put_contents($this->root . '/' . $path, $contents); + chmod($this->root . '/' . $path, 0o755); + } +}