From 409c6f6cbc89c9da8e10835bde6cb30272ebcfff Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Sun, 1 Nov 2026 08:53:36 -0600 Subject: [PATCH 1/5] updating composer --- composer.json | 4 +- composer.lock | 936 +------------------------------------------------- 2 files changed, 3 insertions(+), 937 deletions(-) diff --git a/composer.json b/composer.json index 144ff1d..fd0432c 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,6 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", - "infection/infection": "^0.29.9", "pds/composer-script-names": "^1", "pds/skeleton": "^1", "phalcon/code-quality": "^1.0", @@ -28,7 +27,8 @@ "psr-4": { "Crest\\": "src/" } }, "autoload-dev": { - "psr-4": { "Crest\\Tests\\": "tests/" } + "psr-4": { "Crest\\Tests\\": "tests/" }, + "exclude-from-classmap": ["tests/_output/"] }, "bin": ["bin/crest"], "config": { diff --git a/composer.lock b/composer.lock index 789292d..78e46d4 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": "b70c4821db31bc9272b981c07d95b772", + "content-hash": "a8b498e8b12ba42d4b48adfe7b899b46", "packages": [], "packages-dev": [ { @@ -72,95 +72,6 @@ "time": "2022-12-23T10:58:28+00:00", "published-time": "2022-12-23T10:59:43+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", - "published-time": "2024-02-09T13:06:43+00:00" - }, { "name": "composer/pcre", "version": "3.4.0", @@ -766,436 +677,6 @@ "time": "2026-09-22T09:54:55+00:00", "published-time": "2026-09-22T09:55:06+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", - "published-time": "2021-08-17T18:50:30+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", - "published-time": "2022-01-17T16:32:15+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", - "published-time": "2021-08-09T10:05:07+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", - "published-time": "2024-12-08T23:12:15+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", - "published-time": "2025-04-29T08:21:00+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", - "published-time": "2026-05-04T18:58:37+00:00" - }, { "name": "masterminds/html5", "version": "2.12.0", @@ -1509,85 +990,6 @@ "time": "2026-09-13T18:51:52+00:00", "published-time": "2026-09-13T18:53:16+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", - "published-time": "2024-03-12T13:23:38+00:00" - }, { "name": "pds/composer-script-names", "version": "1.0.0", @@ -3416,137 +2818,6 @@ "time": "2024-06-11T12:45:25+00:00", "published-time": "2024-06-11T12:48:34+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", - "published-time": "2025-04-10T13:32:41+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", - "published-time": "2024-11-28T02:39:34+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -6633,152 +5904,6 @@ "time": "2026-10-23T15:23:41+00:00", "published-time": "2026-10-28T18:17:03+00:00" }, - { - "name": "thecodingmachine/safe", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "b713b2297432086d16bf4174ca4e2baa1559dfc0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/b713b2297432086d16bf4174ca4e2baa1559dfc0", - "reference": "b713b2297432086d16bf4174ca4e2baa1559dfc0", - "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-10-24T22:44:05+00:00", - "published-time": "2026-10-24T22:44:10+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -6829,65 +5954,6 @@ ], "time": "2025-11-17T20:03:58+00:00", "published-time": "2025-11-17T20:04:52+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", - "published-time": "2025-10-29T15:56:42+00:00" } ], "aliases": [], From 1e0d50e35c6aed9c39072ada3702936b73b4f997 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Tue, 3 Nov 2026 01:34:23 -0600 Subject: [PATCH 2/5] adding make:module Assisted-by: Claude Code --- src/Command/Make/ControllerCommand.php | 44 ++++- src/Command/Make/ModelCommand.php | 114 ++++++++++--- src/Command/Make/ModuleCommand.php | 92 ++++++++++ src/Command/Make/NamedArtifactCommand.php | 7 +- src/Command/ProjectCommand.php | 6 +- src/Commands.php | 2 + src/Generator/ArtifactWriter.php | 25 ++- src/Generator/ModuleLocation.php | 198 ++++++++++++++++++++++ src/Generator/Placement.php | 8 + src/Project/Config.php | 2 + 10 files changed, 453 insertions(+), 45 deletions(-) create mode 100644 src/Command/Make/ModuleCommand.php create mode 100644 src/Generator/ModuleLocation.php diff --git a/src/Command/Make/ControllerCommand.php b/src/Command/Make/ControllerCommand.php index f565023..8f3ff3c 100644 --- a/src/Command/Make/ControllerCommand.php +++ b/src/Command/Make/ControllerCommand.php @@ -16,11 +16,16 @@ use Crest\Console\Exceptions\Exception; use Crest\Console\Input; use Crest\Console\Output; +use Crest\Console\Parsing\Definition; +use Crest\Generator\ClassName; use Crest\Generator\ControllerBase; +use Crest\Generator\ModuleLocation; use Crest\Generator\Outcome; +use Crest\Generator\Placement; use Crest\Project\Flavor; use function array_map; +use function dirname; use function implode; use function preg_replace; use function preg_split; @@ -44,18 +49,39 @@ * The name must give a class that the dispatcher can find from a URL * (route()), because no URL reaches a controller that it cannot find. * + * With --module (#55), the controller and its base go into the Controllers + * folder of the module, in the namespace of the module (ModuleLocation). + * The module must exist: make:module writes it. Its Module.php sets the + * namespace of the dispatcher, so the guidance is the stub + * fragment-guidance-controller-module. + * * handle() builds the values itself, and does not use replacements(): the * checks of the name in route() need the name as the user typed it. */ final class ControllerCommand extends NamedArtifactCommand { + public function define(): Definition + { + return parent::define()->option('module=s', 'Write the controller into a module, e.g. admin'); + } + public function handle(Input $input, Output $output): int { - $config = $this->config($input); - $name = $input->argumentString('name'); - $placement = $this->placement($config, $name, $this->key(), $this->suffix()); + $config = $this->config($input); + $name = $input->argumentString('name'); + $module = $input->optionStringOrNull('module'); + $location = null === $module ? null : ModuleLocation::existing($config, $module); + $placement = null === $location + ? $this->placement($config, $name, $this->key(), $this->suffix()) + : Placement::in( + $location->folder(ModuleLocation::CONTROLLERS), + $location->namespaceOf(ModuleLocation::CONTROLLERS), + ClassName::suffixed($name, $this->suffix()) + ); + // The base goes into the folder of the controller, in the module or + // in the `controller` path. $baseNamespace = ControllerBase::namespace($placement->namespace); - $base = ControllerBase::file($config->path($this->key())); + $base = ControllerBase::file(dirname($placement->file)); $values = [ 'base' => ControllerBase::NAME, 'baseNamespace' => $baseNamespace, @@ -86,7 +112,15 @@ public function handle(Input $input, Output $output): int $output->success(sprintf('Created %s', $placement->file)); - $this->guidance($config, $output, $values); + if (null === $location) { + $this->guidance($config, $output, $values); + + return 0; + } + + // Module.php sets the namespace, so the guidance of a module has no + // line for the dispatcher. + $this->guidance($config, $output, $values + ['module' => $location->key], 'module'); return 0; } diff --git a/src/Command/Make/ModelCommand.php b/src/Command/Make/ModelCommand.php index 3fe7768..603b956 100644 --- a/src/Command/Make/ModelCommand.php +++ b/src/Command/Make/ModelCommand.php @@ -26,6 +26,7 @@ use Crest\Generator\ModelProperties; use Crest\Generator\ModelRelations; use Crest\Generator\ModelTables; +use Crest\Generator\ModuleLocation; use Crest\Generator\Outcome; use Crest\Generator\PropertyNames; use Crest\Generator\RelationAliases; @@ -63,15 +64,15 @@ * * crest reads the table through the `db` service of the project * (Database::adapter()), so crest and the model read the same database. The - * base that crest owns (Base in the Base subfolder of the `model` path) - * has a typed nullable property for each column, the name of the table, and - * the relations from the foreign keys (ModelRelations, #51). Each relation - * has a RelationAlias attribute on the base, and the developer can change its - * alias there (RelationAliases). crest writes the base again when the table - * or its keys change (ArtifactWriter::renderOwned()). The model extends the - * base, and crest writes it one time (ArtifactWriter::renderOnce()). So - * another run brings the model up to date with its table, and the code of the - * developer does not change. + * base that crest owns (Base in the Base subfolder of the folder of the + * models) has a typed nullable property for each column, the name of the + * table, and the relations from the foreign keys (ModelRelations, #51). Each + * relation has a RelationAlias attribute on the base, and the developer can + * change its alias there (RelationAliases). crest writes the base again when + * the table or its keys change (ArtifactWriter::renderOwned()). The model + * extends the base, and crest writes it one time (ArtifactWriter::renderOnce()). + * So another run brings the model up to date with its table, and the code of + * the developer does not change. * * The names (#53) come from ModelNames: the class of a table from * models.classes in crest.php, and the property of a column from --property, @@ -88,6 +89,11 @@ * A crest error in the read phase fails only its table: crest prints the * reason, goes to the next table, and lists the failed tables in the summary. * + * With --module (#55), the models and their bases go into the Models folder + * of the module, in its namespace (ModuleLocation), and the relations name + * models in the same module. The lines that tell the developer to run + * make:model again name the option too. + * * Not NamedArtifactCommand: the argument is a table, there is no suffix, and * there is no guidance. * @@ -97,6 +103,7 @@ * baseFile: string, * baseNamespace: string, * class: string, + * command: string, * dropped: list, * file: string, * map: array, @@ -144,7 +151,8 @@ final class ModelCommand extends ProjectCommand ]; /** - * The base of a class, in the Base subfolder of the `model` path. + * The base of a class, in the Base subfolder of the folder of the models: + * the `model` path, or the Models folder of the module. */ private static function baseFile(string $path, string $class): string { @@ -196,6 +204,32 @@ private static function classOrNull(ModelNames $names, string $table): ?string } } + /** + * The command that the lines of make:model tell the developer to run + * again. With --module, it names the module, so that the related models + * go into the same module (#55). + */ + private static function command(?ModuleLocation $module): string + { + return null === $module ? 'make:model' : 'make:model --module=' . $module->key; + } + + /** + * The folder and the namespace of the models: the `model` path, or the + * Models folder of the module (#55). A `model` path that no psr-4 entry + * covers stops the command. + * + * @return array{string, string} + */ + private static function folder(Config $config, ?ModuleLocation $module): array + { + if (null === $module) { + return [$config->path(self::KEY), $config->namespaceFor(self::KEY)]; + } + + return [$module->folder(ModuleLocation::MODELS), $module->namespaceOf(ModuleLocation::MODELS)]; + } + /** * The names of --property: column => name (spec 3.1). Each item is split * at its last `=`, and the spaces at the start and the end of each part @@ -265,6 +299,7 @@ private static function mapped(array $names): bool * The line also names the related tables that give a class and that this * run does not write: their bases have relations with the old class. * + * @param string $command The command to run again (command()) * @param list $written The tables that this run writes */ private static function oldFiles( @@ -273,6 +308,7 @@ private static function oldFiles( string $table, string $class, string $path, + string $command, array $written ): ?string { try { @@ -306,7 +342,7 @@ private static function oldFiles( } } - $then = [] === $run ? '' : ', then run make:model for ' . Sentence::join($run); + $then = [] === $run ? '' : ', then run ' . $command . ' for ' . Sentence::join($run); $model = $path . '/' . $default . '.php'; return true === is_file($model) @@ -334,6 +370,7 @@ public function define(): Definition ->option('all', 'Create a model for each table of the database') ->option('exclude=s', 'With --all: the tables to skip, separated by commas') ->option('force', 'Overwrite an existing model') + ->option('module=s', 'Write the models into a module, e.g. admin') ->option('property=s', 'For one table: property names, as column=name, separated by commas'); } @@ -371,15 +408,19 @@ public function handle(Input $input, Output $output): int $given = null === $property ? [] : self::given($property); $config = $this->config($input); + // The module reads only files, so it comes before crest boots the + // project (#55). + $value = $input->optionStringOrNull('module'); + $module = null === $value ? null : ModuleLocation::existing($config, $value); // The models key of crest.php, before crest boots the project (#53). $names = ModelNames::of($config->models()); $force = true === $input->option('force'); if (true === $all) { - return $this->all($config, $names, $excluded, $force, $output); + return $this->all($config, $module, $names, $excluded, $force, $output); } - $this->one($config, $names->withGiven($table, $given), $table, $force, $output); + $this->one($config, $module, $names->withGiven($table, $given), $table, $force, $output); return 0; } @@ -401,12 +442,17 @@ protected function flavors(): array * * @param list $exclude The names of --exclude, without spaces */ - private function all(Config $config, ModelNames $names, array $exclude, bool $force, Output $output): int - { - $path = $config->path(self::KEY); - $namespace = $config->namespaceFor(self::KEY); - $adapter = Database::adapter($config); - $tables = ModelTables::of($adapter, $exclude, $names); + private function all( + Config $config, + ?ModuleLocation $module, + ModelNames $names, + array $exclude, + bool $force, + Output $output + ): int { + [$path, $namespace] = self::folder($config, $module); + $adapter = Database::adapter($config); + $tables = ModelTables::of($adapter, $exclude, $names); foreach ($tables->notes as $note) { $output->line($note); @@ -458,6 +504,7 @@ private function all(Config $config, ModelNames $names, array $exclude, bool $fo $tables->classes[$table], $path, $namespace, + self::command($module), $written ); } catch (Exception $exception) { @@ -558,15 +605,21 @@ private function names(array $columns): string * make:model : the output of #50 to #52, with the names of #53. * Each error stops the command. */ - private function one(Config $config, ModelNames $names, string $table, bool $force, Output $output): void - { + private function one( + Config $config, + ?ModuleLocation $module, + ModelNames $names, + string $table, + bool $force, + Output $output + ): void { // These checks do not boot the project, so they come first: a table // that gives no class, or a `model` path that no psr-4 entry covers, - // stops the command before crest boots the project. - $class = $names->classOf($table); - $path = $config->path(self::KEY); - $namespace = $config->namespaceFor(self::KEY); - $adapter = Database::adapter($config); + // stops the command before crest boots the project. With --module, + // ModuleLocation::existing() did the psr-4 check of the module. + $class = $names->classOf($table); + [$path, $namespace] = self::folder($config, $module); + $adapter = Database::adapter($config); // The columns and the names before the keys: a table that does not // exist, a base that crest cannot read, or a column with no usable @@ -595,6 +648,7 @@ private function one(Config $config, ModelNames $names, string $table, bool $for $class, $path, $namespace, + self::command($module), [$table] ), $force, @@ -635,6 +689,7 @@ private function properties(Config $config, array $properties, array $names): st * * @param array $columns The column name and its PHP type * @param array $final The column name and its property + * @param string $command The command to run again (command()) * @param list $written The tables that this run writes * * @return Parts @@ -650,6 +705,7 @@ private function read( string $class, string $path, string $namespace, + string $command, array $written ): array { $baseFile = self::baseFile($path, $class); @@ -680,12 +736,13 @@ private function read( 'baseFile' => $baseFile, 'baseNamespace' => $namespace . '\\' . self::BASE_FOLDER, 'class' => $class, + 'command' => $command, 'dropped' => $names->dropped($table, $list), 'file' => $path . '/' . $class . '.php', 'map' => true === $mapped ? $final : [], 'names' => $final, 'namespace' => $namespace, - 'note' => self::oldFiles($names, $keys, $table, $class, $path, $written), + 'note' => self::oldFiles($names, $keys, $table, $class, $path, $command, $written), 'properties' => $columns, 'relations' => $relations, 'renamed' => $renamed, @@ -832,7 +889,8 @@ private function write(Config $config, array $parts, bool $force, Output $output if ([] !== $parts['run']) { $output->line(sprintf( - 'Run make:model for %s too: %s relations use the property names of %s', + 'Run %s for %s too: %s relations use the property names of %s', + $parts['command'], Sentence::join($parts['run']), 1 === count($parts['run']) ? 'its' : 'their', $parts['table'] diff --git a/src/Command/Make/ModuleCommand.php b/src/Command/Make/ModuleCommand.php new file mode 100644 index 0000000..2a1f731 --- /dev/null +++ b/src/Command/Make/ModuleCommand.php @@ -0,0 +1,92 @@ + + * + * 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\Console\Input; +use Crest\Console\Output; +use Crest\Generator\ArtifactWriter; +use Crest\Generator\ModuleLocation; +use Crest\Project\Flavor; + +/** + * Generates an MVC module (#55): Module.php, which implements + * Phalcon\Mvc\ModuleDefinitionInterface, and the folders Controllers, Models + * and views. ModuleLocation holds the structure, and finds the folder of an + * existing module from its key. + * + * The developer owns Module.php and adds the services of the module to it. + * An existing Module.php stops the command, as #55 says, and --force + * replaces only that file: the folders and their files stay. + * + * crest does not change the front controller or the router. It prints the + * registration and the routes instead (the stub fragment-guidance-module). + */ +final class ModuleCommand extends NamedArtifactCommand +{ + public function handle(Input $input, Output $output): int + { + $config = $this->config($input); + $location = ModuleLocation::of($config, $input->argumentString('name')); + + $this->writer($config)->render( + $location->file(), + $this->key(), + ['class' => ModuleLocation::NAME, 'module' => $location->key, 'namespace' => $location->namespace], + true === $input->option('force') + ); + + $output->success('Created ' . $location->file()); + + foreach ([ModuleLocation::CONTROLLERS, ModuleLocation::MODELS, ModuleLocation::VIEWS] as $part) { + $folder = $location->folder($part); + + if (true === ArtifactWriter::directory($folder)) { + $output->success('Created ' . $folder); + } + } + + $this->guidance($config, $output, ['module' => $location->key, 'namespace' => $location->namespace]); + + return 0; + } + + protected function description(): string + { + return 'Create an MVC module'; + } + + protected function example(): string + { + return 'admin'; + } + + protected function flavors(): array + { + return [Flavor::MVC]; + } + + protected function key(): string + { + return 'module'; + } + + /** + * Not used: handle() places the module itself, and the class is always + * Module. + */ + protected function suffix(): string + { + return ''; + } +} diff --git a/src/Command/Make/NamedArtifactCommand.php b/src/Command/Make/NamedArtifactCommand.php index c7af864..08c59fb 100644 --- a/src/Command/Make/NamedArtifactCommand.php +++ b/src/Command/Make/NamedArtifactCommand.php @@ -85,13 +85,16 @@ abstract protected function example(): string; * A generator with nothing to say has no fragment. A project can add one, * or change the packaged one. * + * A variant gives the stub `fragment-guidance--`: for + * example, make:controller --module prints `-module` (#55). + * * @param array $values */ - protected function guidance(Config $config, Output $output, array $values): void + protected function guidance(Config $config, Output $output, array $values, string $variant = ''): void { $flavor = $config->flavor()->value; $stub = $this->stub($config); - $guidance = Stub::FRAGMENT_PREFIX . 'guidance-' . $this->key(); + $guidance = Stub::FRAGMENT_PREFIX . 'guidance-' . $this->key() . ('' === $variant ? '' : '-' . $variant); if (true === $stub->has($flavor, $guidance)) { $output->write($stub->render($flavor, $guidance, $values)); diff --git a/src/Command/ProjectCommand.php b/src/Command/ProjectCommand.php index 1aa1697..f7d5365 100644 --- a/src/Command/ProjectCommand.php +++ b/src/Command/ProjectCommand.php @@ -110,11 +110,7 @@ protected function placement(Config $config, string $name, string $key, string $ { $class = ClassName::suffixed($name, $suffix); - return new Placement( - $class, - $config->path($key) . '/' . $class . '.php', - $config->namespaceFor($key) - ); + return Placement::in($config->path($key), $config->namespaceFor($key), $class); } /** diff --git a/src/Commands.php b/src/Commands.php index 2e8d639..7b721c6 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -26,6 +26,7 @@ use Crest\Command\Make\ControllerCommand; use Crest\Command\Make\MiddlewareCommand; use Crest\Command\Make\ModelCommand; +use Crest\Command\Make\ModuleCommand; use Crest\Command\Make\ProviderCommand; use Crest\Command\Make\ResponderCommand; use Crest\Command\NewCommand; @@ -111,6 +112,7 @@ public static function registry(): Registry ->add('make:controller', ControllerCommand::class) ->add('make:middleware', MiddlewareCommand::class) ->add('make:model', ModelCommand::class) + ->add('make:module', ModuleCommand::class) ->add('make:provider', ProviderCommand::class) ->add('make:responder', ResponderCommand::class) ->add('new', NewCommand::class) diff --git a/src/Generator/ArtifactWriter.php b/src/Generator/ArtifactWriter.php index dde2b98..ac8e197 100644 --- a/src/Generator/ArtifactWriter.php +++ b/src/Generator/ArtifactWriter.php @@ -56,6 +56,25 @@ public function __construct( ) { } + /** + * Makes a folder and its parents when the folder does not exist. Gives + * true when it made the folder, so that make:module prints a Created line + * only for a new folder (#55). The warning is suppressed for the reason + * given at write(). + */ + public static function directory(string $directory): bool + { + if (true === is_dir($directory)) { + return false; + } + + if (false === @mkdir($directory, 0o775, true)) { + throw new Exception(sprintf('could not create %s', $directory)); + } + + return true; + } + /** * Writes contents to a file, creating the directory if it is missing. * @@ -74,11 +93,7 @@ public function __construct( */ public static function write(string $file, string $contents): void { - $directory = dirname($file); - - if (false === is_dir($directory) && false === @mkdir($directory, 0o775, true)) { - throw new Exception(sprintf('could not create %s', $directory)); - } + self::directory(dirname($file)); if (false === @file_put_contents($file, $contents)) { throw new Exception(sprintf('could not write %s', $file)); diff --git a/src/Generator/ModuleLocation.php b/src/Generator/ModuleLocation.php new file mode 100644 index 0000000..b99ed08 --- /dev/null +++ b/src/Generator/ModuleLocation.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Generator; + +use Crest\Console\Exceptions\Exception; +use Crest\Project\Config; + +use function count; +use function is_dir; +use function is_file; +use function preg_match; +use function scandir; +use function sort; +use function sprintf; +use function strtolower; + +use const SORT_STRING; + +/** + * Where an MVC module is (#55). All the modules are in the `module` path, and + * each module has the same structure: + * + * /Admin/Module.php App\Modules\Admin\Module + * /Admin/Controllers/ App\Modules\Admin\Controllers + * /Admin/Models/ App\Modules\Admin\Models + * /Admin/views/ no classes + * + * The folders with classes are in StudlyCase, because psr-4 needs the folder + * to match the namespace. The folder of a new module is the name in + * StudlyCase (ClassName::studly()), and the key of the module is the folder + * in lowercase. The key goes into registerModules(), the routes and each + * printed --module. + * + * crest finds a module by its key, and uses the spelling of the folder on the + * disk. So --module=adminpanel finds AdminPanel. On a file system that + * ignores case, a check of the path alone would find the folder Admin for the + * name ADMIN, and crest would write App\Modules\ADMIN classes into it. + */ +final class ModuleLocation +{ + /** + * The folder of the controllers of a module. + */ + public const CONTROLLERS = 'Controllers'; + + /** + * The folder of the models of a module. + */ + public const MODELS = 'Models'; + + /** + * The class of a module, and the name of its file. + */ + public const NAME = 'Module'; + + /** + * The folder of the views of a module. It holds no classes. + */ + public const VIEWS = 'views'; + + private function __construct( + public readonly string $key, + public readonly string $namespace, + public readonly string $path, + ) { + } + + /** + * A module that make:controller and make:model write into. Its Module.php + * must exist: make:module writes it. + * + * The hint gives the name as the user typed it, not the key: make:module + * with that name writes the folder that the error names. The key of + * admin_panel is adminpanel, which gives the folder Adminpanel, not + * AdminPanel. + */ + public static function existing(Config $config, string $name): self + { + $location = self::of($config, $name); + + if (false === is_file($location->file())) { + throw new Exception( + sprintf( + "module '%s' was not found: no %s.php in %s; run crest make:module %s first", + $location->key, + self::NAME, + $location->path, + $name + ) + ); + } + + return $location; + } + + /** + * The module of a name: the folder with its key, or the folder of a new + * module. The checks come in this order: the name, the folders, and the + * psr-4 entry of the `module` path. + */ + public static function of(Config $config, string $name): self + { + // studly() only removes `_` and makes the first letter of each part + // uppercase, so a name that is not usable gives a folder that is not + // usable. + $folder = ClassName::studly($name); + + if (0 === preg_match(ClassName::PATTERN, $folder)) { + throw new Exception( + sprintf("'%s' is not a usable module name; expected a single name like 'admin'", $name) + ); + } + + $key = strtolower($folder); + $modules = $config->path('module'); + $found = self::folders($modules, $key); + + if (1 < count($found)) { + throw new Exception( + sprintf( + "two folders give the module '%s': %s/%s and %s/%s", + $key, + $modules, + $found[0], + $modules, + $found[1] + ) + ); + } + + $folder = $found[0] ?? $folder; + + return new self($key, $config->namespaceFor('module') . '\\' . $folder, $modules . '/' . $folder); + } + + /** + * The folders in the `module` path whose name in lowercase is the key, + * in byte order. A file is not a module. scandir() sorts with the + * collation of the locale, so its order alone can change from one + * environment to the next. A folder that crest cannot read gives no + * folders and no PHP warning, as Survey does. + * + * @return list + */ + private static function folders(string $modules, string $key): array + { + if (false === is_dir($modules)) { + return []; + } + + $found = []; + + foreach (@scandir($modules) ?: [] as $entry) { + if ($key === strtolower($entry) && true === is_dir($modules . '/' . $entry)) { + $found[] = $entry; + } + } + + sort($found, SORT_STRING); + + return $found; + } + + /** + * The file of the Module class. + */ + public function file(): string + { + return $this->path . '/' . self::NAME . '.php'; + } + + /** + * A folder of the module: CONTROLLERS, MODELS or VIEWS. + */ + public function folder(string $part): string + { + return $this->path . '/' . $part; + } + + /** + * The namespace of a folder of the module: CONTROLLERS or MODELS. + */ + public function namespaceOf(string $part): string + { + return $this->namespace . '\\' . $part; + } +} diff --git a/src/Generator/Placement.php b/src/Generator/Placement.php index 297b1e0..d2e1817 100644 --- a/src/Generator/Placement.php +++ b/src/Generator/Placement.php @@ -32,4 +32,12 @@ public function __construct( public readonly string $namespace, ) { } + + /** + * A class in a folder: the file is /.php. + */ + public static function in(string $folder, string $namespace, string $class): self + { + return new self($class, $folder . '/' . $class . '.php', $namespace); + } } diff --git a/src/Project/Config.php b/src/Project/Config.php index bdf412e..fc5cb3b 100644 --- a/src/Project/Config.php +++ b/src/Project/Config.php @@ -117,6 +117,8 @@ public static function defaultPaths(Flavor $flavor): array Flavor::MVC => [ 'controller' => Layout::SOURCE . '/Controllers', 'model' => Layout::SOURCE . '/Models', + // One folder for all the modules (#55). + 'module' => Layout::SOURCE . '/Modules', 'provider' => Layout::SOURCE . '/Providers', // The layout of the Phalcon sample applications (vokuro, // invo), with one fixed theme. From 4d7bca94dcd731f513b54cc4cc0ba32091d6756d Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Tue, 3 Nov 2026 01:34:37 -0600 Subject: [PATCH 3/5] additional tests Assisted-by: Claude Code --- .../Command/Make/ModelCommandModuleTest.php | 160 ++++++++ tests/Unit/Command/Config/ShowCommandTest.php | 2 + .../Unit/Command/GeneratedMvcProjectTest.php | 84 ++++- tests/Unit/Command/InitCommandTest.php | 9 +- tests/Unit/Command/ListCommandTest.php | 2 +- .../Command/Make/ControllerCommandTest.php | 106 ++++++ .../Command/Make/GuidanceContractsTest.php | 26 ++ tests/Unit/Command/Make/ModelCommandTest.php | 30 ++ tests/Unit/Command/Make/ModuleCommandTest.php | 357 ++++++++++++++++++ tests/Unit/Command/NewCommandTest.php | 1 + tests/Unit/CommandsTest.php | 1 + tests/Unit/Generator/ArtifactWriterTest.php | 20 + tests/Unit/Generator/ModuleLocationTest.php | 260 +++++++++++++ tests/Unit/Generator/PlacementTest.php | 33 ++ tests/Unit/Generator/StubContractsTest.php | 65 ++++ tests/Unit/Project/ConfigTest.php | 3 + tests/Unit/Project/SurveyTest.php | 2 + 17 files changed, 1154 insertions(+), 7 deletions(-) create mode 100644 tests/Database/Command/Make/ModelCommandModuleTest.php create mode 100644 tests/Unit/Command/Make/ModuleCommandTest.php create mode 100644 tests/Unit/Generator/ModuleLocationTest.php create mode 100644 tests/Unit/Generator/PlacementTest.php diff --git a/tests/Database/Command/Make/ModelCommandModuleTest.php b/tests/Database/Command/Make/ModelCommandModuleTest.php new file mode 100644 index 0000000..6dcb3f0 --- /dev/null +++ b/tests/Database/Command/Make/ModelCommandModuleTest.php @@ -0,0 +1,160 @@ + + * + * 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\Database\Command\Make; + +use Crest\Tests\Database\AbstractDatabaseTestCase; +use Crest\Tests\Support\Migrations\CustomersMigration; +use Crest\Tests\Support\Migrations\InvoicesMigration; +use Crest\Tests\Support\Migrations\MessagesMigration; +use Crest\Tests\Support\Migrations\UsersMigration; +use Crest\Tests\Support\Project\DatabaseFront; +use Crest\Tests\Support\RunsMakeModel; +use Phalcon\Di\Di; + +use function file_get_contents; +use function file_put_contents; +use function mkdir; + +use const PHP_EOL; + +/** + * make:model --module (#55): the models go into the Models folder of the + * module, in its namespace, and the relations name models in the same + * module. The tests run on each driver: DatabaseFront gives the database of + * the suite. A test starts with no tables. + * + * @group mysql + * @group pgsql + * @group sqlite + */ +final class ModelCommandModuleTest extends AbstractDatabaseTestCase +{ + use RunsMakeModel; + + protected function setUp(): void + { + parent::setUp(); + $this->startScratchProject('make-model-module'); + $this->front(DatabaseFront::class); + + // --module needs only the Module.php of the module. + mkdir($this->root . '/src/Modules/Admin', 0o775, true); + file_put_contents($this->root . '/src/Modules/Admin/Module.php', 'endScratchProject(); + parent::tearDown(); + } + + public function testAllWritesEachTableIntoTheModule(): void + { + $this->createTables(UsersMigration::class, MessagesMigration::class); + + $status = $this->runCommand(['--all', '--module=admin']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->model('Users.php')); + $this->assertFileExists($this->model('Base/UsersBase.php')); + $this->assertFileExists($this->model('Messages.php')); + $this->assertFileExists($this->model('Base/MessagesBase.php')); + $this->assertStringEndsWith('2 tables: 2 models created, 0 kept, 0 failed.' . PHP_EOL, $this->readStdout()); + $this->assertDirectoryDoesNotExist($this->root . '/src/Models'); + } + + public function testARelationNamesTheClassInTheModule(): void + { + $this->createTables(UsersMigration::class, MessagesMigration::class); + + $status = $this->runCommand(['messages', '--module=admin']); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + '\\App\\Modules\\Admin\\Models\\Users::class', + (string) file_get_contents($this->model('Base/MessagesBase.php')) + ); + } + + public function testOneTableGoesIntoTheModule(): void + { + $this->createTables(UsersMigration::class); + + $status = $this->runCommand(['users', '--module=admin']); + + $this->assertSame(0, $status); + + $model = (string) file_get_contents($this->model('Users.php')); + + $this->assertStringContainsString("namespace App\\Modules\\Admin\\Models;\n", $model); + $this->assertStringContainsString("use App\\Modules\\Admin\\Models\\Base\\UsersBase;\n", $model); + $this->assertStringContainsString( + "namespace App\\Modules\\Admin\\Models\\Base;\n", + (string) file_get_contents($this->model('Base/UsersBase.php')) + ); + $this->assertSame( + 'Created ' . $this->model('Base/UsersBase.php') . PHP_EOL + . 'No relations for users: the table has no foreign keys, and no foreign keys point to it. ' + . 'Relations come only from foreign keys.' . PHP_EOL + . 'Created ' . $this->model('Users.php') . PHP_EOL, + $this->readStdout() + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Models'); + } + + public function testTheNoteNamesTheModule(): void + { + // The Note line of #71 names the option too: the related table must + // be written into the same module. + $this->createTables(UsersMigration::class, MessagesMigration::class); + $this->runCommand(['--all', '--module=admin']); + $this->front(DatabaseFront::class, "['classes' => ['users' => 'User']]"); + $this->freshStreams(); + + $status = $this->runCommand(['users', '--module=admin']); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + 'Note: the old files of users stay: ' . $this->model('Users.php') . ' and ' + . $this->model('Base/UsersBase.php') . '; move your code to User, delete them, ' + . 'then run make:model --module=admin for messages' . PHP_EOL, + $this->readStdout() + ); + } + + public function testTheRunLineNamesTheModule(): void + { + // A developer who runs the line as it is must write into the module. + $this->createTables(CustomersMigration::class, InvoicesMigration::class); + $this->runCommand(['invoices', '--module=admin']); + $this->front(DatabaseFront::class, "['camelCase' => true]"); + $this->freshStreams(); + + $status = $this->runCommand(['invoices', '--module=admin']); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + 'Run make:model --module=admin for customers too: its relations use the property names of invoices' + . PHP_EOL, + $this->readStdout() + ); + } + + private function model(string $file): string + { + return $this->root . '/src/Modules/Admin/Models/' . $file; + } +} diff --git a/tests/Unit/Command/Config/ShowCommandTest.php b/tests/Unit/Command/Config/ShowCommandTest.php index a653515..05a4c5d 100644 --- a/tests/Unit/Command/Config/ShowCommandTest.php +++ b/tests/Unit/Command/Config/ShowCommandTest.php @@ -301,6 +301,7 @@ public function testTheModelsKeyIsShownForAnMvcProject(): void . 'command ' . $this->root . '/src/Command default' . PHP_EOL . 'controller ' . $this->root . '/src/Controllers default' . PHP_EOL . 'model ' . $this->root . '/src/Models default' . PHP_EOL + . 'module ' . $this->root . '/src/Modules default' . PHP_EOL . 'provider ' . $this->root . '/src/Providers default' . PHP_EOL . 'views ' . $this->root . '/themes/default default' . PHP_EOL . PHP_EOL @@ -346,6 +347,7 @@ public function testTheWholeReportIsRenderedForADeclaredProject(): void . 'command ' . $this->root . '/src/Command default' . PHP_EOL . 'controller ' . $this->root . '/src/Controllers default' . PHP_EOL . 'model ' . $this->root . '/src/Models default' . PHP_EOL + . 'module ' . $this->root . '/src/Modules default' . PHP_EOL . 'provider ' . $this->root . '/src/Providers default' . PHP_EOL . 'views ' . $this->root . '/templates declared' . PHP_EOL; diff --git a/tests/Unit/Command/GeneratedMvcProjectTest.php b/tests/Unit/Command/GeneratedMvcProjectTest.php index 2fed49c..7f71b59 100644 --- a/tests/Unit/Command/GeneratedMvcProjectTest.php +++ b/tests/Unit/Command/GeneratedMvcProjectTest.php @@ -15,6 +15,7 @@ use Closure; use Crest\Command\Make\ControllerCommand; +use Crest\Command\Make\ModuleCommand; use Crest\Command\NewCommand; use Crest\Console\Input; use Crest\Console\Kernel; @@ -29,16 +30,19 @@ use Phalcon\Di\DiInterface; use Phalcon\Http\ResponseInterface; use Phalcon\Mvc\Application; +use Phalcon\Mvc\Router; use PHPUnit\Framework\TestCase; use ReflectionFunction; use function class_exists; use function extension_loaded; use function file_get_contents; +use function file_put_contents; use function interface_exists; use function is_array; use function is_file; use function method_exists; +use function mkdir; use function preg_match_all; use function putenv; use function spl_autoload_register; @@ -147,6 +151,57 @@ public function testAControllerFromMakeControllerAnswersItsUrl(): void $this->assertStringNotContainsString('

shop

', $content); } + public function testAModuleFromMakeModuleAnswersItsUrl(): void + { + // The "Done when" of #55: make:module, make:controller --module, the + // registration and the routes of the guidance, and a request reaches + // the controller of the module. The module uses only its own views, + // so the page has no layout of the project (spec 3.4). + $output = new Output($this->stdout, $this->stderr, false); + $module = new ModuleCommand(); + + $this->assertSame( + 0, + $module->handle( + new Input( + 'make:module', + $module->define()->merge(Kernel::globals())->bind(['admin', '--directory', $this->project]) + ), + $output + ) + ); + + $controller = new ControllerCommand(); + + $this->assertSame( + 0, + $controller->handle( + new Input( + 'make:controller', + $controller->define() + ->merge(Kernel::globals()) + ->bind(['Index', '--module=admin', '--directory', $this->project]) + ), + $output + ) + ); + + mkdir($this->project . '/src/Modules/Admin/views/index', 0o775, true); + file_put_contents($this->project . '/src/Modules/Admin/views/index/index.phtml', '

admin

'); + + $content = $this->get('/admin', true); + + $this->assertStringContainsString('

admin

', $content); + $this->assertStringNotContainsString('shop', $content); + + // The route /admin/:controller: a controller with no action. + $this->assertStringContainsString('

admin

', $this->get('/admin/index', true)); + + // With the module registered, a URL outside the module still gets + // the page of the project. + $this->assertStringContainsString('

shop

', $this->get('/', true)); + } + public function testAProjectWithADatabaseAnswersGetSlashWithNoDatabase(): void { // The db service connects at the first use, and GET / does not use @@ -244,6 +299,8 @@ public function testTheGeneratedConfigurationIsReadByCrest(): void $this->assertSame(self::ROOT_NAMESPACE . '\\AppFront', $config->bootstrap()); $this->assertSame(self::ROOT_NAMESPACE . '\\Controllers', $config->namespaceFor('controller')); $this->assertSame($this->project . '/src/Controllers', $config->path('controller')); + $this->assertSame($this->project . '/src/Modules', $config->path('module')); + $this->assertSame(self::ROOT_NAMESPACE . '\\Modules', $config->namespaceFor('module')); $this->assertSame($this->project . '/src/Providers', $config->path('provider')); $this->assertSame($this->project . '/themes/default', $config->path('views')); } @@ -345,8 +402,11 @@ private function generate(string $name, string $namespace, array $options): stri * Sends a GET request through the generated application, as * public/index.php does, but in this process and with no send(). Gives * the content of the response. + * + * With $modules, the application registers the module admin and its + * routes, as the guidance of make:module says. */ - private function get(string $uri): string + private function get(string $uri, bool $modules = false): string { // 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 @@ -372,7 +432,27 @@ private function get(string $uri): string $_SERVER['REQUEST_METHOD'] = 'GET'; $_SERVER['REQUEST_URI'] = $uri; - $response = (new Application($container))->handle($uri); + $application = new Application($container); + + if (true === $modules) { + $router = $container->getShared('router'); + + if (false === $router instanceof Router) { + $this->fail('the router of the project is not a Phalcon\Mvc\Router'); + } + + $application->registerModules( + ['admin' => ['className' => self::ROOT_NAMESPACE . '\\Modules\\Admin\\Module']] + ); + $router->add('/admin', ['module' => 'admin', 'controller' => 'index', 'action' => 'index']); + $router->add('/admin/:controller', ['module' => 'admin', 'controller' => 1, 'action' => 'index']); + $router->add( + '/admin/:controller/:action/:params', + ['module' => 'admin', 'controller' => 1, 'action' => 2, 'params' => 3] + ); + } + + $response = $application->handle($uri); if (false === $response instanceof ResponseInterface) { $this->fail('the application did not return a response'); diff --git a/tests/Unit/Command/InitCommandTest.php b/tests/Unit/Command/InitCommandTest.php index 2dd7004..9514bb8 100644 --- a/tests/Unit/Command/InitCommandTest.php +++ b/tests/Unit/Command/InitCommandTest.php @@ -148,15 +148,16 @@ public function testAnExistingCrestPhpIsKept(): void public function testAnMvcAnswerAsksForTheMvcPaths(): void { - // The questions: namespace, flavor, front controller, the five MVC + // The questions: namespace, flavor, front controller, the six MVC // paths, runtime. - $this->answers("\nmvc\n\n\n\n\n\n\n\n"); + $this->answers("\nmvc\n\n\n\n\n\n\n\n\n"); $this->assertSame(0, $this->init([], true)); $this->assertStringContainsString( 'command folder (does not exist yet) [src/Command]: ' . 'controller folder (does not exist yet) [src/Controllers]: ' . 'model folder (does not exist yet) [src/Models]: ' + . 'module folder (does not exist yet) [src/Modules]: ' . 'provider folder (does not exist yet) [src/Providers]: ' . 'views folder (does not exist yet) [themes/default]: ', $this->readStderr() @@ -352,9 +353,9 @@ public function testTheFlavorProposalFollowsTheNamespace(): void { $this->writeComposerJson(['App\\' => 'src/', 'Admin\\' => 'admin/']); mkdir($this->root . '/admin/Controllers', 0o775, true); - // The questions: namespace, flavor, front controller, the five MVC + // The questions: namespace, flavor, front controller, the six MVC // paths, runtime. - $this->answers("Admin\n\n\n\n\n\n\n\n\n"); + $this->answers("Admin\n\n\n\n\n\n\n\n\n\n"); $this->assertSame(0, $this->init([], true)); $this->assertStringStartsWith('Root namespace [App]: Flavor (adr, mvc) [mvc]: ', $this->readStderr()); diff --git a/tests/Unit/Command/ListCommandTest.php b/tests/Unit/Command/ListCommandTest.php index 20d88c4..0848389 100644 --- a/tests/Unit/Command/ListCommandTest.php +++ b/tests/Unit/Command/ListCommandTest.php @@ -59,7 +59,7 @@ final class ListCommandTest extends TestCase /** * The commands that have no ADR or CLI version. */ - private const MVC_ONLY = ['make:controller', 'make:model']; + private const MVC_ONLY = ['make:controller', 'make:model', 'make:module']; /** * @var array{ diff --git a/tests/Unit/Command/Make/ControllerCommandTest.php b/tests/Unit/Command/Make/ControllerCommandTest.php index 322753b..cce1993 100644 --- a/tests/Unit/Command/Make/ControllerCommandTest.php +++ b/tests/Unit/Command/Make/ControllerCommandTest.php @@ -120,6 +120,40 @@ public function testABaseWithTheMarkerIsUpdated(): void ); } + public function testAControllerGoesIntoTheModule(): void + { + // Asserted whole: the module guidance replaces the line of the + // dispatcher, because Module.php sets the namespace. + $this->writeModule('Admin'); + + $status = $this->runCommand(['Users', '--module=admin']); + + $controllers = $this->root . '/src/Modules/Admin/Controllers'; + $expected = 'Created ' . $controllers . '/Base/CrestControllerBase.php' . PHP_EOL + . 'Created ' . $controllers . '/UsersController.php' . PHP_EOL + . 'The admin module finds it: its Module class sets the default namespace' . PHP_EOL + . 'of the dispatcher to App\\Modules\\Admin\\Controllers.' . PHP_EOL + . PHP_EOL + . 'With the routes from make:module, GET /admin/users/index runs' . PHP_EOL + . 'indexAction(). Its view is users/index in the views folder of the module.' . PHP_EOL; + + $this->assertSame(0, $status); + $this->assertSame($expected, $this->readStdout()); + + $controller = (string) file_get_contents($controllers . '/UsersController.php'); + + $this->assertStringContainsString("namespace App\\Modules\\Admin\\Controllers;\n", $controller); + $this->assertStringContainsString( + "use App\\Modules\\Admin\\Controllers\\Base\\CrestControllerBase;\n", + $controller + ); + $this->assertStringContainsString( + "namespace App\\Modules\\Admin\\Controllers\\Base;\n", + (string) file_get_contents($controllers . '/Base/CrestControllerBase.php') + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Controllers'); + } + public function testADeclaredControllerPathIsUsed(): void { // A project that keeps its classes in app/. @@ -204,6 +238,29 @@ public function testAnExistingControllerIsKept(): void ); } + public function testAnUnknownModuleStops(): void + { + $status = $this->runCommand(['Users', '--module=admin']); + + $this->assertSame(1, $status); + $this->assertSame( + "crest: module 'admin' was not found: no Module.php in " . $this->root + . '/src/Modules/Admin; run crest make:module admin first' . PHP_EOL, + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Modules'); + $this->assertDirectoryDoesNotExist($this->root . '/src/Controllers'); + } + + public function testAnUnusableModuleNameStops(): void + { + $status = $this->runCommand(['Users', '--module=admin-panel']); + + $this->assertSame(1, $status); + $this->assertStringContainsString("'admin-panel' is not a usable module name", $this->readStderr()); + $this->assertDirectoryDoesNotExist($this->root . '/src/Controllers'); + } + public function testAnUnusableNameIsReported(): void { $status = $this->runCommand(['Admin/Sample']); @@ -343,6 +400,31 @@ public function testTheGuidanceNamesTheRouteAndTheView(string $name, string $rou ); } + public function testTheModuleIsFoundByItsKey(): void + { + // The key adminpanel is not the folder AdminPanel. The namespace has + // the spelling of the folder. + $this->writeModule('AdminPanel'); + + foreach (['adminpanel', 'ADMINPANEL'] as $module) { + $status = $this->runCommand(['Users', '--module=' . $module, '--force']); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + "namespace App\\Modules\\AdminPanel\\Controllers;\n", + (string) file_get_contents($this->root . '/src/Modules/AdminPanel/Controllers/UsersController.php') + ); + } + } + + public function testTheModuleOptionHasItsHelp(): void + { + $option = (new ControllerCommand())->define()->findOption('module'); + + $this->assertNotNull($option); + $this->assertSame('Write the controller into a module, e.g. admin', $option->description); + } + public function testTheNameBaseDoesNotCollideWithTheBaseFolder(): void { $status = $this->runCommand(['Base']); @@ -352,6 +434,21 @@ public function testTheNameBaseDoesNotCollideWithTheBaseFolder(): void $this->assertFileExists($this->root . self::BASE); } + public function testTheNameRulesStillApplyInAModule(): void + { + $this->writeModule('Admin'); + + $status = $this->runCommand(['users', '--module=admin']); + + $this->assertSame(1, $status); + $this->assertSame( + "crest: 'users' gives usersController, which the dispatcher cannot find from a URL; " + . "use a name like 'Users'" . PHP_EOL, + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Modules/Admin/Controllers'); + } + public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void { $status = $this->runCommand(['UserController']); @@ -430,4 +527,13 @@ private function wholeController(): string . " }\n" . "}\n"; } + + /** + * A module that make:module wrote: --module needs only its Module.php. + */ + private function writeModule(string $folder): void + { + mkdir($this->root . '/src/Modules/' . $folder, 0o775, true); + file_put_contents($this->root . '/src/Modules/' . $folder . '/Module.php', 'assertSame(['array'], $this->parameterTypes(Router::class, 'setMiddlewareMap')); } + public function testModuleGuidanceNamesMethodsThatExist(): void + { + $this->writeCrestPhp("['flavor' => 'mvc']"); + + $this->runProjectCommand('make:module', ModuleCommand::class, ['admin']); + + $stdout = $this->readStdout(); + + $this->assertStringContainsString('$application->registerModules([', $stdout); + $this->assertStringContainsString("\$router->add('/admin/:controller/:action/:params', [", $stdout); + + $this->assertSame(['array', 'bool'], $this->parameterTypes(Application::class, 'registerModules')); + // Only the pattern: v5 and v6 give the other parameters other types. + $this->assertSame('string', $this->parameterTypes(MvcRouter::class, 'add')[0]); + + // The first block of the guidance changes the run() of the front + // controller of crest new. + $front = (string) file_get_contents(Paths::stubs() . '/mvc/project-front.stub'); + + $this->assertStringContainsString('new Application($this->boot())', $front); + $this->assertStringContainsString('->handle($uri)', $front); + } + public function testMvcProviderGuidanceNamesMethodsThatExist(): void { $this->writeCrestPhp("['flavor' => 'mvc']"); diff --git a/tests/Unit/Command/Make/ModelCommandTest.php b/tests/Unit/Command/Make/ModelCommandTest.php index 84f4336..5b08469 100644 --- a/tests/Unit/Command/Make/ModelCommandTest.php +++ b/tests/Unit/Command/Make/ModelCommandTest.php @@ -137,6 +137,28 @@ public function testAnEmptyExcludeStops(): void $this->assertStops(['--all', '--exclude='], '--exclude has an empty table name'); } + public function testAnUnknownModuleStopsBeforeTheBoot(): void + { + // FailingFront fails to boot, so the module check comes first. + $this->front(FailingFront::class); + + $this->assertStops( + ['users', '--module=admin'], + "module 'admin' was not found: no Module.php in " . $this->root + . '/src/Modules/Admin; run crest make:module admin first' + ); + } + + public function testAnUnusableModuleNameStopsBeforeTheBoot(): void + { + $this->front(FailingFront::class); + + $this->assertStops( + ['users', '--module=admin-panel'], + "'admin-panel' is not a usable module name; expected a single name like 'admin'" + ); + } + public function testAPropertyWithAnEmptyNameStops(): void { $this->assertStops( @@ -210,6 +232,14 @@ public function testPropertyWithAllStops(): void $this->assertStops(['--all', '--property=a=b'], '--property is only for one table, not --all'); } + public function testTheModuleOptionHasItsHelp(): void + { + $option = (new ModelCommand())->define()->findOption('module'); + + $this->assertNotNull($option); + $this->assertSame('Write the models into a module, e.g. admin', $option->description); + } + public function testThePropertyOptionHasItsHelp(): void { $option = (new ModelCommand())->define()->findOption('property'); diff --git a/tests/Unit/Command/Make/ModuleCommandTest.php b/tests/Unit/Command/Make/ModuleCommandTest.php new file mode 100644 index 0000000..69f1926 --- /dev/null +++ b/tests/Unit/Command/Make/ModuleCommandTest.php @@ -0,0 +1,357 @@ + + * + * 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\ModuleCommand; +use Crest\Tests\Support\GeneratesInAScratchProject; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +use function file_get_contents; +use function file_put_contents; +use function mkdir; + +use const PHP_EOL; + +/** + * make:module writes Module.php and the folders of the module, and prints how + * to register it (spec 3.3 to 3.5). + */ +final class ModuleCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + private const MODULE = '/src/Modules/Admin/Module.php'; + + protected function setUp(): void + { + $this->startScratchProject('make-module'); + $this->writeCrestPhp("['flavor' => 'mvc']"); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + /** + * @return iterable + */ + public static function unusableNames(): iterable + { + yield 'a space' => ['my module']; + yield 'a first digit' => ['9admin']; + yield 'a slash' => ['admin/users']; + yield 'a hyphen' => ['admin-panel']; + } + + public function testACliProjectGetsTheFlavorError(): void + { + $this->writeCrestPhp("['flavor' => 'cli']"); + + $status = $this->runCommand(['admin']); + + $this->assertSame(1, $status); + $this->assertSame( + "crest: make:module does not support the 'cli' flavor of this project; it supports: mvc" . PHP_EOL, + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Modules'); + } + + public function testAModulePathOutsidePsr4StopsTheCommand(): void + { + $this->writeCrestPhp("['flavor' => 'mvc', 'paths' => ['module' => 'lib/Modules']]"); + + $status = $this->runCommand(['admin']); + + $this->assertSame(1, $status); + $this->assertSame("crest: no psr-4 autoload entry covers 'lib/Modules'" . PHP_EOL, $this->readStderr()); + $this->assertDirectoryDoesNotExist($this->root . '/lib'); + } + + public function testAnAdrProjectGetsTheFlavorError(): void + { + $this->writeCrestPhp(); + + $status = $this->runCommand(['admin']); + + $this->assertSame(1, $status); + $this->assertSame( + "crest: make:module does not support the 'adr' flavor of this project; it supports: mvc" . PHP_EOL, + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Modules'); + } + + public function testAnExistingFolderIsUsed(): void + { + // The key adminpanel finds the folder AdminPanel. crest does not + // make a second folder Adminpanel. + mkdir($this->root . '/src/Modules/AdminPanel', 0o775, true); + + $status = $this->runCommand(['adminpanel']); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + "namespace App\\Modules\\AdminPanel;\n", + (string) file_get_contents($this->root . '/src/Modules/AdminPanel/Module.php') + ); + $this->assertStringStartsWith( + 'Created ' . $this->root . '/src/Modules/AdminPanel/Module.php' . PHP_EOL + . 'Created ' . $this->root . '/src/Modules/AdminPanel/Controllers' . PHP_EOL + . 'Created ' . $this->root . '/src/Modules/AdminPanel/Models' . PHP_EOL + . 'Created ' . $this->root . '/src/Modules/AdminPanel/views' . PHP_EOL, + $this->readStdout() + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Modules/Adminpanel'); + } + + public function testAnExistingModuleStops(): void + { + // ADMIN has the key of admin, so it finds the folder Admin. + $this->runCommand(['admin']); + $this->freshStreams(); + + $status = $this->runCommand(['ADMIN']); + + $this->assertSame(1, $status); + $this->assertSame( + 'crest: ' . $this->root . self::MODULE . ' already exists; pass --force to overwrite' . PHP_EOL, + $this->readStderr() + ); + } + + public function testAnUnderscoreNameGivesAStudlyFolderAndALowercaseKey(): void + { + $status = $this->runCommand(['admin_panel']); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + "namespace App\\Modules\\AdminPanel;\n", + (string) file_get_contents($this->root . '/src/Modules/AdminPanel/Module.php') + ); + $this->assertStringContainsString('--module=adminpanel', $this->readStdout()); + } + + /** + * @dataProvider unusableNames + */ + public function testAnUnusableNameStopsAndWritesNothing(string $name): void + { + $status = $this->runCommand([$name]); + + $this->assertSame(1, $status); + $this->assertSame( + "crest: '" . $name . "' is not a usable module name; expected a single name like 'admin'" . PHP_EOL, + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/src/Modules'); + } + + public function testAnUppercaseNameGivesTheSameModule(): void + { + $status = $this->runCommand(['Admin']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . self::MODULE); + $this->assertStringContainsString(" 'admin' => ['className' =>", $this->readStdout()); + } + + public function testDefinitionNamesItself(): void + { + $definition = (new ModuleCommand())->define(); + + $this->assertSame('make:module', $definition->getName()); + $this->assertSame('Create an MVC module', $definition->getDescription()); + } + + public function testForceReplacesOnlyTheModuleClass(): void + { + // The folders exist, so they get no Created line, and the files in + // them stay. + $this->runCommand(['admin']); + file_put_contents($this->root . self::MODULE, 'mine'); + file_put_contents($this->root . '/src/Modules/Admin/Controllers/Mine.php', 'mine'); + $this->freshStreams(); + + $status = $this->runCommand(['admin', '--force']); + + $this->assertSame(0, $status); + $this->assertSame($this->wholeModule(), (string) file_get_contents($this->root . self::MODULE)); + $this->assertSame( + 'mine', + (string) file_get_contents($this->root . '/src/Modules/Admin/Controllers/Mine.php') + ); + $this->assertStringStartsWith( + 'Created ' . $this->root . self::MODULE . PHP_EOL . 'Nothing registers it yet.', + $this->readStdout() + ); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testTheFirstRunWritesTheModuleTheFoldersAndTheGuidance(): void + { + // Asserted whole: the guidance is the wiring that the developer + // copies, and the blank lines around the snippets are part of it. + $status = $this->runCommand(['admin']); + + $expected = 'Created ' . $this->root . self::MODULE . PHP_EOL + . 'Created ' . $this->root . '/src/Modules/Admin/Controllers' . PHP_EOL + . 'Created ' . $this->root . '/src/Modules/Admin/Models' . PHP_EOL + . 'Created ' . $this->root . '/src/Modules/Admin/views' . PHP_EOL + . 'Nothing registers it yet. In run() of your front controller, register the' . PHP_EOL + . 'module before handle():' . PHP_EOL + . PHP_EOL + . ' $application = new Application($this->boot());' . PHP_EOL + . ' $application->registerModules([' . PHP_EOL + . " 'admin' => ['className' => \\App\\Modules\\Admin\\Module::class]," . PHP_EOL + . ' ]);' . PHP_EOL + . ' $response = $application->handle($uri);' . PHP_EOL + . PHP_EOL + . 'Then add the routes of the module to the router, for example:' . PHP_EOL + . PHP_EOL + . " \$router->add('/admin', [" . PHP_EOL + . " 'module' => 'admin'," . PHP_EOL + . " 'controller' => 'index'," . PHP_EOL + . " 'action' => 'index'," . PHP_EOL + . ' ]);' . PHP_EOL + . " \$router->add('/admin/:controller', [" . PHP_EOL + . " 'module' => 'admin'," . PHP_EOL + . " 'controller' => 1," . PHP_EOL + . " 'action' => 'index'," . PHP_EOL + . ' ]);' . PHP_EOL + . " \$router->add('/admin/:controller/:action/:params', [" . PHP_EOL + . " 'module' => 'admin'," . PHP_EOL + . " 'controller' => 1," . PHP_EOL + . " 'action' => 2," . PHP_EOL + . " 'params' => 3," . PHP_EOL + . ' ]);' . PHP_EOL + . PHP_EOL + . 'Write its first controller with: crest make:controller Index --module=admin' . PHP_EOL; + + $this->assertSame(0, $status); + $this->assertSame($expected, $this->readStdout()); + $this->assertDirectoryExists($this->root . '/src/Modules/Admin/Controllers'); + $this->assertDirectoryExists($this->root . '/src/Modules/Admin/Models'); + $this->assertDirectoryExists($this->root . '/src/Modules/Admin/views'); + } + + public function testTheModuleClassHasNoSuffix(): void + { + // NamedArtifactCommand asks each command for the suffix of its class. + // The class of a module is always Module, so make:module gives none. + $suffix = new ReflectionMethod(ModuleCommand::class, 'suffix'); + + $this->assertSame('', $suffix->invoke(new ModuleCommand())); + } + + public function testTheNamespacesKeyGivesTheNamespace(): void + { + $this->writeCrestPhp("['flavor' => 'mvc', 'namespaces' => ['module' => 'Acme\\\\Mods']]"); + + $this->runCommand(['admin']); + + $module = (string) file_get_contents($this->root . self::MODULE); + + $this->assertStringContainsString("namespace Acme\\Mods\\Admin;\n", $module); + $this->assertStringContainsString("setDefaultNamespace('Acme\\Mods\\Admin\\Controllers');", $module); + } + + public function testTheWholeModuleIsRendered(): void + { + // Asserted whole: this is generated code that nobody reviews. + $this->runCommand(['admin']); + + $this->assertSame($this->wholeModule(), (string) file_get_contents($this->root . self::MODULE)); + } + + public function testTwoFoldersWithOneKeyStop(): void + { + mkdir($this->root . '/src/Modules/Admin', 0o775, true); + mkdir($this->root . '/src/Modules/ADMIN', 0o775, true); + + $status = $this->runCommand(['admin']); + + $this->assertSame(1, $status); + $this->assertStringContainsString("two folders give the module 'admin'", $this->readStderr()); + $this->assertFileDoesNotExist($this->root . self::MODULE); + $this->assertFileDoesNotExist($this->root . '/src/Modules/ADMIN/Module.php'); + } + + /** + * Empty streams for the output of the next run. + */ + private function freshStreams(): void + { + $this->closeStreams(); + $this->captureStreams(); + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('make:module', ModuleCommand::class, $arguments); + } + + private function wholeModule(): string + { + return "getShared('dispatcher');\n" + . " \$dispatcher->setDefaultNamespace('App\\Modules\\Admin\\Controllers');\n" + . "\n" + . " /** @var View \$view */\n" + . " \$view = \$container->getShared('view');\n" + . " \$view->setViewsDir(__DIR__ . '/views/');\n" + . " }\n" + . "}\n"; + } +} diff --git a/tests/Unit/Command/NewCommandTest.php b/tests/Unit/Command/NewCommandTest.php index de89aeb..f701e26 100644 --- a/tests/Unit/Command/NewCommandTest.php +++ b/tests/Unit/Command/NewCommandTest.php @@ -1113,6 +1113,7 @@ public function testTheMvcConfigStatesEveryKey(): void . " 'command' => 'src/Command',\n" . " 'controller' => 'src/Controllers',\n" . " 'model' => 'src/Models',\n" + . " 'module' => 'src/Modules',\n" . " 'provider' => 'src/Providers',\n" . " 'views' => 'themes/default',\n" . " ],\n" diff --git a/tests/Unit/CommandsTest.php b/tests/Unit/CommandsTest.php index 58ec817..a5b9338 100644 --- a/tests/Unit/CommandsTest.php +++ b/tests/Unit/CommandsTest.php @@ -40,6 +40,7 @@ public function testAliasesAreNotListedAsCommands(): void 'make:controller', 'make:middleware', 'make:model', + 'make:module', 'make:provider', 'make:responder', 'new', diff --git a/tests/Unit/Generator/ArtifactWriterTest.php b/tests/Unit/Generator/ArtifactWriterTest.php index 9a6879d..0ca45c6 100644 --- a/tests/Unit/Generator/ArtifactWriterTest.php +++ b/tests/Unit/Generator/ArtifactWriterTest.php @@ -70,6 +70,26 @@ public function testAnUncreatableDirectoryIsReported(): void $this->writer()->render($file, 'thing', ['class' => 'Thing'], false); } + public function testDirectoryMakesAMissingFolderAndSaysSo(): void + { + // make:module prints a Created line only for a folder that it makes. + $folder = $this->root . '/a/b'; + + $this->assertTrue(ArtifactWriter::directory($folder)); + $this->assertDirectoryExists($folder); + $this->assertFalse(ArtifactWriter::directory($folder)); + } + + public function testDirectoryReportsAFolderThatItCannotMake(): void + { + file_put_contents($this->root . '/a', 'a file, not a folder'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('could not create ' . $this->root . '/a/b'); + + ArtifactWriter::directory($this->root . '/a/b'); + } + public function testRenderCreatesMissingDirectoriesAllTheWayDown(): void { $file = $this->root . '/a/b/c/Thing.php'; diff --git a/tests/Unit/Generator/ModuleLocationTest.php b/tests/Unit/Generator/ModuleLocationTest.php new file mode 100644 index 0000000..204b255 --- /dev/null +++ b/tests/Unit/Generator/ModuleLocationTest.php @@ -0,0 +1,260 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Generator; + +use Crest\Console\Exceptions\Exception; +use Crest\Generator\ModuleLocation; +use Crest\Project\Config; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function chmod; +use function file_put_contents; +use function is_readable; +use function mkdir; +use function setlocale; + +use const LC_COLLATE; + +/** + * The structure of a module (spec 3.1), and how crest finds the folder of a + * module from its key (spec 3.2). + */ +final class ModuleLocationTest extends TestCase +{ + use ScratchDirectory; + + protected function setUp(): void + { + $this->makeScratchDirectory('module-location'); + $this->writeComposerJson(['App\\' => 'src/']); + $this->writeCrestPhp("['flavor' => 'mvc']"); + } + + protected function tearDown(): void + { + $this->removeScratchDirectory(); + } + + /** + * @return iterable + */ + public static function unusableNames(): iterable + { + yield 'a space' => ['my module']; + yield 'a first digit' => ['9admin']; + yield 'a slash' => ['admin/users']; + yield 'a hyphen' => ['admin-panel']; + yield 'empty' => ['']; + yield 'only an underscore' => ['_']; + } + + public function testAFileWithTheKeyIsNotAModule(): void + { + // Only a folder is a module. A file with the key in its name does + // not count, so the module gets the path of a new folder, Admin, and + // not the name of the file, ADMIN. + mkdir($this->root . '/src/Modules', 0o775, true); + file_put_contents($this->root . '/src/Modules/ADMIN', 'not a folder'); + + $this->assertSame($this->root . '/src/Modules/Admin', ModuleLocation::of($this->config(), 'admin')->path); + } + + public function testAModulePathOutsidePsr4Stops(): void + { + $this->writeCrestPhp("['flavor' => 'mvc', 'paths' => ['module' => 'lib/Modules']]"); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("no psr-4 autoload entry covers 'lib/Modules'"); + + ModuleLocation::of($this->config(), 'admin'); + } + + public function testANewNameGivesTheStudlyFolder(): void + { + $config = $this->config(); + $panel = ModuleLocation::of($config, 'admin_panel'); + + $this->assertSame('adminpanel', $panel->key); + $this->assertSame($this->root . '/src/Modules/AdminPanel', $panel->path); + $this->assertSame('App\\Modules\\AdminPanel', $panel->namespace); + + foreach (['admin', 'Admin'] as $name) { + $admin = ModuleLocation::of($config, $name); + + $this->assertSame('admin', $admin->key); + $this->assertSame($this->root . '/src/Modules/Admin', $admin->path); + $this->assertSame('App\\Modules\\Admin', $admin->namespace); + } + } + + public function testAnExistingFolderIsFoundByItsKey(): void + { + // The key is not the folder: studly('adminpanel') is Adminpanel. The + // lookup reads the names of the folders, so it finds AdminPanel. + mkdir($this->root . '/src/Modules/AdminPanel', 0o775, true); + + $config = $this->config(); + + foreach (['adminpanel', 'ADMINPANEL', 'AdminPanel'] as $name) { + $location = ModuleLocation::of($config, $name); + + $this->assertSame('adminpanel', $location->key); + $this->assertSame($this->root . '/src/Modules/AdminPanel', $location->path); + $this->assertSame('App\\Modules\\AdminPanel', $location->namespace); + } + } + + public function testAnUnreadableModulePathGivesTheNewFolder(): void + { + // No PHP warning: PHPUnit fails a test that causes one. + mkdir($this->root . '/src/Modules', 0o775, true); + chmod($this->root . '/src/Modules', 0o000); + + try { + // root can read a folder with mode 000, so the test proves nothing + // then. + if (true === is_readable($this->root . '/src/Modules')) { + $this->markTestSkipped('this user can read a folder with mode 000'); + } + + $path = ModuleLocation::of($this->config(), 'admin')->path; + } finally { + // The scratch folder can be removed only with its mode back. + chmod($this->root . '/src/Modules', 0o775); + } + + $this->assertSame($this->root . '/src/Modules/Admin', $path); + } + + /** + * @dataProvider unusableNames + */ + public function testAnUnusableNameStops(string $name): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "'" . $name . "' is not a usable module name; expected a single name like 'admin'" + ); + + ModuleLocation::of($this->config(), $name); + } + + public function testExistingNamesTheModuleAsItWasTyped(): void + { + // The hint gives the folder that the error names: make:module + // admin_panel writes AdminPanel. The key adminpanel would write + // Adminpanel. + try { + ModuleLocation::existing($this->config(), 'admin_panel'); + $this->fail('a module with no Module.php was found'); + } catch (Exception $exception) { + $this->assertSame( + "module 'adminpanel' was not found: no Module.php in " . $this->root + . '/src/Modules/AdminPanel; run crest make:module admin_panel first', + $exception->getMessage() + ); + } + } + + public function testExistingNeedsTheModuleClass(): void + { + $config = $this->config(); + + try { + ModuleLocation::existing($config, 'admin'); + $this->fail('a module with no Module.php was found'); + } catch (Exception $exception) { + $this->assertSame( + "module 'admin' was not found: no Module.php in " . $this->root + . "/src/Modules/Admin; run crest make:module admin first", + $exception->getMessage() + ); + } + + mkdir($this->root . '/src/Modules/Admin', 0o775, true); + file_put_contents($this->root . '/src/Modules/Admin/Module.php', 'assertSame($this->root . '/src/Modules/Admin', ModuleLocation::existing($config, 'Admin')->path); + } + + public function testTheNamespacesKeyWins(): void + { + $this->writeCrestPhp("['flavor' => 'mvc', 'namespaces' => ['module' => 'Acme\\\\Mods']]"); + + $this->assertSame('Acme\\Mods\\Admin', ModuleLocation::of($this->config(), 'admin')->namespace); + } + + public function testThePartsOfAModule(): void + { + $location = ModuleLocation::of($this->config(), 'admin'); + + $this->assertSame($this->root . '/src/Modules/Admin/Module.php', $location->file()); + $this->assertSame( + $this->root . '/src/Modules/Admin/Controllers', + $location->folder(ModuleLocation::CONTROLLERS) + ); + $this->assertSame($this->root . '/src/Modules/Admin/Models', $location->folder(ModuleLocation::MODELS)); + $this->assertSame($this->root . '/src/Modules/Admin/views', $location->folder(ModuleLocation::VIEWS)); + $this->assertSame('App\\Modules\\Admin\\Controllers', $location->namespaceOf(ModuleLocation::CONTROLLERS)); + $this->assertSame('App\\Modules\\Admin\\Models', $location->namespaceOf(ModuleLocation::MODELS)); + $this->assertSame('Module', ModuleLocation::NAME); + } + + public function testTwoFoldersWithOneKeyStop(): void + { + mkdir($this->root . '/src/Modules/Admin', 0o775, true); + mkdir($this->root . '/src/Modules/ADMIN', 0o775, true); + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "two folders give the module 'admin': " . $this->root . '/src/Modules/ADMIN and ' + . $this->root . '/src/Modules/Admin' + ); + + ModuleLocation::of($this->config(), 'admin'); + } + + public function testTwoFoldersWithOneKeyStopInByteOrderInEachLocale(): void + { + // scandir() sorts with the collation of the locale: en_US puts Admin + // before ADMIN. The text must be the same in each locale. + $previous = setlocale(LC_COLLATE, '0'); + + if (false === setlocale(LC_COLLATE, 'en_US.utf8', 'en_US.UTF-8')) { + $this->markTestSkipped('the en_US locale is not installed'); + } + + mkdir($this->root . '/src/Modules/Admin', 0o775, true); + mkdir($this->root . '/src/Modules/ADMIN', 0o775, true); + + try { + ModuleLocation::of($this->config(), 'admin'); + $this->fail('two folders with one key were accepted'); + } catch (Exception $exception) { + $this->assertSame( + "two folders give the module 'admin': " . $this->root . '/src/Modules/ADMIN and ' + . $this->root . '/src/Modules/Admin', + $exception->getMessage() + ); + } finally { + setlocale(LC_COLLATE, false === $previous ? 'C' : $previous); + } + } + + private function config(): Config + { + return Config::discover($this->root); + } +} diff --git a/tests/Unit/Generator/PlacementTest.php b/tests/Unit/Generator/PlacementTest.php new file mode 100644 index 0000000..2d86e7e --- /dev/null +++ b/tests/Unit/Generator/PlacementTest.php @@ -0,0 +1,33 @@ + + * + * 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\Generator\Placement; +use PHPUnit\Framework\TestCase; + +final class PlacementTest extends TestCase +{ + public function testInPutsTheClassFileInTheFolder(): void + { + $placement = Placement::in( + '/srv/app/src/Modules/Admin/Controllers', + 'App\\Modules\\Admin\\Controllers', + 'UsersController' + ); + + $this->assertSame('UsersController', $placement->class); + $this->assertSame('/srv/app/src/Modules/Admin/Controllers/UsersController.php', $placement->file); + $this->assertSame('App\\Modules\\Admin\\Controllers', $placement->namespace); + } +} diff --git a/tests/Unit/Generator/StubContractsTest.php b/tests/Unit/Generator/StubContractsTest.php index 12c08da..1fc928e 100644 --- a/tests/Unit/Generator/StubContractsTest.php +++ b/tests/Unit/Generator/StubContractsTest.php @@ -30,12 +30,16 @@ use Phalcon\Contracts\Container\Service\Collection; use Phalcon\Contracts\Container\Service\Provider; use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\Di\Di; use Phalcon\Di\DiInterface; use Phalcon\Di\ServiceProviderInterface; use Phalcon\Http\RequestInterface; use Phalcon\Http\ResponseInterface; use Phalcon\Mvc\Controller; +use Phalcon\Mvc\Dispatcher; use Phalcon\Mvc\Model; +use Phalcon\Mvc\ModuleDefinitionInterface; +use Phalcon\Mvc\View; use PHPUnit\Framework\TestCase; use ReflectionMethod; use ReflectionNamedType; @@ -92,6 +96,7 @@ final class StubContractsTest extends TestCase 'entries' => '', 'extraKey' => 'crest', 'fields' => "'id'", + 'module' => 'generated', 'name' => 'id', 'namespace' => 'App\\Generated', 'pairs' => '', @@ -145,6 +150,14 @@ public static function implementedContracts(): iterable yield 'middleware' => [Middleware::class, '__invoke', [AttributeRequest::class, Handler::class]]; + yield 'mvc module autoloaders' => [ + ModuleDefinitionInterface::class, + 'registerAutoloaders', + [DiInterface::class], + ]; + + yield 'mvc module services' => [ModuleDefinitionInterface::class, 'registerServices', [DiInterface::class]]; + yield 'mvc provider' => [ServiceProviderInterface::class, 'register', [DiInterface::class]]; yield 'provider' => [Provider::class, 'provide', [Collection::class]]; @@ -472,6 +485,58 @@ public function testTheMvcModelExtendsTheFrameworkModel(): void $this->assertSame('id', $property->name); } + /** + * The module stub declares a class that implements the interface of the + * framework. The declaration fails when a signature does not match the + * interface of v5 or v6. registerServices() must change the dispatcher + * and the view that the providers of the project set (spec 3.4). + */ + public function testTheMvcModuleSetsTheDispatcherAndTheViews(): void + { + $namespace = 'Crest\\Tests\\Generated\\MvcModule'; + + $this->makeScratchDirectory('stub-contracts'); + + try { + file_put_contents( + $this->root . '/Module.php', + (new Stub(Paths::stubs()))->render( + 'mvc', + 'module', + ['class' => 'Module', 'module' => 'admin', 'namespace' => $namespace] + ) + ); + + require $this->root . '/Module.php'; + + $class = $namespace . '\\Module'; + $dispatcher = new Dispatcher(); + $view = new View(); + $container = new Di(); + $container->setShared('dispatcher', $dispatcher); + $container->setShared('view', $view); + + // Through the reflection of a method, as the model test does: the + // analyzer looks for a literal class name, and this class exists + // only at run time. + $module = (new ReflectionMethod($class, 'registerServices'))->getDeclaringClass()->newInstance(); + + if (false === $module instanceof ModuleDefinitionInterface) { + $this->fail('the module does not implement ModuleDefinitionInterface'); + } + + $module->registerAutoloaders(null); + $module->registerServices($container); + + $this->assertSame($namespace . '\\Controllers', $dispatcher->getDefaultNamespace()); + $this->assertSame($this->root . '/views/', $view->getViewsDir()); + } finally { + // The first DI of the process is the default DI. + Di::reset(); + $this->removeScratchDirectory(); + } + } + private function render(string $flavor, string $name): string { return (new Stub(Paths::stubs()))->render($flavor, $name, self::REPLACEMENTS); diff --git a/tests/Unit/Project/ConfigTest.php b/tests/Unit/Project/ConfigTest.php index e1f6df0..c179893 100644 --- a/tests/Unit/Project/ConfigTest.php +++ b/tests/Unit/Project/ConfigTest.php @@ -327,6 +327,7 @@ public function testMvcGetsTheControllerPath(): void 'command' => $this->root . '/src/Command', 'controller' => $this->root . '/src/Controllers', 'model' => $this->root . '/src/Models', + 'module' => $this->root . '/src/Modules', 'provider' => $this->root . '/src/Providers', 'views' => $this->root . '/themes/default', ], @@ -334,6 +335,7 @@ public function testMvcGetsTheControllerPath(): void ); $this->assertSame('App\\Controllers', $config->namespaceFor('controller')); $this->assertSame('App\\Models', $config->namespaceFor('model')); + $this->assertSame('App\\Modules', $config->namespaceFor('module')); $this->assertSame('App\\Providers', $config->namespaceFor('provider')); } @@ -511,6 +513,7 @@ public function testTheDefaultPathsOfEachFlavor(): void 'command' => 'src/Command', 'controller' => 'src/Controllers', 'model' => 'src/Models', + 'module' => 'src/Modules', 'provider' => 'src/Providers', 'views' => 'themes/default', ], diff --git a/tests/Unit/Project/SurveyTest.php b/tests/Unit/Project/SurveyTest.php index 382f497..b85e78e 100644 --- a/tests/Unit/Project/SurveyTest.php +++ b/tests/Unit/Project/SurveyTest.php @@ -366,6 +366,7 @@ public function testTheMvcPathsGoIntoTheFolderAndTheViewsStayAtTheRoot(): void 'command' => 'lib/Shop/Command', 'controller' => 'lib/Shop/Controllers', 'model' => 'lib/Shop/Models', + 'module' => 'lib/Shop/Modules', 'provider' => 'lib/Shop/Providers', 'views' => 'themes/default', ], @@ -484,6 +485,7 @@ public function testTheSettingsOfAnMvcProjectLikeVokuro(): void 'command' => 'src/Command', 'controller' => 'src/Controllers', 'model' => 'src/Models', + 'module' => 'src/Modules', 'provider' => 'src/Providers', 'views' => 'themes/vokuro', ], From edb4270ea9449c82bd399e90a7cf8ba7ab43e311 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Tue, 3 Nov 2026 01:34:56 -0600 Subject: [PATCH 4/5] new resource stubs for model Assisted-by: Claude Code --- .../fragment-guidance-controller-module.stub | 5 +++ .../stubs/mvc/fragment-guidance-module.stub | 29 +++++++++++++++ resources/stubs/mvc/module.stub | 37 +++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 resources/stubs/mvc/fragment-guidance-controller-module.stub create mode 100644 resources/stubs/mvc/fragment-guidance-module.stub create mode 100644 resources/stubs/mvc/module.stub diff --git a/resources/stubs/mvc/fragment-guidance-controller-module.stub b/resources/stubs/mvc/fragment-guidance-controller-module.stub new file mode 100644 index 0000000..099197d --- /dev/null +++ b/resources/stubs/mvc/fragment-guidance-controller-module.stub @@ -0,0 +1,5 @@ +The {{ module }} module finds it: its Module class sets the default namespace +of the dispatcher to {{ namespace }}. + +With the routes from make:module, GET /{{ module }}/{{ route }}/index runs +indexAction(). Its view is {{ route }}/index in the views folder of the module. diff --git a/resources/stubs/mvc/fragment-guidance-module.stub b/resources/stubs/mvc/fragment-guidance-module.stub new file mode 100644 index 0000000..07ebfb2 --- /dev/null +++ b/resources/stubs/mvc/fragment-guidance-module.stub @@ -0,0 +1,29 @@ +Nothing registers it yet. In run() of your front controller, register the +module before handle(): + + $application = new Application($this->boot()); + $application->registerModules([ + '{{ module }}' => ['className' => \{{ namespace }}\Module::class], + ]); + $response = $application->handle($uri); + +Then add the routes of the module to the router, for example: + + $router->add('/{{ module }}', [ + 'module' => '{{ module }}', + 'controller' => 'index', + 'action' => 'index', + ]); + $router->add('/{{ module }}/:controller', [ + 'module' => '{{ module }}', + 'controller' => 1, + 'action' => 'index', + ]); + $router->add('/{{ module }}/:controller/:action/:params', [ + 'module' => '{{ module }}', + 'controller' => 1, + 'action' => 2, + 'params' => 3, + ]); + +Write its first controller with: crest make:controller Index --module={{ module }} diff --git a/resources/stubs/mvc/module.stub b/resources/stubs/mvc/module.stub new file mode 100644 index 0000000..a3e318b --- /dev/null +++ b/resources/stubs/mvc/module.stub @@ -0,0 +1,37 @@ +getShared('dispatcher'); + $dispatcher->setDefaultNamespace('{{ namespace }}\Controllers'); + + /** @var View $view */ + $view = $container->getShared('view'); + $view->setViewsDir(__DIR__ . '/views/'); + } +} From 5b68d7b29a3c8074c8204c53ce835aa3c2e05a82 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Tue, 3 Nov 2026 01:35:08 -0600 Subject: [PATCH 5/5] docs update Assisted-by: Claude Code --- CHANGELOG.md | 3 + docs/index.md | 179 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 175 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e14bfbf..d2d45f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added the `models` key to the `crest.php` that `new` and `init` write for an MVC project. `new --camel-case` gives `'camelCase' => true`, and `new` asks for it for an MVC project with a database; `init` writes `false`. [#54](https://github.com/phalcon/crest/issues/54) - Added the `models` key to `config:show` in an MVC project: a `camelCase` row with its origin, and a `TABLE CLASS` table with each line of `models.classes`, sorted by table. A wrong key gives one `models` row with the error; `config:show` still exits with 0, and `make:model` stops on the error. Added `Crest\Generator\ModelNames::camelCase()`. [#71](https://github.com/phalcon/crest/issues/71) - Added a `Note` line to `make:model` for a table whose class comes from `models.classes`: it names the old model and base, which crest keeps, and the related tables to run `make:model` for again. [#71](https://github.com/phalcon/crest/issues/71) +- Added `make:module` for MVC projects: it writes `Module.php` (a class that implements `Phalcon\Mvc\ModuleDefinitionInterface`) and the folders `Controllers`, `Models` and `views` in the `module` path (`src/Modules`), and prints how to register the module and its routes. An existing `Module.php` stops the command; `--force` replaces only that file. Added the `module` default path, the MVC stubs `module`, `fragment-guidance-module` and `fragment-guidance-controller-module`, `Crest\Generator\ModuleLocation` and `Crest\Generator\ArtifactWriter::directory()`. [#55](https://github.com/phalcon/crest/issues/55) +- Added `--module=` to `make:controller` and `make:model`: they write into the module, with its namespace. The relations of a model in a module name models in the same module. [#55](https://github.com/phalcon/crest/issues/55) ### Changed @@ -130,6 +132,7 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Changed the lines of `make:model` for an alias with the name of a property of the table: they say `has the name of a property`, not `has the name of a column`, because a property can have a name that is not the name of its column. [#53](https://github.com/phalcon/crest/issues/53) - Changed the project stubs `project-compose`, `project-dockerfile`, `project-env`, `project-gitignore`, `project-composer`, `project-config` and `project-front` (ADR and MVC): they have new placeholders. When the project needs one, `new` stops before it writes a file if a published copy does not have it: publish the stub again. Without a database and without `--camel-case`, an old copy works as before. [#54](https://github.com/phalcon/crest/issues/54) - Changed `new`: in a terminal, it asks for the database, so a script that sends answers to `new` needs one more line. `Crest\Generator\Project\Answers` has a new last argument `database`, and `Crest\Project\Settings` a new last argument `camelCase`. [#54](https://github.com/phalcon/crest/issues/54) +- Changed `init`: for an MVC project, it also asks for the `module` folder, so a script that sends answers to `init` needs one more line. `new` writes the `module` path into the `crest.php` of an MVC project. [#55](https://github.com/phalcon/crest/issues/55) ### Fixed diff --git a/docs/index.md b/docs/index.md index 4e9f265..8bbff53 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ the commands for the flavor of the project. | `make:controller` | create an MVC controller | | `make:middleware` | create an ADR middleware | | `make:model` | create an MVC model from a database table, or from each table with --all (relations come only from foreign keys) | +| `make:module` | create an MVC module | | `make:provider` | create a service provider | | `make:responder` | create an ADR responder | | `new` | create an ADR or MVC project | @@ -34,8 +35,8 @@ the commands for the flavor of the project. | `up` | start the project containers | `make:command` works in each flavor. `make:provider` works in ADR and MVC projects, -with a stub for each flavor. `make:controller` and `make:model` are for MVC -projects only. These commands are for ADR projects only: `make:action`, +with a stub for each flavor. `make:controller`, `make:model` and `make:module` +are for MVC projects only. These commands are for ADR projects only: `make:action`, `make:middleware`, `make:responder`, `route:list`, `container:list` and `event:list`. In a project of a different flavor, they stop with an error: @@ -233,7 +234,7 @@ applications (vokuro, invo): | `themes/default/` | the layout `index.phtml` and the view `index/index.phtml` | `crest.php` has `'flavor' => 'mvc'` and the paths `controller`, `model`, -`provider` and `views`. To add a service, run `crest make:provider Mailer`. It +`module`, `provider` and `views`. To add a service, run `crest make:provider Mailer`. It writes `src/Providers/MailerProvider.php`, a provider that implements `Phalcon\Di\ServiceProviderInterface`, and prints the line to add to `AppFront::providers()`. @@ -402,8 +403,9 @@ questions, in this order, each with a proposal: 3. The front controller: `\AppFront` when `AppFront.php` is in that folder. Answer `none` for no front controller. 4. A folder for each path of the flavor: `action`, `command`, `middleware`, `provider` - and `responder` for ADR; `command`, `controller`, `provider` and `views` for MVC. The - question says if the folder exists. A class folder is in the folder of the namespace. + and `responder` for ADR; `command`, `controller`, `model`, `module`, `provider` and + `views` for MVC. The question says if the folder exists. A class folder is in the + folder of the namespace. An answer must be a relative folder in a psr-4 folder, and its folder names must give a usable namespace: `src/my-actions` gives `App\my-actions`, which PHP cannot use. A leading `./` is removed. @@ -532,12 +534,28 @@ on `users` (the URL `/users` looks for `UsersController`), on `User_Profiles`, and on a name with the suffix two times, for example `UserControllerController` or `Usercontroller`. `UserController` gives `UserController`. +To write the controller into a module, add `--module` (see "Generating +modules"): + + crest make:controller Users --module=admin + + Created src/Modules/Admin/Controllers/Base/CrestControllerBase.php + Created src/Modules/Admin/Controllers/UsersController.php + +Each module has its own `CrestControllerBase`. The `Module.php` of the module +sets the default namespace of the dispatcher, so crest prints the URL with the +routes of `make:module` (`GET /admin/users/index`) and the views folder of the +module instead. The module must exist: else crest stops, and tells you to run +`make:module` first. + ### Generated bases Crest owns `CrestControllerBase` in `src/Controllers/Base/`. Do not edit it: its docblock starts with `Generated by crest.`, and crest writes the file again when the stub of the base changes. You own the controllers, and crest writes -each one only once. +each one only once. Each module has its own copy, in the `Controllers/Base/` +folder of the module (`src/Modules/Admin/Controllers/Base/`), with the same +rules: to get its new base, add `--module=` to `make:controller`. - To get the new base after an upgrade of crest, run `make:controller` again on an existing controller. Crest keeps the controller and writes the base: @@ -639,11 +657,29 @@ Crest stops, and writes no file, when: - the `models` key of `crest.php` is not correct, or `--property` names a column that the table does not have (see "Names"); - a published stub has no `{{ columnMap }}` or `{{ property }}`, and the base - needs a `columnMap()` (see "Names"). + needs a `columnMap()` (see "Names"); +- `--module` names a module that does not exist (see "Generating modules"). `make:model` is for MVC projects. An ADR project uses repositories, not Active Record models, so the command stops there. +To write the models into a module, add `--module` (see "Generating modules"): + + crest make:model users --module=admin + + Created src/Modules/Admin/Models/Base/UsersBase.php + No relations for users: the table has no foreign keys, and no foreign keys point to it. Relations come only from foreign keys. + Created src/Modules/Admin/Models/Users.php + +The models and their bases go into the `Models` folder of the module, in its +namespace (`App\Modules\Admin\Models`). `--module` works with one table and +with `--all`. A relation names a model in the same module, so write the related +tables into the same module. The lines that tell you to run `make:model` again +name the option, for example +`Run make:model --module=admin for customers too: ...`. Crest reads the tables +through the `db` service of the project, also when the module sets its own +`db`. + ### Names Crest gives each model the class of its table name, and each property the @@ -1049,6 +1085,135 @@ An alias with the name of a `get` method of `Phalcon\Mvc\Model`, for example (`$user->Messages`) and through `getRelated('Messages')`, but not as `$user->getMessages()`: PHP calls the method of the model. +## Generating modules + +In an MVC project, `make:module` writes a module: a part of the application +with its own controllers, models and views, for example `admin` or `reports`. +All the modules are in the `module` path of `crest.php` (`src/Modules`), and +each module has the same structure: + + crest make:module admin + + Created src/Modules/Admin/Module.php + Created src/Modules/Admin/Controllers + Created src/Modules/Admin/Models + Created src/Modules/Admin/views + +| Path | Contents | +|---|---| +| `src/Modules/Admin/Module.php` | `App\Modules\Admin\Module`, which implements `Phalcon\Mvc\ModuleDefinitionInterface` | +| `src/Modules/Admin/Controllers/` | the controllers (`crest make:controller Index --module=admin`) | +| `src/Modules/Admin/Models/` | the models (`crest make:model users --module=admin`) | +| `src/Modules/Admin/views/` | the views of the module | + +After the `Created` lines, crest prints the lines to add to your front +controller and your router (see below). + +The folder is the name with the first letter of each part between `_` in +uppercase: `admin` and `Admin` give `Admin`, and `admin_panel` gives +`AdminPanel`. The key of the module is the folder in lowercase: `admin`, or +`adminpanel`. You use the key in `registerModules()`, in the routes and in +`--module`. Crest finds a module from its key, so `--module=adminpanel` finds +the folder `AdminPanel`. A name that does not give a class name (`my module`, +`admin-panel`) stops the command. + +`Module.php` is yours: add the services of the module in `registerServices()`. +Crest writes it one time. When it exists, `make:module` stops; `--force` +replaces only `Module.php`, and the folders and their files stay. There is no +config file: a module that needs settings reads them from `.env` in +`registerServices()`, as the providers of the project do. + +### Registration and routes + +Crest does not change your front controller or your router. Register the +module in `run()` of `AppFront`, before `handle()`: + +```php +public function run(): int +{ + $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); + $application = new Application($this->boot()); + $application->registerModules([ + 'admin' => ['className' => \App\Modules\Admin\Module::class], + 'common' => ['className' => \App\Modules\Common\Module::class], + ]); + $response = $application->handle($uri); + + if ($response instanceof ResponseInterface) { + $response->send(); + } + + return 0; +} +``` + +Then send requests to the modules with routes. Write a provider for the router +(`crest make:provider Router`), and add `new RouterProvider(),` to +`providers()`: + +```php +public function register(DiInterface $di): void +{ + $di->setShared('router', function (): Router { + // The default routes stay for the controllers of the project. + $router = new Router(); + + $router->add('/admin', [ + 'module' => 'admin', + 'controller' => 'index', + 'action' => 'index', + ]); + $router->add('/admin/:controller', [ + 'module' => 'admin', + 'controller' => 1, + 'action' => 'index', + ]); + $router->add('/admin/:controller/:action/:params', [ + 'module' => 'admin', + 'controller' => 1, + 'action' => 2, + 'params' => 3, + ]); + + // A route that does not follow the pattern of a module. + $router->add('/login', [ + 'module' => 'common', + 'controller' => 'session', + 'action' => 'login', + ]); + + return $router; + }); +} +``` + +`Router` is `Phalcon\Mvc\Router`. A request with no module in its route uses +the dispatcher and the views of the project, as before. + +### What a module changes + +`registerServices()` of `Module.php` changes two services that the providers +of the project set: + +- the default namespace of the dispatcher becomes the namespace of the + controllers of the module (`App\Modules\Admin\Controllers`); +- the views folder becomes the `views/` folder of the module. + +So a page of the module uses only the views of the module, and shows no +layout of the project. Add a layout in the `views/` folder of the module. For +one views folder for the whole application, change the `setViewsDir()` line of +`Module.php` to the views folder of the project. + +A project with no `dispatcher` or `view` service must change +`registerServices()`. + +### Models in a module + +A relation of a model in a module names a model in the same module. To share +models between modules, keep them in the `model` path: run `make:model` with +no `--module`. `make:model --module` reads the tables through the `db` service +of the project, also when the module sets its own `db`. + ## Adding commands from a package A package adds commands to crest in its `composer.json`: