From fab6632211e436f282076a1bf267bffcfc1995f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20J=C3=BCrisoo?= Date: Sat, 29 Aug 2026 13:55:16 +0200 Subject: [PATCH 1/4] Add the archetype command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archetype has only ever been reachable from PHP. This adds a command line over the same engine — 26 operations, each an Artisan command under `archetype:`, plus an `archetype` binary that finds the application and forwards to it. Nothing here reimplements AST rewriting; every mutation goes through the existing PHPFile/LaravelFile endpoints. Three properties hold for every operation that writes, because a caller that cannot rely on them has to read the file back and loses the point of asking: - the file is re-rendered and compared, so a change that matched nothing exits non-zero instead of reporting a success that wrote nothing; - the answer carries a diff of what changed; - a change already applied reports SKIP, which makes them safe to repeat. Every operation takes a single target — a path, a class name, or a directory, where a directory means every class beneath it, narrowed with --extends / --implements / --uses-trait / --matching. Every operation takes --json; every mutation takes --dry-run and --no-diff. Two operations exist because the PHP API cannot express them: set-array-key edits the array a method returns, which is where rules(), toArray() and casts() keep their contents, and set-casts writes to whichever casting mechanism a model already uses rather than adding a second one beside the first. add-relation covers all eleven Eloquent relation types with pivot tables, explicit keys and withPivot. Alongside, a few endpoints now reach constructs they previously matched but silently ignored: className(), classConstant(), useTrait() and property() work on any class-like, and implements() on classes and enums. Before this, adding an interface to an enum reported success and wrote nothing. The PSR-2 printer also prints `function name(): Type` rather than `function name() : Type`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x --- CHANGELOG.md | 54 +++- bin/archetype | 41 +++ composer.json | 3 + docs.md | 255 +++++++++++++++- readme.md | 80 +++++ src/Commands/ErrorsCommand.php | 11 +- src/Console/ArchetypeCommand.php | 98 +++++++ src/Console/Commands/AddCaseCommand.php | 46 +++ src/Console/Commands/AddImplementsCommand.php | 39 +++ src/Console/Commands/AddMethodCommand.php | 40 +++ src/Console/Commands/AddRelationCommand.php | 79 +++++ src/Console/Commands/AddToPropertyCommand.php | 45 +++ src/Console/Commands/AddTraitCommand.php | 39 +++ src/Console/Commands/AddUseCommand.php | 32 ++ src/Console/Commands/ApplyCommand.php | 90 ++++++ src/Console/Commands/EmptyPropertyCommand.php | 33 +++ src/Console/Commands/FindCommand.php | 84 ++++++ src/Console/Commands/HelpCommand.php | 45 +++ src/Console/Commands/InspectCommand.php | 167 +++++++++++ src/Console/Commands/MakeCommand.php | 75 +++++ src/Console/Commands/RemoveConstCommand.php | 34 +++ src/Console/Commands/RemoveMethodCommand.php | 35 +++ .../Commands/RemovePropertyCommand.php | 31 ++ src/Console/Commands/RemoveUseCommand.php | 33 +++ src/Console/Commands/RenameClassCommand.php | 36 +++ src/Console/Commands/ReplaceMethodCommand.php | 45 +++ src/Console/Commands/SetArrayKeyCommand.php | 71 +++++ src/Console/Commands/SetCastsCommand.php | 107 +++++++ src/Console/Commands/SetConstCommand.php | 38 +++ src/Console/Commands/SetExtendsCommand.php | 32 ++ src/Console/Commands/SetNamespaceCommand.php | 30 ++ src/Console/Commands/SetPropertyCommand.php | 56 ++++ src/Console/Commands/ShowCommand.php | 54 ++++ src/Console/MutationCommand.php | 215 ++++++++++++++ src/Console/Support/ArrayLiteral.php | 168 +++++++++++ src/Console/Support/Code.php | 137 +++++++++ src/Console/Support/Diff.php | 147 ++++++++++ src/Console/Support/Introspector.php | 276 ++++++++++++++++++ src/Console/Support/Manifest.php | 169 +++++++++++ src/Console/Support/Member.php | 58 ++++ src/Console/Support/Relation.php | 214 ++++++++++++++ src/Console/Support/Target.php | 101 +++++++ src/Console/TargetedCommand.php | 45 +++ src/Endpoints/PHP/ClassConstant.php | 10 +- src/Endpoints/PHP/ClassName.php | 4 +- src/Endpoints/PHP/Implements_.php | 34 ++- src/Endpoints/PHP/Property.php | 10 +- src/Endpoints/PHP/UseTrait.php | 4 +- src/ServiceProvider.php | 6 +- src/Support/PSR2PrettyPrinter.php | 2 +- src/Traits/PHPParserClassMap.php | 18 ++ .../Console/AddRelationCommandTest.php | 114 ++++++++ tests/Feature/Console/ApplyCommandTest.php | 85 ++++++ tests/Feature/Console/BinaryTest.php | 82 ++++++ tests/Feature/Console/EnumCommandsTest.php | 84 ++++++ tests/Feature/Console/FindCommandTest.php | 48 +++ tests/Feature/Console/HelpCommandTest.php | 33 +++ tests/Feature/Console/InspectCommandTest.php | 110 +++++++ tests/Feature/Console/MakeCommandTest.php | 62 ++++ tests/Feature/Console/MethodCommandsTest.php | 98 +++++++ .../Feature/Console/MutationContractTest.php | 133 +++++++++ .../Feature/Console/PropertyCommandsTest.php | 76 +++++ .../Console/SetArrayKeyCommandTest.php | 122 ++++++++ tests/Feature/Console/SetCastsCommandTest.php | 92 ++++++ tests/Feature/Console/ShowCommandTest.php | 56 ++++ .../Feature/Console/StructureCommandsTest.php | 138 +++++++++ tests/Pest.php | 7 + tests/Support/Console.php | 64 ++++ tests/TestCase.php | 6 +- 69 files changed, 4951 insertions(+), 35 deletions(-) create mode 100755 bin/archetype create mode 100644 src/Console/ArchetypeCommand.php create mode 100644 src/Console/Commands/AddCaseCommand.php create mode 100644 src/Console/Commands/AddImplementsCommand.php create mode 100644 src/Console/Commands/AddMethodCommand.php create mode 100644 src/Console/Commands/AddRelationCommand.php create mode 100644 src/Console/Commands/AddToPropertyCommand.php create mode 100644 src/Console/Commands/AddTraitCommand.php create mode 100644 src/Console/Commands/AddUseCommand.php create mode 100644 src/Console/Commands/ApplyCommand.php create mode 100644 src/Console/Commands/EmptyPropertyCommand.php create mode 100644 src/Console/Commands/FindCommand.php create mode 100644 src/Console/Commands/HelpCommand.php create mode 100644 src/Console/Commands/InspectCommand.php create mode 100644 src/Console/Commands/MakeCommand.php create mode 100644 src/Console/Commands/RemoveConstCommand.php create mode 100644 src/Console/Commands/RemoveMethodCommand.php create mode 100644 src/Console/Commands/RemovePropertyCommand.php create mode 100644 src/Console/Commands/RemoveUseCommand.php create mode 100644 src/Console/Commands/RenameClassCommand.php create mode 100644 src/Console/Commands/ReplaceMethodCommand.php create mode 100644 src/Console/Commands/SetArrayKeyCommand.php create mode 100644 src/Console/Commands/SetCastsCommand.php create mode 100644 src/Console/Commands/SetConstCommand.php create mode 100644 src/Console/Commands/SetExtendsCommand.php create mode 100644 src/Console/Commands/SetNamespaceCommand.php create mode 100644 src/Console/Commands/SetPropertyCommand.php create mode 100644 src/Console/Commands/ShowCommand.php create mode 100644 src/Console/MutationCommand.php create mode 100644 src/Console/Support/ArrayLiteral.php create mode 100644 src/Console/Support/Code.php create mode 100644 src/Console/Support/Diff.php create mode 100644 src/Console/Support/Introspector.php create mode 100644 src/Console/Support/Manifest.php create mode 100644 src/Console/Support/Member.php create mode 100644 src/Console/Support/Relation.php create mode 100644 src/Console/Support/Target.php create mode 100644 src/Console/TargetedCommand.php create mode 100644 tests/Feature/Console/AddRelationCommandTest.php create mode 100644 tests/Feature/Console/ApplyCommandTest.php create mode 100644 tests/Feature/Console/BinaryTest.php create mode 100644 tests/Feature/Console/EnumCommandsTest.php create mode 100644 tests/Feature/Console/FindCommandTest.php create mode 100644 tests/Feature/Console/HelpCommandTest.php create mode 100644 tests/Feature/Console/InspectCommandTest.php create mode 100644 tests/Feature/Console/MakeCommandTest.php create mode 100644 tests/Feature/Console/MethodCommandsTest.php create mode 100644 tests/Feature/Console/MutationContractTest.php create mode 100644 tests/Feature/Console/PropertyCommandsTest.php create mode 100644 tests/Feature/Console/SetArrayKeyCommandTest.php create mode 100644 tests/Feature/Console/SetCastsCommandTest.php create mode 100644 tests/Feature/Console/ShowCommandTest.php create mode 100644 tests/Feature/Console/StructureCommandsTest.php create mode 100644 tests/Support/Console.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d5fa21..f539dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [2.1.0] - 2026-08-29 + +Adds a command line to Archetype. Every existing PHP API is untouched; a handful +of endpoints now reach constructs they previously matched but silently ignored. + +### Added + +- **A command line.** 26 operations, each an Artisan command under `archetype:`, + plus an `archetype` binary that finds the application and forwards to it. Run + `archetype` with no arguments for the list, or see the + [reference](docs.md#command-line-reference). + + Reading: `inspect`, `show`, `find`, `errors`. + Writing: `make`, `set-property`, `add-to-property`, `empty-property`, + `remove-property`, `set-casts`, `add-relation`, `set-array-key`, `add-use`, + `remove-use`, `add-trait`, `add-implements`, `set-extends`, `set-namespace`, + `rename-class`, `set-const`, `remove-const`, `add-case`, `add-method`, + `replace-method`, `remove-method`, `apply`. + +- Every operation takes a single target, which is a path, a class name, or a + directory — where a directory means every class beneath it, narrowed with + `--extends`, `--implements`, `--uses-trait` or `--matching`. +- Every operation takes `--json`. +- Every mutation re-renders the file and compares before reporting. One that + matched nothing exits non-zero rather than reporting a success that wrote + nothing; one whose change is already present reports `SKIP`, which makes the + operations safe to repeat. +- Every mutation answers with a diff of what it changed, and takes `--dry-run` + to show that diff without writing. +- `archetype apply` runs a script of operations in one invocation, reading a file + or standard input. +- `set-casts` writes to whichever casting mechanism a model already uses — the + `casts()` method Laravel 11 generates, or the `$casts` property — instead of + adding a second one beside the first. +- `set-array-key` edits the array a method returns, which is where `rules()`, + `toArray()`, `casts()` and `definition()` keep their contents. +- `add-relation` covers all eleven Eloquent relation types, with pivot tables, + explicit keys, `withPivot`, `withTimestamps` and a custom pivot model. +- `enum()` and `enumCase()` query methods on the `ASTQueryBuilder`. +- `php artisan archetype:errors` takes `--json`. + +### Changed + +- `className()`, `classConstant()`, `useTrait()` and `property()` now match any + class-like declaration rather than only `class`, so they work on enums, + interfaces and traits. `implements()` matches classes and enums. Previously + these silently did nothing on anything but a class. +- The PSR-2 pretty printer prints `function name(): Type` rather than + `function name() : Type`. Only newly printed declarations are affected; + untouched code keeps its own formatting. + ## [2.0.1] - 2026-08-25 Maintenance only. No API changes, and nothing here can break existing usage. @@ -67,7 +118,8 @@ Last release of the 1.x line, which requires `nikic/php-parser` ^4.11. Pest 3 and newer. If Composer refuses to resolve `ajthinking/archetype`, upgrade to 2.x. -[Unreleased]: https://github.com/ajthinking/archetype/compare/v2.0.1...HEAD +[Unreleased]: https://github.com/ajthinking/archetype/compare/v2.1.0...HEAD +[2.1.0]: https://github.com/ajthinking/archetype/compare/v2.0.1...v2.1.0 [2.0.1]: https://github.com/ajthinking/archetype/compare/v2.0.0...v2.0.1 [2.0.0]: https://github.com/ajthinking/archetype/compare/v1.1.5...v2.0.0 [1.1.5]: https://github.com/ajthinking/archetype/releases/tag/v1.1.5 diff --git a/bin/archetype b/bin/archetype new file mode 100755 index 0000000..285842b --- /dev/null +++ b/bin/archetype @@ -0,0 +1,41 @@ +#!/usr/bin/env php +`. +if ($operation === null || str_starts_with($operation, '-')) { + array_unshift($arguments, 'archetype'); +} else { + $arguments[0] = str_starts_with($operation, 'archetype:') ? $operation : 'archetype:'.$operation; +} + +passthru( + implode(' ', array_map('escapeshellarg', array_merge([PHP_BINARY, $directory.'/artisan'], $arguments))), + $status +); + +exit($status); diff --git a/composer.json b/composer.json index 3795160..0bb8176 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,9 @@ ] } }, + "bin": [ + "bin/archetype" + ], "autoload": { "psr-4": { "Archetype\\": "src/" diff --git a/docs.md b/docs.md index 9472812..27e875a 100644 --- a/docs.md +++ b/docs.md @@ -160,4 +160,257 @@ $file->add()->use([ Extra1::class, Extra2::class, ]) -``` \ No newline at end of file +``` + +## Command line reference + +Every operation is an Artisan command named `archetype:`. The +`archetype` binary walks up from the working directory to find your +application's `artisan` file and forwards to it, so these are the same call: + +```bash +./vendor/bin/archetype inspect app/Models/User.php +php artisan archetype:inspect app/Models/User.php +``` + +### Targets + +Every operation but `make` and `apply` takes one target, which is any of: + +| Target | Means | +|---|---| +| `app/Models/User.php` | that file | +| `App\Models\User` | that class, resolved to a path | +| `app/Models` | every PHP class under that directory | + +A directory target can be narrowed: + +| Option | Keeps only classes | +|---|---| +| `--extends=Model` | extending that class | +| `--implements=Auditable` | implementing that interface | +| `--uses-trait=SoftDeletes` | using that trait | +| `--matching=` | whose path matches | + +These options are rejected on a single-file target rather than ignored. + +### Options every operation takes + +| Option | Effect | +|---|---| +| `--json` | Emit JSON instead of the compact line format | + +### Options every mutation takes + +| Option | Effect | +|---|---| +| `--dry-run` | Show the diff without writing | +| `--no-diff` | Suppress the diff | + +### Exit codes and statuses + +| Status | Meaning | Exit | +|---|---|---| +| `OK ` | Changed and saved | 0 | +| `DRY ` | Would change; nothing written | 0 | +| `SKIP ` | Already in the desired state | 0 | +| `ERR ` | Could not do what was asked | 1 | + +A mutation that matches nothing reports `ERR`, never `OK`. That is what makes it +safe not to read the file back. + +### Reading + +#### Summarise a file +```bash +archetype inspect app/Models/User.php +``` +``` +app/Models/User.php +class App\Models\User extends Authenticatable +uses HasApiTokens, HasFactory, Notifiable +import Illuminate\Foundation\Auth\User as Authenticatable +prop protected $fillable = ["name","email","password"] +prop protected $casts = {"email_verified_at":"datetime"} +fn public posts() [4 lines] +rel posts hasMany Post +``` + +Limit it to the sections you need — `meta`, `traits`, `uses`, `consts`, `cases`, +`props`, `methods`, `relations`: + +```bash +archetype inspect app/Models/User.php props relations +``` + +#### Print one method +```bash +archetype show app/Http/Requests/StoreTaskRequest.php rules +``` + +`inspect` deliberately leaves method bodies out; this is how you get one. + +#### Find files +```bash +archetype find app +archetype find app --type=models +archetype find --type=migrations +archetype find app --extends=FormRequest +archetype find app --matching='Http/Controllers' +``` + +`--type` is one of `all`, `models`, `controllers`, `providers`, `migrations`. +The class types use reflection, so they only see classes the application can +autoload; the other filters read the syntax tree and work on anything that +parses. + +#### List files that do not parse +```bash +archetype errors +``` + +### Creating + +```bash +archetype make 'App\Services\Billing' +archetype make app/Services/Billing.php +archetype make 'App\Models\Invoice' \ + --extends='Illuminate\Database\Eloquent\Model' \ + --implements='App\Contracts\Payable' \ + --trait='Illuminate\Database\Eloquent\Factories\HasFactory' +archetype make app/helpers.php --file +``` + +Refuses to overwrite an existing file unless given `--force`. + +### Properties + +```bash +archetype set-property app/Models/User.php table gdpr_users +archetype set-property app/Models/User.php with '["profile","posts"]' +archetype set-property app/Models/User.php perPage 25 --visibility=public +archetype set-property app/Models/User.php connection # no default value + +archetype add-to-property app/Models/User.php fillable nickname avatar +archetype empty-property app/Models/User.php fillable +archetype remove-property app/Models/User.php hidden +``` + +Values are read as JSON when they are valid JSON, and as a plain string +otherwise. Visibility is left as it is unless `--visibility` says otherwise. + +### Eloquent + +```bash +archetype set-casts app/Models/User.php archived_at=datetime status=Status::class + +archetype add-relation app/Models/Project.php hasMany Task +archetype add-relation app/Models/Project.php belongsTo User --name=owner --foreign-key=owner_id +archetype add-relation app/Models/Project.php belongsToMany Label \ + --table=label_project --with-pivot=sort,note --with-timestamps +archetype add-relation app/Models/Project.php morphMany Comment --morph-name=commentable +archetype add-relation app/Models/Project.php hasManyThrough Comment --through=Task +``` + +`set-casts` writes to whichever mechanism the model already uses — the `casts()` +method Laravel 11 generates, or the `$casts` property — rather than adding a +second one beside it. + +`add-relation` covers all eleven relation types: `hasOne`, `hasMany`, +`belongsTo`, `belongsToMany`, `hasOneThrough`, `hasManyThrough`, `morphOne`, +`morphMany`, `morphTo`, `morphToMany`, `morphedByMany`. The related class is +imported when it needs to be. + +### Arrays returned from methods + +```bash +archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules due_at 'nullable|date' +archetype set-array-key app/Http/Resources/TaskResource.php toArray budget '$this->budget_cents' +archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules tags "['array', 'max:5']" +archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules title --remove +archetype set-array-key app/Providers/AppServiceProvider.php policies ignored Policy::class --append +``` + +This reaches `rules()`, `toArray()`, `casts()`, `definition()` and everything +else of that shape — the array a method returns directly, never one returned +from a closure nested inside it. + +A bare word is a string, so `nullable|date` is a validation rule rather than a +bitwise or. Brackets, quotes, `$variables`, calls, `Class::constants`, numbers +and booleans are read as PHP. + +### Structure + +```bash +archetype add-use app/Models/User.php 'App\Contracts\Auditable' 'Illuminate\Support\Str' +archetype remove-use app/Models/User.php 'Illuminate\Support\Str' +archetype add-trait app/Models/User.php 'Illuminate\Database\Eloquent\SoftDeletes' +archetype add-implements app/Models/User.php 'App\Contracts\Auditable' +archetype set-extends app/Models/User.php 'Illuminate\Database\Eloquent\Model' +archetype set-namespace app/Models/User.php 'App\Domain\Models' +archetype rename-class app/Models/User.php Account +``` + +`add-trait`, `add-implements` and `set-extends` add the import too, since a name +used without one is never valid PHP. + +`rename-class` renames the declaration only. It does not move the file or update +references elsewhere. + +### Constants and enum cases + +```bash +archetype set-const app/Models/User.php HOME /dashboard +archetype remove-const app/Models/User.php HOME + +archetype add-case app/Enums/ProjectStatus.php OnHold on_hold +archetype add-case app/Enums/Suit.php Spades # pure enum, no backing value +``` + +Constants work on classes, interfaces, enums and traits. New enum cases are +added after the ones already there. + +### Methods + +```bash +archetype add-method app/Models/Project.php \ + --code='public function scopeActive($query) { return $query->where("active", true); }' +archetype replace-method app/Models/Project.php isActive \ + --code='public function isActive(): bool { return $this->active; }' +archetype remove-method app/Models/Project.php isActive +``` + +Methods can be added to a class, enum, interface or trait, and are appended +after the methods already there. + +### Several operations in one call + +```bash +archetype apply operations.txt +archetype apply < operations.txt +``` + +One operation per line, `#` for comments, the `archetype:` prefix optional: + +```text +# what this change needs +add-to-property app/Models/Project.php fillable budget_cents +set-casts app/Models/Project.php budget_cents=integer +add-relation app/Models/Project.php hasMany Task +``` + +Each operation keeps its own verification, diff and exit status. `apply` exits +non-zero if any of them failed, and `--stop-on-failure` stops at the first. + +### JSON + +Every operation takes `--json`: + +```bash +archetype add-to-property app/Models/User.php fillable nickname --json +``` +```json +{"ok":true,"dryRun":false,"changed":1,"skipped":0,"failed":0,"results":[{"file":"app/Models/User.php","status":"changed","detail":"$fillable +1","diff":"@@ 24 @@\n+ 'nickname',\n ];"}]} +``` + +An error answers with `{"ok":false,"error":"..."}` and exit code 1. diff --git a/readme.md b/readme.md index e900a38..eec179b 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,7 @@ * Programatically modify php files with an intuitive top level read/write API * Read/write on classes, framework- and language constructs using `FileQueryBuilders` and `AbstractSyntaxTreeQueryBuilders` +* Do the same from a terminal — or from an AI agent — with the [`archetype` command line](#command-line) ## Getting started ```bash @@ -196,6 +197,85 @@ $file->astQuery() ->save() ``` +## Command line + +Everything above is also a command. Each operation is an Artisan command under +`archetype:`, and the `archetype` binary is a shorthand that finds your +application and forwards to it: + +```bash +./vendor/bin/archetype inspect app/Models/User.php +# is the same as +php artisan archetype:inspect app/Models/User.php +``` + +Run `archetype` with no arguments for the full list. There are 26 operations; +these are the shape of them: + +```bash +# read +archetype inspect app/Models/User.php # structure, without method bodies +archetype inspect app/Models/User.php props methods # only the parts you want +archetype show app/Http/Requests/StoreTask.php rules +archetype find app --type=models --uses-trait=SoftDeletes + +# write +archetype add-to-property app/Models/User.php fillable nickname +archetype set-casts app/Models/User.php archived_at=datetime status=Status::class +archetype add-relation app/Models/Project.php belongsToMany Label --table=label_project --with-timestamps +archetype set-array-key app/Http/Requests/StoreTask.php rules due_at 'nullable|date' +archetype add-case app/Enums/Status.php OnHold on_hold +archetype add-method app/Models/User.php --code='public function scopeActive($q) { return $q->where("active", true); }' +``` + +The full reference is in [docs.md](docs.md#command-line-reference). + +### What a target is + +Every operation takes one target, which is a path, a class name, or a directory: + +```bash +archetype add-trait app/Models/User.php Auditable # one file +archetype add-trait 'App\Models\User' Auditable # the same file +archetype add-trait app/Models Auditable # every class under app/Models +``` + +A directory target can be narrowed with `--extends`, `--implements`, +`--uses-trait` and `--matching`. + +### What a mutation answers with + +```bash +$ archetype add-to-property app/Models/User.php fillable nickname +OK app/Models/User.php $fillable +1 +@@ 24 @@ ++ 'nickname', + ]; +``` + +Three rules hold for every operation that writes: + +* it re-renders the file and compares, so a change that matched nothing is an + error and exits non-zero — never a success that wrote nothing; +* it answers with a diff, so you do not have to read the file back to see what + happened; +* a change already applied is `SKIP`, not `OK` and not an error, so operations + are safe to repeat. + +`--dry-run` shows the same diff without writing. `--json` gives every operation a +machine-readable answer instead. + +### Several changes in one call + +```bash +archetype apply <<'EOF' +add-to-property app/Models/Project.php fillable budget_cents +set-casts app/Models/Project.php budget_cents=integer +add-relation app/Models/Project.php hasMany Task +add-implements app/Models/Project.php 'App\Contracts\Auditable' +EOF +``` + ## Errors 😵 If a file can't be parsed, a `FileParseError` will be thrown. This can happen if you try to explicitly load a broken file *but also* when performing queries matching one or more problematic files. diff --git a/src/Commands/ErrorsCommand.php b/src/Commands/ErrorsCommand.php index a50b579..f2c4dc3 100644 --- a/src/Commands/ErrorsCommand.php +++ b/src/Commands/ErrorsCommand.php @@ -9,7 +9,7 @@ class ErrorsCommand extends Command { - protected $signature = 'archetype:errors'; + protected $signature = 'archetype:errors {--json : Emit JSON instead of a table}'; protected $description = 'List dirty files'; protected $result; protected $errors; @@ -33,6 +33,15 @@ public function handle() } }); + if ($this->option('json')) { + $this->output->writeln(json_encode([ + 'ok' => $this->errors->isEmpty(), + 'errors' => $this->errors->values()->all(), + ], JSON_UNESCAPED_SLASHES)); + + return; + } + if ($this->errors->isEmpty()) { $this->info('No errors found!'); return; diff --git a/src/Console/ArchetypeCommand.php b/src/Console/ArchetypeCommand.php new file mode 100644 index 0000000..9fe252f --- /dev/null +++ b/src/Console/ArchetypeCommand.php @@ -0,0 +1,98 @@ + */ + protected array $lines = []; + + /** @var array */ + protected array $payload = []; + + public function __construct() + { + parent::__construct(); + + foreach ($this->sharedOptions() as $option) { + $this->getDefinition()->addOption($option); + } + } + + /** Do the work. Return an exit code; throwing is equivalent to returning 1. */ + abstract protected function perform(): int; + + public function handle(): int + { + // Artisan resolves a command once and reuses the instance, so state + // from an earlier invocation has to be cleared rather than assumed + // absent. + $this->lines = []; + $this->payload = []; + + try { + $status = $this->perform(); + } catch (Throwable $exception) { + return $this->failWith($exception->getMessage()); + } + + $this->flush(); + + return $status; + } + + /** @return array */ + protected function sharedOptions(): array + { + return [ + new InputOption('json', null, InputOption::VALUE_NONE, 'Emit JSON instead of the compact line format'), + ]; + } + + protected function emit(string $line): void + { + $this->lines[] = $line; + } + + protected function failWith(string $message): int + { + $this->output->writeln( + $this->option('json') + ? $this->encode(['ok' => false, 'error' => $message]) + : "ERR $message" + ); + + return self::FAILURE; + } + + protected function flush(): void + { + if ($this->option('json')) { + $this->output->writeln($this->encode($this->payload)); + + return; + } + + foreach ($this->lines as $line) { + $this->output->writeln($line); + } + } + + protected function encode(array $payload): string + { + return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/src/Console/Commands/AddCaseCommand.php b/src/Console/Commands/AddCaseCommand.php new file mode 100644 index 0000000..b9a5fde --- /dev/null +++ b/src/Console/Commands/AddCaseCommand.php @@ -0,0 +1,46 @@ +argument('name'); + $value = $this->argument('value'); + + return $this->mutate(function (LaravelFile $file) use ($name, $value) { + $scope = new Introspector($file); + + if ($scope->kind() !== 'enum') { + throw new InvalidArgumentException('not an enum, it is a '.$scope->kind()); + } + + if ($scope->hasCase($name)) { + return $this->unchanged("case $name exists"); + } + + Member::add($file, new Node\Stmt\EnumCase( + $name, + $value === null ? null : Code::literal($value) + )); + + return "case $name"; + }); + } +} diff --git a/src/Console/Commands/AddImplementsCommand.php b/src/Console/Commands/AddImplementsCommand.php new file mode 100644 index 0000000..a6232c5 --- /dev/null +++ b/src/Console/Commands/AddImplementsCommand.php @@ -0,0 +1,39 @@ +argument('interfaces'); + + return $this->mutate(function (LaravelFile $file) use ($interfaces) { + $existing = array_map(fn ($name) => class_basename($name), $file->implements()); + + $wanted = array_values(array_filter( + $interfaces, + fn ($name) => ! in_array(class_basename($name), $existing, true) + )); + + if (! $wanted) { + return $this->unchanged('implements unchanged'); + } + + $imported = $this->import($file, $wanted); + + $file->add()->implements(array_map(fn ($name) => class_basename($name), $wanted)); + + return 'implements +'.count($wanted).($imported ? " (+$imported use)" : ''); + }); + } +} diff --git a/src/Console/Commands/AddMethodCommand.php b/src/Console/Commands/AddMethodCommand.php new file mode 100644 index 0000000..8ce05e5 --- /dev/null +++ b/src/Console/Commands/AddMethodCommand.php @@ -0,0 +1,40 @@ +where(\'active\', true); }"}'; + + protected $description = 'Add a method to a class, enum, interface or trait'; + + protected function perform(): int + { + $code = $this->option('code'); + + if (! $code) { + throw new InvalidArgumentException('--code is required'); + } + + $method = Code::method($code); + $name = $method->name->name; + + return $this->mutate(function (LaravelFile $file) use ($method, $name) { + if (in_array($name, $file->methodNames(), true)) { + return $this->unchanged("$name exists"); + } + + Member::add($file, Code::copy($method)); + + return "fn $name added"; + }); + } +} diff --git a/src/Console/Commands/AddRelationCommand.php b/src/Console/Commands/AddRelationCommand.php new file mode 100644 index 0000000..1d12fa1 --- /dev/null +++ b/src/Console/Commands/AddRelationCommand.php @@ -0,0 +1,79 @@ +argument('type'), + $this->argument('related'), + [ + 'name' => $this->option('name'), + 'morph-name' => $this->option('morph-name'), + 'through' => $this->option('through'), + 'table' => $this->option('table'), + 'foreign-key' => $this->option('foreign-key'), + 'related-key' => $this->option('related-key'), + 'local-key' => $this->option('local-key'), + 'owner-key' => $this->option('owner-key'), + 'first-key' => $this->option('first-key'), + 'second-key' => $this->option('second-key'), + 'type-column' => $this->option('type-column'), + 'id-column' => $this->option('id-column'), + 'using' => $this->option('using'), + 'with-pivot' => $this->option('with-pivot'), + 'with-timestamps' => $this->option('with-timestamps'), + ] + ); + + $name = $relation->name(); + $method = Code::method($relation->source()); + + return $this->mutate(function (LaravelFile $file) use ($relation, $name, $method) { + if (in_array($name, $file->methodNames(), true)) { + return $this->unchanged("$name exists"); + } + + $imported = $this->import($file, $relation->imports()); + + Member::add($file, Code::copy($method)); + + return sprintf( + '%s %s%s', + $this->argument('type'), + $name, + $imported ? " (+$imported use)" : '' + ); + }); + } +} diff --git a/src/Console/Commands/AddToPropertyCommand.php b/src/Console/Commands/AddToPropertyCommand.php new file mode 100644 index 0000000..c741ac1 --- /dev/null +++ b/src/Console/Commands/AddToPropertyCommand.php @@ -0,0 +1,45 @@ +argument('name'); + $values = $this->argument('values'); + + return $this->mutate(function (LaravelFile $file) use ($name, $values) { + $this->requirePropertyHolder($file); + + $visibility = $this->visibilityOf($file, $name, $this->option('visibility')); + $existing = $file->property($name); + $existing = is_array($existing) ? $existing : []; + + $missing = array_values(array_diff($values, $existing)); + + if (! $missing) { + return $this->unchanged("\$$name unchanged"); + } + + $file->assumeType('array')->{$visibility}()->add()->property($name, $missing); + + return "\$$name +".count($missing); + }); + } +} diff --git a/src/Console/Commands/AddTraitCommand.php b/src/Console/Commands/AddTraitCommand.php new file mode 100644 index 0000000..4a44cc8 --- /dev/null +++ b/src/Console/Commands/AddTraitCommand.php @@ -0,0 +1,39 @@ +argument('traits'); + + return $this->mutate(function (LaravelFile $file) use ($traits) { + $existing = array_map(fn ($trait) => class_basename($trait), $file->useTrait()); + + $wanted = array_values(array_filter( + $traits, + fn ($trait) => ! in_array(class_basename($trait), $existing, true) + )); + + if (! $wanted) { + return $this->unchanged('traits unchanged'); + } + + $imported = $this->import($file, $wanted); + + $file->add()->useTrait(array_map(fn ($trait) => class_basename($trait), $wanted)); + + return 'uses +'.count($wanted).($imported ? " (+$imported use)" : ''); + }); + } +} diff --git a/src/Console/Commands/AddUseCommand.php b/src/Console/Commands/AddUseCommand.php new file mode 100644 index 0000000..6f0fb47 --- /dev/null +++ b/src/Console/Commands/AddUseCommand.php @@ -0,0 +1,32 @@ +argument('imports'); + + return $this->mutate(function (LaravelFile $file) use ($imports) { + $missing = array_values(array_diff($imports, $file->use())); + + if (! $missing) { + return $this->unchanged('imports unchanged'); + } + + $file->add()->use($missing); + + return 'import +'.count($missing); + }); + } +} diff --git a/src/Console/Commands/ApplyCommand.php b/src/Console/Commands/ApplyCommand.php new file mode 100644 index 0000000..1936bea --- /dev/null +++ b/src/Console/Commands/ApplyCommand.php @@ -0,0 +1,90 @@ +operations(); + + if (! $operations) { + throw new RuntimeException('no operations given'); + } + + $results = []; + $failed = 0; + + foreach ($operations as $operation) { + $buffer = new BufferedOutput; + $status = $this->getApplication()->call($this->normalise($operation), [], $buffer); + $output = rtrim($buffer->fetch(), "\n"); + + $failed += $status === self::SUCCESS ? 0 : 1; + $results[] = ['operation' => $operation, 'ok' => $status === self::SUCCESS, 'output' => $output]; + + foreach (explode("\n", $output) as $line) { + $this->emit($line); + } + + if ($status !== self::SUCCESS && $this->option('stop-on-failure')) { + break; + } + } + + $this->emit(sprintf('%d of %d operations ok', count($results) - $failed, count($results))); + + $this->payload = [ + 'ok' => $failed === 0, + 'ran' => count($results), + 'failed' => $failed, + 'results' => $results, + ]; + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + /** @return array */ + protected function operations(): array + { + $file = $this->argument('file'); + + if ($file !== null && ! is_file($file)) { + throw new RuntimeException("no such file: $file"); + } + + $script = $file === null ? (string) file_get_contents('php://stdin') : (string) file_get_contents($file); + + return collect(explode("\n", $script)) + ->map(fn ($line) => trim($line)) + ->reject(fn ($line) => $line === '' || str_starts_with($line, '#')) + ->values() + ->all(); + } + + /** Operations may be written with or without the `archetype:` prefix. */ + protected function normalise(string $operation): string + { + $operation = str_starts_with($operation, 'archetype:') ? $operation : 'archetype:'.$operation; + + return $this->option('json') ? $operation.' --json' : $operation; + } +} diff --git a/src/Console/Commands/EmptyPropertyCommand.php b/src/Console/Commands/EmptyPropertyCommand.php new file mode 100644 index 0000000..f1df961 --- /dev/null +++ b/src/Console/Commands/EmptyPropertyCommand.php @@ -0,0 +1,33 @@ +argument('name'); + + return $this->mutate(function (LaravelFile $file) use ($name) { + $this->requirePropertyHolder($file); + + if (! (new Introspector($file))->hasProperty($name)) { + return $this->unchanged("no \$$name"); + } + + $file->{$this->visibilityOf($file, $name)}()->empty()->property($name); + + return "\$$name emptied"; + }); + } +} diff --git a/src/Console/Commands/FindCommand.php b/src/Console/Commands/FindCommand.php new file mode 100644 index 0000000..e97fe30 --- /dev/null +++ b/src/Console/Commands/FindCommand.php @@ -0,0 +1,84 @@ +option('type'); + + if (! in_array($type, self::TYPES, true)) { + throw new InvalidArgumentException("unknown --type '$type' — one of ".implode(', ', self::TYPES)); + } + + $directory = $this->argument('directory') + ?? ($type === 'migrations' ? 'database/migrations' : 'app'); + + if (! Target::isDirectory($directory)) { + throw new InvalidArgumentException("'$directory' is not a directory"); + } + + $paths = $this->query($directory, $type) + ->map(fn ($file) => Target::relative($file->inputDriver()->absolutePath())) + ->sort() + ->values(); + + if ($matching = $this->option('matching')) { + $paths = $paths->filter(fn ($path) => (bool) preg_match('/'.str_replace('/', '\/', $matching).'/', $path))->values(); + } + + $paths->each(fn ($path) => $this->emit($path)); + $this->emit($paths->count().' file(s)'); + + $this->payload = ['files' => $paths->all(), 'count' => $paths->count()]; + + return self::SUCCESS; + } + + protected function query(string $directory, string $type) + { + $query = LaravelFile::in($directory); + + $query = match ($type) { + 'models' => $query->models(), + 'controllers' => $query->controllers(), + 'providers' => $query->serviceProviders(), + default => $query, + }; + + if ($extends = $this->option('extends')) { + $query = $query->where('extends', $extends); + } + + if ($implements = $this->option('implements')) { + $query = $query->where('implements', 'contains', $implements); + } + + if ($trait = $this->option('uses-trait')) { + $query = $query->where('useTrait', 'contains', $trait); + } + + return $query->get(); + } +} diff --git a/src/Console/Commands/HelpCommand.php b/src/Console/Commands/HelpCommand.php new file mode 100644 index 0000000..63ba44b --- /dev/null +++ b/src/Console/Commands/HelpCommand.php @@ -0,0 +1,45 @@ +emit('archetype [arguments] [options]'); + $this->emit(''); + + foreach (Manifest::lines() as $line) { + $this->emit($line); + } + + $this->emit(''); + $this->emit(' is a path (app/Models/User.php), a class name (App\\Models\\User),'); + $this->emit('or a directory (app/Models) to apply the same change to every class beneath it,'); + $this->emit('narrowed with --extends, --implements, --uses-trait or --matching.'); + $this->emit(''); + $this->emit('Every operation takes --json. Mutations take --dry-run and --no-diff,'); + $this->emit('answer with a diff, skip work already done, and exit non-zero if they'); + $this->emit('could not do what was asked.'); + + $this->payload = [ + 'operations' => collect(Manifest::OPERATIONS) + ->map(fn ($operation, $name) => [ + 'operation' => $name, + 'usage' => $operation[1], + 'description' => $operation[2], + ]) + ->values() + ->all(), + ]; + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/InspectCommand.php b/src/Console/Commands/InspectCommand.php new file mode 100644 index 0000000..72cbba2 --- /dev/null +++ b/src/Console/Commands/InspectCommand.php @@ -0,0 +1,167 @@ +sections(); + $files = []; + + foreach ($this->targets() as $path) { + $files[] = $this->describe($path, LaravelFile::load($path), $sections); + } + + // A directory target always answers with a collection, even when it + // matched one file, so the shape is a property of the question rather + // than of the answer. + $this->payload = Target::isDirectory($this->argument('target')) + ? ['files' => $files, 'count' => count($files)] + : $files[0]; + + return self::SUCCESS; + } + + /** @return array */ + protected function sections(): array + { + $sections = $this->argument('sections') ?: self::SECTIONS; + + foreach ($sections as $section) { + if (! in_array($section, self::SECTIONS, true)) { + throw new InvalidArgumentException( + "unknown section '$section' — one of ".implode(', ', self::SECTIONS) + ); + } + } + + return $sections; + } + + /** @return array */ + protected function describe(string $path, PHPFile $file, array $sections): array + { + $scope = new Introspector($file); + $data = ['file' => $path]; + + $this->emit($path); + + if (in_array('meta', $sections, true)) { + $data += [ + 'kind' => $scope->kind(), + 'namespace' => (string) $file->namespace(), + 'name' => $scope->name(), + 'extends' => $scope->extends(), + 'implements' => $scope->implements(), + ]; + + $this->emit(trim(sprintf( + '%s %s%s%s', + $data['kind'], + $data['namespace'] ? $data['namespace'].'\\'.$data['name'] : $data['name'], + $data['extends'] ? ' extends '.implode(', ', $data['extends']) : '', + $data['implements'] ? ' implements '.implode(', ', $data['implements']) : '' + ))); + } + + if (in_array('traits', $sections, true)) { + $data['traits'] = $file->useTrait(); + + if ($data['traits']) { + $this->emit('uses '.implode(', ', $data['traits'])); + } + } + + if (in_array('uses', $sections, true)) { + $data['imports'] = $file->use(); + + foreach ($data['imports'] as $import) { + $this->emit("import $import"); + } + } + + if (in_array('consts', $sections, true)) { + $data['constants'] = $scope->constants(); + + foreach ($data['constants'] as $constant) { + $this->emit('const '.$constant['name'].' = '.$this->literal($constant)); + } + } + + if (in_array('cases', $sections, true)) { + $data['cases'] = $scope->cases(); + + foreach ($data['cases'] as $case) { + $this->emit(trim('case '.$case['name'].($case['value'] === null ? '' : ' = '.$this->literal($case)))); + } + } + + if (in_array('props', $sections, true)) { + $data['properties'] = $scope->properties(); + + foreach ($data['properties'] as $property) { + $this->emit(sprintf( + 'prop %s%s $%s = %s', + $property['visibility'], + $property['static'] ? ' static' : '', + $property['name'], + $this->literal($property) + )); + } + } + + if (in_array('methods', $sections, true)) { + $data['methods'] = $scope->methods(); + + foreach ($data['methods'] as $method) { + $this->emit(sprintf( + 'fn %s%s %s(%s)%s [%d lines]', + $method['visibility'], + $method['static'] ? ' static' : '', + $method['name'], + $method['params'], + $method['returns'] ? ': '.$method['returns'] : '', + $method['lines'] + )); + } + } + + if (in_array('relations', $sections, true)) { + $data['relations'] = $scope->relations(); + + foreach ($data['relations'] as $relation) { + $this->emit(sprintf('rel %s %s %s', $relation['name'], $relation['type'], $relation['target'] ?? '?')); + } + } + + return $data; + } + + /** `?` rather than a wrong value when the declaration is not a constant expression. */ + protected function literal(array $entry): string + { + return $entry['evaluated'] ? json_encode($entry['value'], JSON_UNESCAPED_SLASHES) : '?'; + } +} diff --git a/src/Console/Commands/MakeCommand.php b/src/Console/Commands/MakeCommand.php new file mode 100644 index 0000000..1a16851 --- /dev/null +++ b/src/Console/Commands/MakeCommand.php @@ -0,0 +1,75 @@ +argument('name'); + $path = URI::make($name)->path(); + + if (is_file(base_path($path)) && ! $this->option('force')) { + throw new RuntimeException("$path already exists — pass --force to overwrite it"); + } + + $file = $this->option('file') + ? LaravelFile::make()->file($name) + : LaravelFile::make()->class($name); + + if ($parent = $this->option('extends')) { + $this->importInto($file, [$parent]); + $file->extends(class_basename($parent)); + } + + if ($interfaces = $this->option('implements')) { + $this->importInto($file, $interfaces); + $file->add()->implements(array_map(fn ($name) => class_basename($name), $interfaces)); + } + + if ($traits = $this->option('trait')) { + $this->importInto($file, $traits); + $file->add()->useTrait(array_map(fn ($name) => class_basename($name), $traits)); + } + + $source = $file->render(); + $file->save(); + + $this->emit("OK $path created"); + $this->emit($source); + + $this->payload = ['ok' => true, 'file' => $path, 'source' => $source]; + + return self::SUCCESS; + } + + protected function importInto($file, array $names): void + { + $namespace = (string) $file->namespace(); + + $needed = array_values(array_filter($names, function ($name) use ($namespace) { + $own = trim(implode('\\', array_slice(explode('\\', $name), 0, -1)), '\\'); + + return $own !== '' && $own !== $namespace; + })); + + if ($needed) { + $file->add()->use($needed); + } + } +} diff --git a/src/Console/Commands/RemoveConstCommand.php b/src/Console/Commands/RemoveConstCommand.php new file mode 100644 index 0000000..5fc79f6 --- /dev/null +++ b/src/Console/Commands/RemoveConstCommand.php @@ -0,0 +1,34 @@ +argument('name'); + + return $this->mutate(function (LaravelFile $file) use ($name) { + $present = collect((new Introspector($file))->constants()) + ->contains(fn ($constant) => $constant['name'] === $name); + + if (! $present) { + return $this->unchanged("no const $name"); + } + + $file->remove()->classConstant($name); + + return "const $name removed"; + }); + } +} diff --git a/src/Console/Commands/RemoveMethodCommand.php b/src/Console/Commands/RemoveMethodCommand.php new file mode 100644 index 0000000..17064a8 --- /dev/null +++ b/src/Console/Commands/RemoveMethodCommand.php @@ -0,0 +1,35 @@ +argument('name'); + + return $this->mutate(function (LaravelFile $file) use ($name) { + if (! in_array($name, $file->methodNames(), true)) { + return $this->unchanged("no fn $name"); + } + + $file->astQuery() + ->classMethod() + ->where('name->name', $name) + ->remove() + ->commit() + ->end(); + + return "fn $name removed"; + }); + } +} diff --git a/src/Console/Commands/RemovePropertyCommand.php b/src/Console/Commands/RemovePropertyCommand.php new file mode 100644 index 0000000..1e1c2c0 --- /dev/null +++ b/src/Console/Commands/RemovePropertyCommand.php @@ -0,0 +1,31 @@ +argument('name'); + + return $this->mutate(function (LaravelFile $file) use ($name) { + if (! (new Introspector($file))->hasProperty($name)) { + return $this->unchanged("no \$$name"); + } + + $file->remove()->property($name); + + return "\$$name removed"; + }); + } +} diff --git a/src/Console/Commands/RemoveUseCommand.php b/src/Console/Commands/RemoveUseCommand.php new file mode 100644 index 0000000..0cd7dcf --- /dev/null +++ b/src/Console/Commands/RemoveUseCommand.php @@ -0,0 +1,33 @@ +argument('imports'); + + return $this->mutate(function (LaravelFile $file) use ($imports) { + $existing = $file->use(); + $keep = array_values(array_diff($existing, $imports)); + + if (count($keep) === count($existing)) { + return $this->unchanged('imports unchanged'); + } + + $file->use($keep); + + return 'import -'.(count($existing) - count($keep)); + }); + } +} diff --git a/src/Console/Commands/RenameClassCommand.php b/src/Console/Commands/RenameClassCommand.php new file mode 100644 index 0000000..bcfaa5d --- /dev/null +++ b/src/Console/Commands/RenameClassCommand.php @@ -0,0 +1,36 @@ +argument('name'); + + return $this->mutate(function (LaravelFile $file) use ($name) { + if ((new Introspector($file))->name() === $name) { + return $this->unchanged('class name unchanged'); + } + + $file->className($name); + + return "class $name"; + }); + } +} diff --git a/src/Console/Commands/ReplaceMethodCommand.php b/src/Console/Commands/ReplaceMethodCommand.php new file mode 100644 index 0000000..8099726 --- /dev/null +++ b/src/Console/Commands/ReplaceMethodCommand.php @@ -0,0 +1,45 @@ +argument('name'); + $code = $this->option('code'); + + if (! $code) { + throw new InvalidArgumentException('--code is required'); + } + + $method = Code::method($code); + + return $this->mutate(function (LaravelFile $file) use ($name, $method) { + if (! in_array($name, $file->methodNames(), true)) { + return $this->unchanged("no fn $name"); + } + + $file->astQuery() + ->classMethod() + ->where('name->name', $name) + ->replace(Code::copy($method)) + ->commit() + ->end(); + + return "fn $name replaced"; + }); + } +} diff --git a/src/Console/Commands/SetArrayKeyCommand.php b/src/Console/Commands/SetArrayKeyCommand.php new file mode 100644 index 0000000..587d852 --- /dev/null +++ b/src/Console/Commands/SetArrayKeyCommand.php @@ -0,0 +1,71 @@ +argument('method'); + $key = $this->argument('key'); + $value = $this->argument('value'); + $remove = $this->option('remove'); + $append = $this->option('append'); + + if (! $remove && $value === null) { + throw new InvalidArgumentException('a value is required unless --remove is given'); + } + + return $this->mutate(function (LaravelFile $file) use ($method, $key, $value, $remove, $append) { + $array = ArrayLiteral::returnedBy($file, $method); + + if (! $array) { + throw new RuntimeException("$method() does not return an array literal"); + } + + if ($remove) { + return ArrayLiteral::remove($array, $key) + ? "$method()[$key] removed" + : $this->unchanged("no $method()[$key]"); + } + + if ($append) { + return ArrayLiteral::append($array, Code::literal($value)) + ? "$method() +1" + : $this->unchanged("$method() unchanged"); + } + + $outcome = ArrayLiteral::set($array, $key, Code::literal($value)); + + return $outcome === 'unchanged' + ? $this->unchanged("$method()[$key] unchanged") + : "$method()[$key] $outcome"; + }); + } +} diff --git a/src/Console/Commands/SetCastsCommand.php b/src/Console/Commands/SetCastsCommand.php new file mode 100644 index 0000000..ba0c50d --- /dev/null +++ b/src/Console/Commands/SetCastsCommand.php @@ -0,0 +1,107 @@ +casts(); + + return $this->mutate(function (LaravelFile $file) use ($casts) { + $this->requirePropertyHolder($file); + + [$array, $where] = $this->literal($file); + + $counts = ['added' => 0, 'updated' => 0, 'unchanged' => 0]; + + foreach ($casts as $field => $cast) { + $counts[ArrayLiteral::set($array, $field, $cast)]++; + } + + if ($counts['added'] === 0 && $counts['updated'] === 0) { + return $this->unchanged('casts unchanged'); + } + + return sprintf( + 'casts +%d ~%d in %s', + $counts['added'], + $counts['updated'], + $where + ); + }); + } + + /** @return array */ + protected function casts(): array + { + $casts = []; + + foreach ($this->argument('casts') as $pair) { + if (! str_contains($pair, '=')) { + throw new InvalidArgumentException("expected field=cast, got '$pair'"); + } + + [$field, $cast] = explode('=', $pair, 2); + + $casts[$field] = Code::literal($cast); + } + + return $casts; + } + + /** + * Find the array the model actually casts through, creating one if needed. + * + * @return array{0: Node\Expr\Array_, 1: string} + */ + protected function literal(LaravelFile $file): array + { + if ((new Introspector($file))->method('casts')) { + $array = ArrayLiteral::returnedBy($file, 'casts'); + + if (! $array) { + throw new InvalidArgumentException('casts() does not return an array literal directly'); + } + + return [$array, 'casts()']; + } + + if ($array = ArrayLiteral::defaultOf($file, 'casts')) { + return [$array, '$casts']; + } + + $file->assumeType('array')->protected()->property('casts', []); + + $array = ArrayLiteral::defaultOf($file, 'casts'); + + if (! $array) { + throw new \RuntimeException('could not create a $casts property'); + } + + return [$array, '$casts']; + } +} diff --git a/src/Console/Commands/SetConstCommand.php b/src/Console/Commands/SetConstCommand.php new file mode 100644 index 0000000..b17a5e1 --- /dev/null +++ b/src/Console/Commands/SetConstCommand.php @@ -0,0 +1,38 @@ +argument('name'); + $raw = $this->argument('value'); + + return $this->mutate(function (LaravelFile $file) use ($name, $raw) { + foreach ((new Introspector($file))->constants() as $constant) { + if ($constant['name'] === $name && $constant['evaluated'] && $constant['value'] === Code::value($raw)) { + return $this->unchanged("$name unchanged"); + } + } + + $raw === null + ? $file->setClassConstant($name) + : $file->classConstant($name, Code::value($raw)); + + return "const $name"; + }); + } +} diff --git a/src/Console/Commands/SetExtendsCommand.php b/src/Console/Commands/SetExtendsCommand.php new file mode 100644 index 0000000..c86c075 --- /dev/null +++ b/src/Console/Commands/SetExtendsCommand.php @@ -0,0 +1,32 @@ +argument('parent'); + + return $this->mutate(function (LaravelFile $file) use ($parent) { + if ($file->extends() === class_basename($parent)) { + return $this->unchanged('extends unchanged'); + } + + $imported = $this->import($file, [$parent]); + + $file->extends(class_basename($parent)); + + return 'extends '.class_basename($parent).($imported ? ' (+use)' : ''); + }); + } +} diff --git a/src/Console/Commands/SetNamespaceCommand.php b/src/Console/Commands/SetNamespaceCommand.php new file mode 100644 index 0000000..9a681c2 --- /dev/null +++ b/src/Console/Commands/SetNamespaceCommand.php @@ -0,0 +1,30 @@ +argument('namespace'); + + return $this->mutate(function (LaravelFile $file) use ($namespace) { + if ((string) $file->namespace() === $namespace) { + return $this->unchanged('namespace unchanged'); + } + + $file->namespace($namespace); + + return "namespace $namespace"; + }); + } +} diff --git a/src/Console/Commands/SetPropertyCommand.php b/src/Console/Commands/SetPropertyCommand.php new file mode 100644 index 0000000..59cf57f --- /dev/null +++ b/src/Console/Commands/SetPropertyCommand.php @@ -0,0 +1,56 @@ +argument('name'); + $raw = $this->argument('value'); + + return $this->mutate(function (LaravelFile $file) use ($name, $raw) { + $this->requirePropertyHolder($file); + + $visibility = $this->visibilityOf($file, $name, $this->option('visibility')); + + if ($this->alreadySet($file, $name, $raw, $visibility)) { + return $this->unchanged("\$$name unchanged"); + } + + $raw === null + ? $file->{$visibility}()->setProperty($name) + : $file->{$visibility}()->property($name, Code::value($raw)); + + return "\$$name set"; + }); + } + + protected function alreadySet(LaravelFile $file, string $name, ?string $raw, string $visibility): bool + { + foreach ((new Introspector($file))->properties() as $property) { + if ($property['name'] !== $name) { + continue; + } + + return $property['visibility'] === $visibility + && $property['evaluated'] + && $property['value'] === ($raw === null ? null : Code::value($raw)); + } + + return false; + } +} diff --git a/src/Console/Commands/ShowCommand.php b/src/Console/Commands/ShowCommand.php new file mode 100644 index 0000000..368e034 --- /dev/null +++ b/src/Console/Commands/ShowCommand.php @@ -0,0 +1,54 @@ +argument('method'); + $found = []; + + foreach ($this->targets() as $path) { + $file = LaravelFile::load($path); + $node = (new Introspector($file))->method($method); + + if (! $node) { + continue; + } + + $source = Code::source($file, $node); + $found[] = ['file' => $path, 'method' => $method, 'source' => $source]; + + $this->emit("$path::$method"); + $this->emit($source); + } + + if (! $found) { + throw new RuntimeException("no method '$method' in ".$this->argument('target')); + } + + $this->payload = count($found) === 1 ? $found[0] : ['matches' => $found, 'count' => count($found)]; + + return self::SUCCESS; + } +} diff --git a/src/Console/MutationCommand.php b/src/Console/MutationCommand.php new file mode 100644 index 0000000..127c26b --- /dev/null +++ b/src/Console/MutationCommand.php @@ -0,0 +1,215 @@ +targets(); + $differ = new Diff; + $results = []; + + $this->changed = $this->skipped = $this->failed = 0; + + foreach ($targets as $path) { + $results[] = $this->mutateOne($path, $work, $differ); + } + + if (count($targets) > 1) { + $this->emit(sprintf( + '%d changed, %d unchanged, %d failed of %d files', + $this->changed, $this->skipped, $this->failed, count($targets) + )); + } + + $this->payload = [ + 'ok' => $this->failed === 0, + 'dryRun' => (bool) $this->option('dry-run'), + 'changed' => $this->changed, + 'skipped' => $this->skipped, + 'failed' => $this->failed, + 'results' => $results, + ]; + + return $this->failed === 0 ? self::SUCCESS : self::FAILURE; + } + + /** Report that the file was already in the desired state. */ + protected function unchanged(string $message): array + { + return ['__unchanged' => true, 'message' => $message]; + } + + /** @return array */ + protected function mutateOne(string $path, callable $work, Diff $differ): array + { + try { + $file = LaravelFile::load($path); + $before = $file->render(); + + $outcome = $work($file, $path); + + $skipped = is_array($outcome) && ($outcome['__unchanged'] ?? false); + $detail = $skipped ? $outcome['message'] : (string) $outcome; + + $after = $file->render(); + + if ($before === $after) { + return $skipped + ? $this->report('SKIP', $path, $detail) + : $this->report('ERR', $path, "$detail — but the file did not change"); + } + + if (! $this->option('dry-run')) { + $file->save(); + } + + return $this->report( + $this->option('dry-run') ? 'DRY' : 'OK', + $path, + $detail, + $this->option('no-diff') ? '' : $differ->render($before, $after) + ); + } catch (Throwable $exception) { + return $this->report('ERR', $path, $exception->getMessage()); + } + } + + /** @return array */ + protected function report(string $status, string $path, string $detail, string $diff = ''): array + { + match ($status) { + 'SKIP' => $this->skipped++, + 'ERR' => $this->failed++, + default => $this->changed++, + }; + + $this->emit(trim("$status $path $detail")); + + foreach ($diff === '' ? [] : explode("\n", $diff) as $line) { + $this->emit($line); + } + + return array_filter([ + 'file' => $path, + 'status' => ['OK' => 'changed', 'DRY' => 'would-change', 'SKIP' => 'unchanged', 'ERR' => 'error'][$status], + 'detail' => $detail, + 'diff' => $diff, + ], fn ($value) => $value !== ''); + } + + /** + * Refuse a property write on a construct that cannot hold one. + * + * The endpoints match any class-like now, so an enum or an interface would + * otherwise accept a property and produce a file PHP cannot parse. + */ + protected function requirePropertyHolder(File $file): void + { + $kind = (new Introspector($file))->kind(); + + if (in_array($kind, ['enum', 'interface'], true)) { + throw new InvalidArgumentException( + ($kind === 'enum' ? 'an' : 'a')." $kind cannot have properties" + ); + } + } + + /** + * The visibility a property write should use. + * + * The property endpoint rewrites the modifiers on every set, defaulting to + * public, so an operation that says nothing about visibility would quietly + * widen a protected property. Keeping the one it already has means only an + * explicit --visibility ever changes it. + */ + protected function visibilityOf(File $file, string $property, ?string $override = null): string + { + if ($override) { + if (! in_array($override, ['public', 'protected', 'private'], true)) { + throw new InvalidArgumentException( + "--visibility must be public, protected or private, got '$override'" + ); + } + + return $override; + } + + foreach ((new Introspector($file))->properties() as $existing) { + if ($existing['name'] === $property) { + return $existing['visibility']; + } + } + + return 'protected'; + } + + /** + * Import every fully qualified name not already imported and not already in + * this file's own namespace. A trait or interface referenced without its + * import is never valid PHP, so importing is part of the operation rather + * than a second call the caller has to remember. + */ + protected function import(File $file, array $names): int + { + $namespace = (string) $file->namespace(); + $existing = $file->use(); + + $needed = array_values(array_filter($names, function ($name) use ($namespace, $existing) { + $own = trim(implode('\\', array_slice(explode('\\', $name), 0, -1)), '\\'); + + return $own !== '' && $own !== $namespace && ! in_array($name, $existing, true); + })); + + if ($needed) { + $file->add()->use($needed); + } + + return count($needed); + } + + /** @return array */ + protected function sharedOptions(): array + { + return array_merge(parent::sharedOptions(), [ + new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would change without writing'), + new InputOption('no-diff', null, InputOption::VALUE_NONE, 'Suppress the diff a mutation normally answers with'), + ]); + } +} diff --git a/src/Console/Support/ArrayLiteral.php b/src/Console/Support/ArrayLiteral.php new file mode 100644 index 0000000..a7ffd83 --- /dev/null +++ b/src/Console/Support/ArrayLiteral.php @@ -0,0 +1,168 @@ +method($method); + + if (! $node) { + return null; + } + + foreach ($node->stmts ?? [] as $statement) { + if ($statement instanceof Node\Stmt\Return_ && $statement->expr instanceof Node\Expr\Array_) { + return $statement->expr; + } + } + + return static::nestedReturn($node); + } + + /** The array literal a property is initialised with, or null when it is not an array. */ + public static function defaultOf(PHPFile $file, string $property): ?Node\Expr\Array_ + { + foreach ((new NodeFinder)->findInstanceOf($file->ast(), Node\Stmt\Property::class) as $node) { + foreach ($node->props as $prop) { + if ($prop->name->name === $property && $prop->default instanceof Node\Expr\Array_) { + return $prop->default; + } + } + } + + return null; + } + + /** + * Set `$key` to `$value`, appending when the key is absent. + * + * @return string one of added|updated|unchanged + */ + public static function set(Node\Expr\Array_ $array, string $key, Node\Expr $value): string + { + foreach ($array->items as $item) { + if ($item instanceof Node\ArrayItem && static::keyOf($item) === $key) { + if (static::print($item->value) === static::print($value)) { + return 'unchanged'; + } + + $item->value = $value; + + return 'updated'; + } + } + + static::keepMultiline($array); + + $array->items[] = new Node\ArrayItem($value, new Node\Scalar\String_($key)); + + return 'added'; + } + + /** Append a value with no key. Returns false when an identical value is already present. */ + public static function append(Node\Expr\Array_ $array, Node\Expr $value): bool + { + foreach ($array->items as $item) { + if ($item instanceof Node\ArrayItem && $item->key === null && static::print($item->value) === static::print($value)) { + return false; + } + } + + static::keepMultiline($array); + + $array->items[] = new Node\ArrayItem($value); + + return true; + } + + /** + * Keep a one-per-line array one-per-line. + * + * php-parser only calls a list multiline when it can see a newline between + * two items, so an array holding a single item gets the new one appended on + * the same line. Dropping the node's formatting makes the printer lay the + * whole array out again in the style the rest of the file uses. + */ + protected static function keepMultiline(Node\Expr\Array_ $array): void + { + $spansLines = $array->getStartLine() !== $array->getEndLine(); + + if ($spansLines && count($array->items) < 2) { + FormattingRemover::on($array); + } + } + + public static function remove(Node\Expr\Array_ $array, string $key): bool + { + $kept = array_values(array_filter( + $array->items, + fn ($item) => ! ($item instanceof Node\ArrayItem && static::keyOf($item) === $key) + )); + + if (count($kept) === count($array->items)) { + return false; + } + + $array->items = $kept; + + return true; + } + + public static function keyOf(Node\ArrayItem $item): ?string + { + return $item->key instanceof Node\Scalar\String_ ? $item->key->value : null; + } + + public static function print(Node\Expr $node): string + { + return (new PSR2PrettyPrinter)->prettyPrintExpr($node); + } + + /** + * A `return [...]` somewhere inside the method but not inside a closure — + * an early return in a conditional, typically. + */ + protected static function nestedReturn(Node\Stmt\ClassMethod $method): ?Node\Expr\Array_ + { + $finder = new NodeFinder; + + $closures = collect($finder->find($method->stmts ?? [], fn (Node $node) => $node instanceof Node\Expr\Closure + || $node instanceof Node\Expr\ArrowFunction + || $node instanceof Node\Stmt\Function_)); + + foreach ($finder->findInstanceOf($method->stmts ?? [], Node\Stmt\Return_::class) as $return) { + if (! $return->expr instanceof Node\Expr\Array_) { + continue; + } + + $nested = $closures->contains(fn (Node $closure) => $return->getStartLine() >= $closure->getStartLine() + && $return->getEndLine() <= $closure->getEndLine()); + + if (! $nested) { + return $return->expr; + } + } + + return null; + } +} diff --git a/src/Console/Support/Code.php b/src/Console/Support/Code.php new file mode 100644 index 0000000..dd38b34 --- /dev/null +++ b/src/Console/Support/Code.php @@ -0,0 +1,137 @@ +findFirstInstanceOf( + static::parse('class __ArchetypeScratch {'.PHP_EOL.static::stripTag($code).PHP_EOL.'}'), + Node\Stmt\ClassMethod::class + ); + + if (! $method) { + throw new InvalidArgumentException('could not parse a method declaration from the given code'); + } + + return FormattingRemover::on($method); + } + + /** + * Parse a value given on the command line as a PHP expression. + * + * This is what lets a caller write `'nullable|max:255'`, `['required']`, + * `$this->budget_cents` or `Status::Active` in the same argument slot — + * anything PHP itself accepts on the right of an assignment. + */ + public static function expression(string $value): Node\Expr + { + $statements = static::parse('$__archetype = '.static::stripTag($value).';'); + $expression = $statements[0] ?? null; + + if (! $expression instanceof Node\Stmt\Expression || ! $expression->expr instanceof Node\Expr\Assign) { + throw new InvalidArgumentException("could not parse '$value' as a PHP expression"); + } + + return FormattingRemover::on($expression->expr->expr); + } + + /** + * Parse a value the way a caller most likely meant it. + * + * `expression()` alone is not usable from a command line: `nullable|date` + * is a perfectly valid PHP expression — a bitwise or of two constants — and + * that is never what someone typing a validation rule meant. So a bare word + * is a string, and PHP is only assumed where the text announces it: a + * bracket, a quote, a variable, a call, a class constant, a number or a + * boolean. + */ + public static function literal(string $value): Node\Expr + { + return static::looksLikePhp($value) + ? static::expression($value) + : new Node\Scalar\String_($value); + } + + protected static function looksLikePhp(string $value): bool + { + $value = trim($value); + + if ($value === '') { + return false; + } + + return (bool) preg_match('/^[\[\(\\\\\'"$\-]/', $value) + || is_numeric($value) + || in_array(strtolower($value), ['true', 'false', 'null'], true) + || (bool) preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]*\s*(::|\()/', $value); + } + + /** + * Decode a value as JSON when it is valid JSON, otherwise keep the string. + * + * Used where the endpoint wants a PHP value rather than an AST node, so + * `'["a","b"]'` sets an array and `gdpr_users` sets a string. + */ + public static function value(?string $raw) + { + if ($raw === null) { + return null; + } + + $decoded = json_decode($raw, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $raw; + } + + /** + * A fresh node per file. + * + * Inserting one node object into several ASTs would alias them, so a + * directory-wide mutation must hand each file its own copy. + */ + public static function copy(Node $node): Node + { + return FormattingRemover::on(unserialize(serialize($node))); + } + + /** The source of one method, exactly as written, doc block included. */ + public static function source(PHPFile $file, Node\Stmt\ClassMethod $method): string + { + $lines = explode("\n", $file->contents()); + + $comments = $method->getComments(); + $start = $comments ? $comments[0]->getStartLine() : $method->getStartLine(); + + return implode("\n", array_slice($lines, $start - 1, $method->getEndLine() - $start + 1)); + } + + /** @return array */ + protected static function parse(string $code): array + { + try { + return (new ParserFactory)->createForNewestSupportedVersion()->parse('getRawMessage()); + } + } + + protected static function stripTag(string $code): string + { + return preg_replace('/^<\?php\s*/', '', trim($code)); + } +} diff --git a/src/Console/Support/Diff.php b/src/Console/Support/Diff.php new file mode 100644 index 0000000..5ea5bbd --- /dev/null +++ b/src/Console/Support/Diff.php @@ -0,0 +1,147 @@ +hunks(explode("\n", $before), explode("\n", $after)); + + if (! $hunks) { + return ''; + } + + $out = []; + $budget = $this->maxLines; + + foreach ($hunks as $hunk) { + if ($budget <= 0) { + $out[] = ' … more changes not shown'; + break; + } + + $out[] = sprintf('@@ %d @@', $hunk['line']); + + foreach ($hunk['lines'] as $line) { + if ($budget-- <= 0) { + $out[] = ' …'; + break; + } + + $out[] = $line; + } + } + + return implode("\n", $out); + } + + /** @return array}> */ + protected function hunks(array $a, array $b): array + { + $hunks = []; + $current = null; + $gap = 0; + + foreach ($this->ops($a, $b) as [$kind, $line, $index]) { + if ($kind === ' ') { + if ($current === null) { + continue; + } + + if (++$gap > $this->context) { + $hunks[] = $current; + $current = null; + $gap = 0; + + continue; + } + + $current['lines'][] = ' '.$line; + + continue; + } + + if ($current === null) { + $current = ['line' => $index + 1, 'lines' => []]; + } + + $gap = 0; + $current['lines'][] = $kind.' '.$line; + } + + if ($current !== null) { + $hunks[] = $current; + } + + return $hunks; + } + + /** + * Classic LCS diff. The inputs are single PHP classes, so the quadratic + * table is a few thousand cells at worst. + * + * @return array + */ + protected function ops(array $a, array $b): array + { + $n = count($a); + $m = count($b); + + $lcs = array_fill(0, $n + 1, array_fill(0, $m + 1, 0)); + + for ($i = $n - 1; $i >= 0; $i--) { + for ($j = $m - 1; $j >= 0; $j--) { + $lcs[$i][$j] = $a[$i] === $b[$j] + ? $lcs[$i + 1][$j + 1] + 1 + : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]); + } + } + + $ops = []; + $i = $j = 0; + + while ($i < $n && $j < $m) { + if ($a[$i] === $b[$j]) { + $ops[] = [' ', $a[$i], $i]; + $i++; + $j++; + } elseif ($lcs[$i + 1][$j] >= $lcs[$i][$j + 1]) { + $ops[] = ['-', $a[$i], $i]; + $i++; + } else { + $ops[] = ['+', $b[$j], $i]; + $j++; + } + } + + while ($i < $n) { + $ops[] = ['-', $a[$i], $i]; + $i++; + } + + while ($j < $m) { + $ops[] = ['+', $b[$j], $i]; + $j++; + } + + return $ops; + } +} diff --git a/src/Console/Support/Introspector.php b/src/Console/Support/Introspector.php new file mode 100644 index 0000000..471149e --- /dev/null +++ b/src/Console/Support/Introspector.php @@ -0,0 +1,276 @@ + */ + public function methods(): array + { + return collect($this->find(Node\Stmt\ClassMethod::class)) + ->map(fn (Node\Stmt\ClassMethod $method) => [ + 'name' => $method->name->name, + 'visibility' => $this->visibility($method), + 'static' => $method->isStatic(), + 'abstract' => $method->isAbstract(), + 'params' => collect($method->params)->map(fn ($p) => $this->param($p))->join(', '), + 'returns' => $method->returnType ? $this->type($method->returnType) : null, + 'lines' => $method->getEndLine() - $method->getStartLine() + 1, + ])->values()->all(); + } + + public function method(string $name): ?Node\Stmt\ClassMethod + { + foreach ($this->find(Node\Stmt\ClassMethod::class) as $method) { + if ($method->name->name === $name) { + return $method; + } + } + + return null; + } + + /** @return array */ + public function properties(): array + { + $out = []; + + foreach ($this->find(Node\Stmt\Property::class) as $property) { + foreach ($property->props as $prop) { + [$value, $evaluated] = $this->evaluate($prop->default); + + $out[] = [ + 'name' => $prop->name->name, + 'visibility' => $this->visibility($property), + 'static' => $property->isStatic(), + 'value' => $value, + 'evaluated' => $evaluated, + ]; + } + } + + return $out; + } + + public function hasProperty(string $name): bool + { + return collect($this->properties())->contains(fn ($property) => $property['name'] === $name); + } + + /** @return array */ + public function relations(): array + { + $out = []; + + foreach ($this->find(Node\Stmt\ClassMethod::class) as $method) { + $calls = (new NodeFinder)->find($method->stmts ?? [], function (Node $node) { + return $node instanceof Node\Expr\MethodCall + && $node->var instanceof Node\Expr\Variable + && $node->var->name === 'this' + && $node->name instanceof Node\Identifier + && in_array($node->name->name, self::RELATION_METHODS, true); + }); + + foreach ($calls as $call) { + $out[] = [ + 'name' => $method->name->name, + 'type' => $call->name->name, + 'target' => $this->firstArgClass($call), + ]; + } + } + + return $out; + } + + /** @return array */ + public function constants(): array + { + $out = []; + + foreach ($this->find(Node\Stmt\ClassConst::class) as $const) { + foreach ($const->consts as $one) { + [$value, $evaluated] = $this->evaluate($one->value); + + $out[] = ['name' => $one->name->name, 'value' => $value, 'evaluated' => $evaluated]; + } + } + + return $out; + } + + /** @return array Enum cases, when the file declares an enum. */ + public function cases(): array + { + $out = []; + + foreach ($this->find(Node\Stmt\EnumCase::class) as $case) { + [$value, $evaluated] = $this->evaluate($case->expr); + + $out[] = [ + 'name' => $case->name->name, + 'value' => $case->expr ? $value : null, + 'evaluated' => $case->expr ? $evaluated : true, + ]; + } + + return $out; + } + + public function hasCase(string $name): bool + { + return collect($this->cases())->contains(fn ($case) => $case['name'] === $name); + } + + /** The declared class/enum/interface/trait name, whatever the construct. */ + public function name(): ?string + { + $node = $this->classLike(); + + return $node && $node->name ? $node->name->name : null; + } + + public function classLike(): ?Node\Stmt\ClassLike + { + return (new NodeFinder)->findFirstInstanceOf($this->file->ast(), Node\Stmt\ClassLike::class); + } + + public function kind(): string + { + return match (true) { + $this->classLike() instanceof Node\Stmt\Enum_ => 'enum', + $this->classLike() instanceof Node\Stmt\Interface_ => 'interface', + $this->classLike() instanceof Node\Stmt\Trait_ => 'trait', + $this->classLike() instanceof Node\Stmt\Class_ => 'class', + default => 'file', + }; + } + + /** Interfaces may extend several parents, so this is always a list. */ + public function extends(): array + { + $node = $this->classLike(); + + if ($node instanceof Node\Stmt\Class_) { + return $node->extends ? [$node->extends->toString()] : []; + } + + if ($node instanceof Node\Stmt\Interface_) { + return collect($node->extends)->map(fn (Node\Name $name) => $name->toString())->all(); + } + + return []; + } + + public function implements(): array + { + $node = $this->classLike(); + + $names = match (true) { + $node instanceof Node\Stmt\Class_ => $node->implements, + $node instanceof Node\Stmt\Enum_ => $node->implements, + default => [], + }; + + return collect($names)->map(fn (Node\Name $name) => $name->toString())->all(); + } + + /** @return array */ + protected function find(string $class): array + { + return (new NodeFinder)->findInstanceOf($this->file->ast(), $class); + } + + protected function visibility(Node\Stmt\ClassMethod|Node\Stmt\Property $node): string + { + return match (true) { + $node->isPrivate() => 'private', + $node->isProtected() => 'protected', + default => 'public', + }; + } + + protected function firstArgClass(Node\Expr\MethodCall $call): ?string + { + $arg = $call->args[0] ?? null; + + if (! $arg instanceof Node\Arg) { + return null; + } + + if ($arg->value instanceof Node\Expr\ClassConstFetch && $arg->value->class instanceof Node\Name) { + return $arg->value->class->toString(); + } + + if ($arg->value instanceof Node\Scalar\String_) { + return $arg->value->value; + } + + return null; + } + + protected function param(Node\Param $param): string + { + $type = $param->type ? $this->type($param->type).' ' : ''; + $name = $param->var instanceof Node\Expr\Variable ? '$'.$param->var->name : '$?'; + + if ($param->default) { + [$value, $evaluated] = $this->evaluate($param->default); + $name .= ' = '.($evaluated ? json_encode($value) : '?'); + } + + return $type.$name; + } + + protected function type($type): string + { + return match (true) { + $type instanceof Node\NullableType => '?'.$this->type($type->type), + $type instanceof Node\UnionType => collect($type->types)->map(fn ($t) => $this->type($t))->join('|'), + $type instanceof Node\IntersectionType => collect($type->types)->map(fn ($t) => $this->type($t))->join('&'), + $type instanceof Node\Name => $type->toString(), + $type instanceof Node\Identifier => $type->name, + default => 'mixed', + }; + } + + /** @return array{0: mixed, 1: bool} the value, and whether it could be evaluated at all */ + protected function evaluate(?Node $node): array + { + if ($node === null) { + return [null, true]; + } + + try { + return [(new ConstExprEvaluator)->evaluateSilently($node), true]; + } catch (\Throwable) { + // Not a constant expression. Reported as unknown rather than + // pretending the declaration has no value. + return [null, false]; + } + } +} diff --git a/src/Console/Support/Manifest.php b/src/Console/Support/Manifest.php new file mode 100644 index 0000000..7aa907e --- /dev/null +++ b/src/Console/Support/Manifest.php @@ -0,0 +1,169 @@ + [class, usage, description] */ + const OPERATIONS = [ + 'inspect' => [ + Commands\InspectCommand::class, + ' [meta|traits|uses|consts|cases|props|methods|relations]...', + 'Structure of a file, without method bodies', + ], + 'show' => [ + Commands\ShowCommand::class, + ' ', + 'Source of one method', + ], + 'find' => [ + Commands\FindCommand::class, + '[] [--type=all|models|controllers|providers|migrations]', + 'List files, narrowed by what they are', + ], + 'errors' => [ + \Archetype\Commands\ErrorsCommand::class, + '', + 'Files that do not parse', + ], + 'make' => [ + Commands\MakeCommand::class, + ' [--file] [--extends=] [--implements=]... [--trait=]...', + 'Create a file or class', + ], + 'set-property' => [ + Commands\SetPropertyCommand::class, + ' [] [--visibility=]', + 'Set a property', + ], + 'add-to-property' => [ + Commands\AddToPropertyCommand::class, + ' ...', + 'Append to an array property, $fillable included', + ], + 'empty-property' => [ + Commands\EmptyPropertyCommand::class, + ' ', + 'Empty a property, keeping the declaration', + ], + 'remove-property' => [ + Commands\RemovePropertyCommand::class, + ' ', + 'Remove a property', + ], + 'set-casts' => [ + Commands\SetCastsCommand::class, + ' =...', + 'Set casts, writing to casts() or $casts, whichever the model uses', + ], + 'add-relation' => [ + Commands\AddRelationCommand::class, + ' [] [--name=] [--morph-name=] [--through=] [--table=] [--with-pivot=] …', + 'Add an Eloquent relationship, any of the eleven types', + ], + 'set-array-key' => [ + Commands\SetArrayKeyCommand::class, + ' [] [--append] [--remove]', + 'Edit the array a method returns — rules(), toArray(), casts()', + ], + 'add-use' => [ + Commands\AddUseCommand::class, + ' ...', + 'Add imports', + ], + 'remove-use' => [ + Commands\RemoveUseCommand::class, + ' ...', + 'Remove imports', + ], + 'add-trait' => [ + Commands\AddTraitCommand::class, + ' ...', + 'Use a trait, importing it too', + ], + 'add-implements' => [ + Commands\AddImplementsCommand::class, + ' ...', + 'Implement interfaces, importing them too', + ], + 'set-extends' => [ + Commands\SetExtendsCommand::class, + ' ', + 'Set the parent class', + ], + 'set-namespace' => [ + Commands\SetNamespaceCommand::class, + ' ', + 'Set the namespace', + ], + 'rename-class' => [ + Commands\RenameClassCommand::class, + ' ', + 'Rename the declared class', + ], + 'set-const' => [ + Commands\SetConstCommand::class, + ' []', + 'Set a class constant', + ], + 'remove-const' => [ + Commands\RemoveConstCommand::class, + ' ', + 'Remove a class constant', + ], + 'add-case' => [ + Commands\AddCaseCommand::class, + ' []', + 'Add an enum case', + ], + 'add-method' => [ + Commands\AddMethodCommand::class, + ' --code=', + 'Add a method to a class, enum, interface or trait', + ], + 'replace-method' => [ + Commands\ReplaceMethodCommand::class, + ' --code=', + 'Replace a method', + ], + 'remove-method' => [ + Commands\RemoveMethodCommand::class, + ' ', + 'Remove a method', + ], + 'apply' => [ + Commands\ApplyCommand::class, + '[]', + 'Run several operations from a script or standard input', + ], + ]; + + /** @return array every command the service provider registers */ + public static function commands(): array + { + return array_merge( + [Commands\HelpCommand::class], + array_values(array_map(fn ($operation) => $operation[0], self::OPERATIONS)) + ); + } + + /** @return array */ + public static function lines(): array + { + $width = max(array_map('strlen', array_keys(self::OPERATIONS))); + + return collect(self::OPERATIONS) + ->map(fn ($operation, $name) => rtrim(' '.str_pad($name, $width).' '.$operation[1])) + ->values() + ->all(); + } +} diff --git a/src/Console/Support/Member.php b/src/Console/Support/Member.php new file mode 100644 index 0000000..5e5e8bc --- /dev/null +++ b/src/Console/Support/Member.php @@ -0,0 +1,58 @@ +classLike(); + + if (! $classLike) { + throw new RuntimeException('the file does not declare a class, enum, interface or trait'); + } + + $rank = static::rank($member); + $position = 0; + + foreach ($classLike->stmts as $index => $statement) { + if (static::rank($statement) <= $rank) { + $position = $index + 1; + } + } + + array_splice($classLike->stmts, $position, 0, [$member]); + } + + protected static function rank(Node\Stmt $node): int + { + $rank = array_search(get_class($node), self::ORDER, true); + + return $rank === false ? count(self::ORDER) : $rank; + } +} diff --git a/src/Console/Support/Relation.php b/src/Console/Support/Relation.php new file mode 100644 index 0000000..a9040dc --- /dev/null +++ b/src/Console/Support/Relation.php @@ -0,0 +1,214 @@ + [needs a related class, is a to-many relation, needs a morph name] */ + const TYPES = [ + 'hasOne' => [true, false, false], + 'hasMany' => [true, true, false], + 'belongsTo' => [true, false, false], + 'belongsToMany' => [true, true, false], + 'hasOneThrough' => [true, false, false], + 'hasManyThrough' => [true, true, false], + 'morphOne' => [true, false, true], + 'morphMany' => [true, true, true], + 'morphTo' => [false, false, false], + 'morphToMany' => [true, true, true], + 'morphedByMany' => [true, true, true], + ]; + + /** + * Positional arguments each type accepts after the ones it requires. + * + * PHP has no way to skip a positional argument, so these are only appended + * while they are contiguous — a gap is rejected rather than filled with + * nulls the caller did not ask for. + */ + const OPTIONS = [ + 'hasOne' => ['foreign-key', 'local-key'], + 'hasMany' => ['foreign-key', 'local-key'], + 'belongsTo' => ['foreign-key', 'owner-key'], + 'belongsToMany' => ['table', 'foreign-key', 'related-key'], + 'hasOneThrough' => ['first-key', 'second-key'], + 'hasManyThrough' => ['first-key', 'second-key'], + 'morphOne' => ['type-column', 'id-column'], + 'morphMany' => ['type-column', 'id-column'], + 'morphTo' => ['type-column', 'id-column'], + 'morphToMany' => ['table'], + 'morphedByMany' => ['table'], + ]; + + public function __construct( + protected string $type, + protected ?string $related, + protected array $options = [], + ) { + if (! array_key_exists($type, self::TYPES)) { + throw new InvalidArgumentException( + "unknown relation type '$type' — one of ".implode(', ', array_keys(self::TYPES)) + ); + } + + [$needsRelated, , $needsMorphName] = self::TYPES[$type]; + + if ($needsRelated && ! $related) { + throw new InvalidArgumentException("$type needs a related class"); + } + + if ($needsMorphName && ! $this->option('morph-name')) { + throw new InvalidArgumentException("$type needs --morph-name (the polymorphic name, e.g. commentable)"); + } + + if (str_ends_with($type, 'Through') && ! $this->option('through')) { + throw new InvalidArgumentException("$type needs --through (the intermediate model)"); + } + } + + public function name(): string + { + if ($given = $this->option('name')) { + return $given; + } + + if ($this->type === 'morphTo') { + return Str::camel($this->option('morph-name') ?: 'related'); + } + + $base = class_basename($this->related); + + return Str::camel(self::TYPES[$this->type][1] ? Str::plural($base) : $base); + } + + /** Fully qualified names this method needs imported. */ + public function imports(): array + { + return array_values(array_filter([ + $this->related, + $this->option('through'), + $this->option('using'), + ])); + } + + public function source(): string + { + $name = $this->name(); + + return implode(PHP_EOL, [ + '/**', + ' * Get the associated '.$this->docBlockName(), + ' */', + 'public function '.$name.'()', + '{', + ' return $this->'.$this->type.'('.implode(', ', $this->arguments()).')'.$this->chain().';', + '}', + ]); + } + + /** @return array */ + protected function arguments(): array + { + $arguments = []; + + if ($this->related) { + $arguments[] = class_basename($this->related).'::class'; + } + + if (str_ends_with($this->type, 'Through')) { + $arguments[] = class_basename($this->option('through')).'::class'; + } + + if (self::TYPES[$this->type][2] || ($this->type === 'morphTo' && $this->option('morph-name'))) { + $arguments[] = $this->quote($this->option('morph-name')); + } + + return array_merge($arguments, $this->optionalArguments()); + } + + /** @return array */ + protected function optionalArguments(): array + { + $given = []; + $seenGap = false; + + foreach (self::OPTIONS[$this->type] as $option) { + $value = $this->option($option); + + if ($value === null) { + $seenGap = true; + + continue; + } + + if ($seenGap) { + throw new InvalidArgumentException( + "--$option cannot be given without the arguments before it: " + .implode(', ', array_map(fn ($o) => "--$o", self::OPTIONS[$this->type])) + ); + } + + $given[] = $this->quote($value); + } + + return $given; + } + + protected function chain(): string + { + $chain = ''; + + if ($using = $this->option('using')) { + $chain .= '->using('.class_basename($using).'::class)'; + } + + if ($pivot = $this->option('with-pivot')) { + $columns = collect(explode(',', $pivot)) + ->map(fn ($column) => $this->quote(trim($column))) + ->join(', '); + + $chain .= '->withPivot('.$columns.')'; + } + + if ($this->option('with-timestamps')) { + $chain .= '->withTimestamps()'; + } + + return $chain; + } + + protected function docBlockName(): string + { + if ($this->type === 'morphTo') { + return Str::studly($this->option('morph-name') ?: 'related'); + } + + $base = class_basename($this->related); + + return Str::studly(self::TYPES[$this->type][1] ? Str::plural($base) : $base); + } + + protected function option(string $key) + { + $value = $this->options[$key] ?? null; + + return $value === '' ? null : $value; + } + + protected function quote(string $value): string + { + return "'".str_replace("'", "\\'", $value)."'"; + } +} diff --git a/src/Console/Support/Target.php b/src/Console/Support/Target.php new file mode 100644 index 0000000..b6b7976 --- /dev/null +++ b/src/Console/Support/Target.php @@ -0,0 +1,101 @@ + relative paths, sorted + */ + public static function resolve(string $target, array $filters = []): array + { + if (static::isDirectory($target)) { + return static::inDirectory($target, $filters); + } + + foreach (['extends', 'implements', 'uses-trait', 'matching'] as $filter) { + if (! empty($filters[$filter])) { + throw new InvalidArgumentException( + "--$filter only applies when the target is a directory, and '$target' is not one" + ); + } + } + + return [URI::make($target)->path()]; + } + + public static function isDirectory(string $target): bool + { + return $target !== '' && is_dir(static::absolute($target)); + } + + /** @return array */ + protected static function inDirectory(string $directory, array $filters): array + { + $query = LaravelFile::in($directory); + + if ($extends = $filters['extends'] ?? null) { + $query = $query->where('extends', $extends); + } + + if ($implements = $filters['implements'] ?? null) { + $query = $query->where('implements', 'contains', $implements); + } + + if ($trait = $filters['uses-trait'] ?? null) { + $query = $query->where('useTrait', 'contains', $trait); + } + + $paths = $query->get() + ->map(fn ($file) => static::relative($file->inputDriver()->absolutePath())) + ->sort() + ->values() + ->all(); + + if ($matching = $filters['matching'] ?? null) { + $paths = array_values(array_filter( + $paths, + fn ($path) => (bool) preg_match('/'.str_replace('/', '\/', $matching).'/', $path) + )); + } + + return $paths; + } + + public static function relative(string $absolute): string + { + return ltrim(str_replace(static::base(), '', $absolute), DIRECTORY_SEPARATOR); + } + + protected static function absolute(string $relative): string + { + return str_starts_with($relative, DIRECTORY_SEPARATOR) + ? $relative + : static::base().DIRECTORY_SEPARATOR.trim($relative, DIRECTORY_SEPARATOR); + } + + protected static function base(): string + { + return rtrim(base_path(), DIRECTORY_SEPARATOR); + } +} diff --git a/src/Console/TargetedCommand.php b/src/Console/TargetedCommand.php new file mode 100644 index 0000000..50d5f6b --- /dev/null +++ b/src/Console/TargetedCommand.php @@ -0,0 +1,45 @@ + the relative paths this invocation addresses */ + protected function targets(?string $target = null): array + { + $paths = Target::resolve($target ?? $this->argument('target'), [ + 'extends' => $this->option('extends'), + 'implements' => $this->option('implements'), + 'uses-trait' => $this->option('uses-trait'), + 'matching' => $this->option('matching'), + ]); + + if (! $paths) { + throw new \RuntimeException('no files matched '.($target ?? $this->argument('target'))); + } + + return $paths; + } + + /** @return array */ + protected function sharedOptions(): array + { + return array_merge(parent::sharedOptions(), [ + new InputOption('extends', null, InputOption::VALUE_REQUIRED, 'Only classes extending this (directory targets)'), + new InputOption('implements', null, InputOption::VALUE_REQUIRED, 'Only classes implementing this (directory targets)'), + new InputOption('uses-trait', null, InputOption::VALUE_REQUIRED, 'Only classes using this trait (directory targets)'), + new InputOption('matching', null, InputOption::VALUE_REQUIRED, 'Only paths matching this regular expression (directory targets)'), + ]); + } +} diff --git a/src/Endpoints/PHP/ClassConstant.php b/src/Endpoints/PHP/ClassConstant.php index f96e818..5e63e35 100755 --- a/src/Endpoints/PHP/ClassConstant.php +++ b/src/Endpoints/PHP/ClassConstant.php @@ -122,7 +122,7 @@ protected function addToNumeric(string $key, $new, $existing = 0) protected function remove(string $key) { return $this->file->astQuery() - ->class() + ->classLike() ->classConst() ->where(function ($query) use ($key) { return $query->const() @@ -170,7 +170,7 @@ protected function getWithReflection(string $name) protected function getWithParser(string $key) { return $this->file->astQuery() - ->class() + ->classLike() ->classConst()->consts ->where('name->name', $key) ->value @@ -183,7 +183,7 @@ protected function set(string $key, $value = Types::NO_VALUE) $value = $this->prepareValue($value); $propertyExists = $this->file->astQuery() - ->class() + ->classLike() ->classConst()->consts ->where('name->name', $key) ->isNotEmpty(); @@ -194,7 +194,7 @@ protected function set(string $key, $value = Types::NO_VALUE) protected function create(string $key, $value) { return $this->file->astQuery() - ->class() + ->classLike() ->insertStmt($this->makeConstant($key, $value)) ->commit() ->end() @@ -204,7 +204,7 @@ protected function create(string $key, $value) protected function update(string $key, $value) { return $this->file->astQuery() - ->class() + ->classLike() ->classConst()->consts ->where('name->name', $key) ->replaceProperty( diff --git a/src/Endpoints/PHP/ClassName.php b/src/Endpoints/PHP/ClassName.php index 9bac073..f7ad964 100644 --- a/src/Endpoints/PHP/ClassName.php +++ b/src/Endpoints/PHP/ClassName.php @@ -31,7 +31,7 @@ public function className(?string $name = null) protected function get() { $className = $this->file->astQuery() - ->class() + ->classLike() ->name ->name ->first(); @@ -48,7 +48,7 @@ protected function get() protected function set(string $newClassName) { return $this->file->astQuery() - ->class() + ->classLike() ->name ->replaceProperty('name', $newClassName) ->commit() diff --git a/src/Endpoints/PHP/Implements_.php b/src/Endpoints/PHP/Implements_.php index ff55de0..ee22be0 100644 --- a/src/Endpoints/PHP/Implements_.php +++ b/src/Endpoints/PHP/Implements_.php @@ -33,23 +33,33 @@ public function implements($name = null) protected function get() { - return $this->file->astQuery() - ->class() - ->implements - ->get() - ->map(fn ($node) => $node->name)->toArray(); + return collect(['class', 'enum'])->flatMap(function ($construct) { + return $this->file->astQuery() + ->$construct() + ->implements + ->get() + ->map(fn ($node) => $node->name); + })->toArray(); } + /** + * Classes and enums, because those are the two constructs that implement. + * An interface extends rather than implements, and giving it an `implements` + * would produce something PHP cannot parse. + */ protected function set($newImplements) { $newImplements = $this->makeNameObject($newImplements); - - return $this->file->astQuery() - ->class() - ->replaceProperty('implements', $newImplements) - ->commit() - ->end() - ->continue(); + + foreach (['class', 'enum'] as $construct) { + $this->file->astQuery() + ->$construct() + ->replaceProperty('implements', $newImplements) + ->commit() + ->end(); + } + + return $this->file->continue(); } protected function add($newImplements) diff --git a/src/Endpoints/PHP/Property.php b/src/Endpoints/PHP/Property.php index 7481128..18d83a2 100644 --- a/src/Endpoints/PHP/Property.php +++ b/src/Endpoints/PHP/Property.php @@ -148,7 +148,7 @@ protected function addToNumeric(string $key, $new, $existing = 0) protected function remove(string $key) { return $this->file->astQuery() - ->class() + ->classLike() ->property() ->where(function ($query) use ($key) { return $query->propertyProperty() @@ -196,7 +196,7 @@ protected function getWithReflection(string $name) protected function getWithParser(string $key) { return $this->file->astQuery() - ->class() + ->classLike() ->propertyProperty() ->where('name->name', $key) ->default @@ -209,7 +209,7 @@ protected function set(string $key, $value = Types::NO_VALUE) $value = $this->prepareValue($value); $propertyExists = $this->file->astQuery() - ->class() + ->classLike() ->propertyProperty() ->where('name->name', $key) ->get()->isNotEmpty(); @@ -220,7 +220,7 @@ protected function set(string $key, $value = Types::NO_VALUE) protected function create(string $key, $value) { return $this->file->astQuery() - ->class() + ->classLike() ->insertStmt($this->makeProperty($key, $value)) ->commit() ->end() @@ -230,7 +230,7 @@ protected function create(string $key, $value) protected function update(string $key, $value) { return $this->file->astQuery() - ->class() + ->classLike() ->property() ->where->propertyProperty('name->name')->is($key)->get() ->replace(function ($property) { diff --git a/src/Endpoints/PHP/UseTrait.php b/src/Endpoints/PHP/UseTrait.php index 2d0f36d..1fb5ad4 100644 --- a/src/Endpoints/PHP/UseTrait.php +++ b/src/Endpoints/PHP/UseTrait.php @@ -28,7 +28,7 @@ public function useTrait($value = null) protected function get() { $r = $this->file->astQuery() - ->class() + ->classLike() ->traitUse() ->name() ->get() @@ -40,7 +40,7 @@ protected function get() protected function add($newUseTraitNames) { return $this->file->astQuery() - ->class() + ->classLike() ->insertStmts( collect(Arr::wrap($newUseTraitNames)) ->reverse() diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 0e06be6..33557e0 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\ServiceProvider as BaseServiceProvider; -use Archetype\Commands\ErrorsCommand; +use Archetype\Console\Support\Manifest; use Archetype\Factories\LaravelFileFactory; use Archetype\Factories\PHPFileFactory; @@ -42,8 +42,6 @@ protected function publishConfig() protected function registerCommands() { - $this->commands([ - ErrorsCommand::class, - ]); + $this->commands(Manifest::commands()); } } diff --git a/src/Support/PSR2PrettyPrinter.php b/src/Support/PSR2PrettyPrinter.php index 4adbe23..671e8ef 100644 --- a/src/Support/PSR2PrettyPrinter.php +++ b/src/Support/PSR2PrettyPrinter.php @@ -30,7 +30,7 @@ protected function pStmt_ClassMethod(ClassMethod $node): string . $this->pModifiers($node->flags) . 'function ' . ($node->byRef ? '&' : '') . $node->name . '(' . $this->pMaybeMultiline($node->params) . ')' - . (null !== $node->returnType ? ' : ' . $this->p($node->returnType) : '') + . (null !== $node->returnType ? ': ' . $this->p($node->returnType) : '') . (null !== $node->stmts ? $this->nl . '{' . $this->pStmts($node->stmts) . $this->nl . '}' : ';'); diff --git a/src/Traits/PHPParserClassMap.php b/src/Traits/PHPParserClassMap.php index 12a5740..40052d7 100644 --- a/src/Traits/PHPParserClassMap.php +++ b/src/Traits/PHPParserClassMap.php @@ -42,6 +42,8 @@ trait PHPParserClassMap 'empty' => \PhpParser\Node\Expr\Empty_::class, 'encapsed' => \PhpParser\Node\Scalar\Encapsed::class, 'encapsedStringPart' => \PhpParser\Node\Scalar\EncapsedStringPart::class, + 'enum' => \PhpParser\Node\Stmt\Enum_::class, + 'enumCase' => \PhpParser\Node\Stmt\EnumCase::class, 'error' => \PhpParser\Node\Expr\Error::class, 'errorSuppress' => \PhpParser\Node\Expr\ErrorSuppress::class, 'eval' => \PhpParser\Node\Expr\Eval_::class, @@ -403,6 +405,22 @@ public function encapsedStringPart($path = ''): self ); } + public function enum($path = ''): self + { + return $this->traverseIntoClass( + $this->phpParserClassMap[__FUNCTION__], + $path + ); + } + + public function enumCase($path = ''): self + { + return $this->traverseIntoClass( + $this->phpParserClassMap[__FUNCTION__], + $path + ); + } + public function error($path = ''): self { return $this->traverseIntoClass( diff --git a/tests/Feature/Console/AddRelationCommandTest.php b/tests/Feature/Console/AddRelationCommandTest.php new file mode 100644 index 0000000..3f6437d --- /dev/null +++ b/tests/Feature/Console/AddRelationCommandTest.php @@ -0,0 +1,114 @@ +succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('OK app/Models/Project.php hasMany tasks'); + expect(Console::read('app/Models/Project.php')) + ->toContain('return $this->hasMany(Task::class);') + ->toContain('Get the associated Tasks'); +}); + +it('appends the method after what is already there', function () { + Console::run('archetype:add-relation app/Models/Project.php hasMany Task'); + + $source = Console::read('app/Models/Project.php'); + + expect(strpos($source, 'public function tasks'))->toBeGreaterThan(strpos($source, '$fillable')); +}); + +it('imports a related class from another namespace', function () { + Console::run('archetype:add-relation', [ + 'target' => 'app/Models/Project.php', + 'type' => 'belongsTo', + 'related' => 'App\Domain\Owner', + ]); + + expect(Console::read('app/Models/Project.php')) + ->toContain('use App\Domain\Owner;') + ->toContain('return $this->belongsTo(Owner::class);'); +}); + +it('overrides the method name', function () { + Console::run('archetype:add-relation app/Models/Project.php belongsTo User --name=owner --foreign-key=owner_id'); + + expect(Console::read('app/Models/Project.php')) + ->toContain('public function owner()') + ->toContain("return \$this->belongsTo(User::class, 'owner_id');"); +}); + +it('writes a belongsToMany with a pivot', function () { + Console::run('archetype:add-relation app/Models/Project.php belongsToMany Label --table=label_project --with-pivot=sort,note --with-timestamps'); + + expect(Console::read('app/Models/Project.php'))->toContain( + "return \$this->belongsToMany(Label::class, 'label_project')->withPivot('sort', 'note')->withTimestamps();" + ); +}); + +it('writes the polymorphic relations', function () { + Console::run('archetype:add-relation app/Models/Project.php morphMany Comment --morph-name=commentable'); + + expect(Console::read('app/Models/Project.php')) + ->toContain("return \$this->morphMany(Comment::class, 'commentable');") + ->toContain('public function comments()'); +}); + +it('writes a through relation', function () { + Console::run('archetype:add-relation app/Models/Project.php hasManyThrough Comment --through=Task'); + + expect(Console::read('app/Models/Project.php')) + ->toContain('return $this->hasManyThrough(Comment::class, Task::class);'); +}); + +it('will not add a relation that is already there', function () { + Console::run('archetype:add-relation app/Models/Project.php hasMany Task'); + $again = Console::run('archetype:add-relation app/Models/Project.php hasMany Task'); + + expect($again->succeeded())->toBeTrue(); + expect($again->lines())->toBe(['SKIP app/Models/Project.php tasks exists']); +}); + +it('rejects a relation type it does not have', function () { + $result = Console::run('archetype:add-relation app/Models/Project.php hasSome Task'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain("unknown relation type 'hasSome'"); +}); + +it('insists on the arguments a relation needs', function () { + expect(Console::run('archetype:add-relation app/Models/Project.php morphMany Comment')->output) + ->toContain('needs --morph-name'); + + expect(Console::run('archetype:add-relation app/Models/Project.php hasManyThrough Comment')->output) + ->toContain('needs --through'); + + expect(Console::run('archetype:add-relation app/Models/Project.php hasMany')->output) + ->toContain('needs a related class'); +}); + +it('refuses to guess an argument the caller skipped', function () { + $result = Console::run('archetype:add-relation app/Models/Project.php hasMany Task --local-key=uuid'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('--local-key cannot be given without the arguments before it'); +}); diff --git a/tests/Feature/Console/ApplyCommandTest.php b/tests/Feature/Console/ApplyCommandTest.php new file mode 100644 index 0000000..8bd9b6e --- /dev/null +++ b/tests/Feature/Console/ApplyCommandTest.php @@ -0,0 +1,85 @@ +succeeded())->toBeTrue(); + expect($result->output)->toContain('3 of 3 operations ok'); + + expect(Console::read('app/Models/User.php')) + ->toContain("'nickname',") + ->toContain("'is_admin' => 'boolean',") + ->toContain('return $this->hasMany(Post::class);'); +}); + +it('accepts operations written with the prefix', function () { + $result = Console::run('archetype:apply '.script('archetype:add-to-property app/Models/User.php fillable nickname')); + + expect($result->succeeded())->toBeTrue(); + expect($result->output)->toContain('OK app/Models/User.php $fillable +1'); +}); + +it('reports a failing operation and keeps going', function () { + $result = Console::run('archetype:apply '.script(<<<'TXT' + add-to-property app/Models/Nope.php fillable slug + add-to-property app/Models/User.php fillable nickname + TXT)); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('1 of 2 operations ok'); + expect(Console::read('app/Models/User.php'))->toContain("'nickname',"); +}); + +it('stops at the first failure when asked', function () { + $result = Console::run('archetype:apply '.script(<<<'TXT' + add-to-property app/Models/Nope.php fillable slug + add-to-property app/Models/User.php fillable nickname + TXT).' --stop-on-failure'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('0 of 1 operations ok'); + expect(Console::read('app/Models/User.php'))->not->toContain('nickname'); +}); + +it('reports each operation as json', function () { + $payload = Console::run('archetype:apply '.script(<<<'TXT' + add-to-property app/Models/User.php fillable nickname + set-casts app/Models/User.php is_admin=boolean + TXT).' --json')->json(); + + expect($payload['ok'])->toBeTrue(); + expect($payload['ran'])->toBe(2); + expect($payload['results'][0]['operation'])->toBe('add-to-property app/Models/User.php fillable nickname'); + expect(json_decode($payload['results'][0]['output'], true)['changed'])->toBe(1); +}); + +it('fails on an empty script', function () { + $result = Console::run('archetype:apply '.script("\n# nothing here\n")); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('no operations given'); +}); + +it('fails on a script that is not there', function () { + $result = Console::run('archetype:apply nowhere.txt'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('no such file: nowhere.txt'); +}); diff --git a/tests/Feature/Console/BinaryTest.php b/tests/Feature/Console/BinaryTest.php new file mode 100644 index 0000000..29cc613 --- /dev/null +++ b/tests/Feature/Console/BinaryTest.php @@ -0,0 +1,82 @@ +&1", $output, $status); + + return [$status, implode("\n", $output)]; +} + +afterEach(function () { + foreach (glob(sys_get_temp_dir().'/archetype-bin-*') as $directory) { + File::deleteDirectory($directory); + } +}); + +it('turns an operation into its artisan command', function () { + $root = stubApplication(); + + [$status, $output] = runBinary($root, 'inspect app/Models/User.php --json'); + + expect($status)->toBe(0); + expect($output)->toBe('archetype:inspect app/Models/User.php --json'); +}); + +it('finds the application from a directory below it', function () { + $root = stubApplication(); + + [, $output] = runBinary($root.'/app/Models', 'inspect app/Models/User.php'); + + expect($output)->toBe('archetype:inspect app/Models/User.php'); +}); + +it('lists the operations when given nothing', function () { + $root = stubApplication(); + + [$status, $output] = runBinary($root); + + expect($status)->toBe(0); + expect($output)->toBe('archetype'); +}); + +it('passes an already prefixed operation through', function () { + $root = stubApplication(); + + [, $output] = runBinary($root, 'archetype:add-case app/Enums/Status.php Draft'); + + expect($output)->toBe('archetype:add-case app/Enums/Status.php Draft'); +}); + +it('keeps backslashes in a class name', function () { + $root = stubApplication(); + + [, $output] = runBinary($root, "inspect 'App\\Models\\User'"); + + expect($output)->toBe('archetype:inspect App\Models\User'); +}); + +it('says so when there is no application to talk to', function () { + [$status, $output] = runBinary(sys_get_temp_dir(), 'inspect app/Models/User.php'); + + expect($status)->toBe(1); + expect($output)->toContain('could not find a Laravel artisan file'); +}); diff --git a/tests/Feature/Console/EnumCommandsTest.php b/tests/Feature/Console/EnumCommandsTest.php new file mode 100644 index 0000000..5b5ced7 --- /dev/null +++ b/tests/Feature/Console/EnumCommandsTest.php @@ -0,0 +1,84 @@ +value); + } + } + PHP); +}); + +it('adds a case after the ones already there', function () { + $result = Console::run('archetype:add-case app/Enums/ProjectStatus.php OnHold on_hold'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('OK app/Enums/ProjectStatus.php case OnHold'); + + $source = Console::read('app/Enums/ProjectStatus.php'); + + expect($source)->toContain("case OnHold = 'on_hold';"); + expect(strpos($source, 'OnHold'))->toBeGreaterThan(strpos($source, 'Active')); + expect(strpos($source, 'OnHold'))->toBeLessThan(strpos($source, 'function label')); +}); + +it('adds a case with an integer value', function () { + Console::run('archetype:add-case app/Enums/ProjectStatus.php Closed 3'); + + expect(Console::read('app/Enums/ProjectStatus.php'))->toContain('case Closed = 3;'); +}); + +it('adds a case with no backing value', function () { + Console::write('app/Enums/Suit.php', <<<'PHP' + toContain('case Spades;'); +}); + +it('will not add a case that is already there', function () { + $result = Console::run('archetype:add-case app/Enums/ProjectStatus.php Draft draft'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Enums/ProjectStatus.php case Draft exists']); +}); + +it('refuses to add a case to something that is not an enum', function () { + $result = Console::run('archetype:add-case app/Models/User.php Draft draft'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('not an enum, it is a class'); +}); + +it('adds an interface and its import to an enum', function () { + $result = Console::run('archetype:add-implements', [ + 'target' => 'app/Enums/ProjectStatus.php', + 'interfaces' => ['App\Contracts\HasColor'], + ]); + + expect($result->succeeded())->toBeTrue(); + expect(Console::read('app/Enums/ProjectStatus.php')) + ->toContain('use App\Contracts\HasColor;') + ->toContain('enum ProjectStatus: string implements HasColor'); +}); diff --git a/tests/Feature/Console/FindCommandTest.php b/tests/Feature/Console/FindCommandTest.php new file mode 100644 index 0000000..49fc353 --- /dev/null +++ b/tests/Feature/Console/FindCommandTest.php @@ -0,0 +1,48 @@ +succeeded())->toBeTrue(); + expect($result->lines())->toContain('app/Http/Middleware/Authenticate.php'); + expect($result->lines())->toContain('8 file(s)'); +}); + +it('lists migrations without being told where they live', function () { + expect(Console::run('archetype:find --type=migrations')->output) + ->toContain('database/migrations/2014_10_12_000000_create_users_table.php'); +}); + +it('narrows by what a class extends', function () { + $payload = Console::run('archetype:find app --extends=Authenticatable --json')->json(); + + expect($payload['files'])->toBe(['app/Models/User.php']); +}); + +it('narrows by trait', function () { + $payload = Console::run('archetype:find app --uses-trait=HasFactory --json')->json(); + + expect($payload['files'])->toBe(['app/Models/User.php']); +}); + +it('narrows by path', function () { + $payload = Console::run('archetype:find app --matching=Middleware --json')->json(); + + expect($payload['count'])->toBe(8); +}); + +it('rejects a type it does not have', function () { + $result = Console::run('archetype:find app --type=nonsense'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain("unknown --type 'nonsense'"); +}); + +it('rejects a directory that is not one', function () { + $result = Console::run('archetype:find app/Models/User.php'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('is not a directory'); +}); diff --git a/tests/Feature/Console/HelpCommandTest.php b/tests/Feature/Console/HelpCommandTest.php new file mode 100644 index 0000000..4d1ae3a --- /dev/null +++ b/tests/Feature/Console/HelpCommandTest.php @@ -0,0 +1,33 @@ +succeeded())->toBeTrue(); + + foreach (array_keys(Manifest::OPERATIONS) as $operation) { + expect($result->output)->toContain($operation); + } +}); + +it('describes the operations as json', function () { + $payload = Console::run('archetype --json')->json(); + + expect($payload['operations'])->toHaveCount(count(Manifest::OPERATIONS)); + expect($payload['operations'][0])->toHaveKeys(['operation', 'usage', 'description']); +}); + +it('registers every command the manifest names', function () { + foreach (Manifest::commands() as $class) { + expect(class_exists($class))->toBeTrue("missing $class"); + } + + $registered = array_keys(Illuminate\Support\Facades\Artisan::all()); + + foreach (array_keys(Manifest::OPERATIONS) as $operation) { + expect($registered)->toContain("archetype:$operation"); + } +}); diff --git a/tests/Feature/Console/InspectCommandTest.php b/tests/Feature/Console/InspectCommandTest.php new file mode 100644 index 0000000..529bc31 --- /dev/null +++ b/tests/Feature/Console/InspectCommandTest.php @@ -0,0 +1,110 @@ +succeeded())->toBeTrue(); + expect($result->lines())->toContain('app/Models/User.php'); + expect($result->lines())->toContain('class App\Models\User extends Authenticatable'); + expect($result->lines())->toContain('uses HasApiTokens, HasFactory, Notifiable'); + expect($result->lines())->toContain('prop protected $fillable = ["name","email","password"]'); + expect($result->lines())->toContain('prop protected $casts = {"email_verified_at":"datetime"}'); +}); + +it('accepts a class name as the target', function () { + expect(Console::run('archetype:inspect', ['target' => 'App\Models\User'])->lines()) + ->toContain('class App\Models\User extends Authenticatable'); +}); + +it('limits the summary to the sections asked for', function () { + $lines = Console::run('archetype:inspect app/Models/User.php props')->lines(); + + expect($lines)->toHaveCount(4); + expect(implode("\n", $lines))->not->toContain('class App\Models\User'); +}); + +it('rejects a section it does not have', function () { + $result = Console::run('archetype:inspect app/Models/User.php nonsense'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain("unknown section 'nonsense'"); +}); + +it('describes an enum as an enum, with its cases', function () { + Console::write('app/Enums/Status.php', <<<'PHP' + value); + } + } + PHP); + + $lines = Console::run('archetype:inspect app/Enums/Status.php')->lines(); + + expect($lines)->toContain('enum App\Enums\Status'); + expect($lines)->toContain('case Active = "active"'); + expect($lines)->toContain('case Archived = "archived"'); + expect($lines)->toContain('fn public label(): string [4 lines]'); +}); + +it('reports relationships it can read from method bodies', function () { + Console::write('app/Models/Project.php', <<<'PHP' + hasMany(Task::class); + } + + public function owner() + { + return $this->belongsTo(User::class, 'owner_id'); + } + } + PHP); + + $lines = Console::run('archetype:inspect app/Models/Project.php relations')->lines(); + + expect($lines)->toContain('rel tasks hasMany Task'); + expect($lines)->toContain('rel owner belongsTo User'); +}); + +it('summarises every class in a directory', function () { + $payload = Console::run('archetype:inspect app/Models meta --json')->json(); + + expect($payload['count'])->toBe(1); + expect($payload['files'][0]['name'])->toBe('User'); +}); + +it('says a value is unknown rather than guessing it', function () { + Console::write('app/Odd.php', <<<'PHP' + lines()) + ->toContain('prop protected $computed = ?'); +}); diff --git a/tests/Feature/Console/MakeCommandTest.php b/tests/Feature/Console/MakeCommandTest.php new file mode 100644 index 0000000..f504460 --- /dev/null +++ b/tests/Feature/Console/MakeCommandTest.php @@ -0,0 +1,62 @@ + 'App\Services\Billing']); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('OK app/Services/Billing.php created'); + expect(Console::read('app/Services/Billing.php')) + ->toContain('namespace App\Services;') + ->toContain('class Billing'); +}); + +it('creates a class from a path', function () { + Console::run('archetype:make app/Services/Billing.php'); + + expect(Console::read('app/Services/Billing.php'))->toContain('class Billing'); +}); + +it('creates a class with a parent, interfaces and traits', function () { + Console::run('archetype:make', [ + 'name' => 'App\Models\Invoice', + '--extends' => 'Illuminate\Database\Eloquent\Model', + '--implements' => ['App\Contracts\Payable'], + '--trait' => ['Illuminate\Database\Eloquent\Factories\HasFactory'], + ]); + + expect(Console::read('app/Models/Invoice.php')) + ->toContain('use Illuminate\Database\Eloquent\Model;') + ->toContain('use App\Contracts\Payable;') + ->toContain('class Invoice extends Model implements Payable') + ->toContain('use HasFactory;'); +}); + +it('creates an empty file', function () { + Console::run('archetype:make app/helpers.php --file'); + + expect(Console::read('app/helpers.php'))->toContain('succeeded())->toBeFalse(); + expect($result->output)->toContain('already exists — pass --force'); +}); + +it('overwrites when forced', function () { + $result = Console::run('archetype:make app/Models/User.php --force'); + + expect($result->succeeded())->toBeTrue(); + expect(Console::read('app/Models/User.php'))->not->toContain('$fillable'); +}); + +it('returns the created source as json', function () { + $payload = Console::run('archetype:make', ['name' => 'App\Services\Billing', '--json' => true])->json(); + + expect($payload['ok'])->toBeTrue(); + expect($payload['file'])->toBe('app/Services/Billing.php'); + expect($payload['source'])->toContain('class Billing'); +}); diff --git a/tests/Feature/Console/MethodCommandsTest.php b/tests/Feature/Console/MethodCommandsTest.php new file mode 100644 index 0000000..a03ff3a --- /dev/null +++ b/tests/Feature/Console/MethodCommandsTest.php @@ -0,0 +1,98 @@ +where(\'active\', true); }"'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('OK app/Models/Project.php fn scopeActive added'); + expect(Console::read('app/Models/Project.php')) + ->toContain('public function scopeActive($query)') + ->toContain("return \$query->where('active', true);"); +}); + +it('adds the method after the ones already there', function () { + Console::run('archetype:add-method app/Models/Project.php --code="public function scopeActive(\$query) { return \$query; }"'); + + $source = Console::read('app/Models/Project.php'); + + expect(strpos($source, 'scopeActive'))->toBeGreaterThan(strpos($source, 'isActive')); +}); + +it('will not add a method that is already there', function () { + $result = Console::run('archetype:add-method app/Models/Project.php --code="public function isActive() { return false; }"'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Models/Project.php isActive exists']); +}); + +it('replaces a method', function () { + $result = Console::run('archetype:replace-method app/Models/Project.php isActive --code="public function isActive(): bool { return \$this->active; }"'); + + expect($result->succeeded())->toBeTrue(); + expect(Console::read('app/Models/Project.php')) + ->toContain('public function isActive(): bool') + ->not->toContain('return true;'); +}); + +it('removes a method', function () { + Console::run('archetype:remove-method app/Models/Project.php isActive'); + + expect(Console::read('app/Models/Project.php'))->not->toContain('isActive'); +}); + +it('reports a method that was never there rather than failing', function () { + $result = Console::run('archetype:remove-method app/Models/Project.php missing'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Models/Project.php no fn missing']); +}); + +it('insists on code for the operations that need it', function () { + expect(Console::run('archetype:add-method app/Models/Project.php')->output)->toContain('--code is required'); + expect(Console::run('archetype:replace-method app/Models/Project.php isActive')->output)->toContain('--code is required'); +}); + +it('rejects code that is not a method', function () { + $result = Console::run('archetype:add-method app/Models/Project.php --code="\$x = 1;"'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('could not parse the given code'); +}); + +it('adds a method to an enum', function () { + Console::write('app/Enums/Status.php', <<<'PHP' + value); }"'); + + expect($result->succeeded())->toBeTrue(); + expect(Console::read('app/Enums/Status.php'))->toContain('public function label(): string'); +}); diff --git a/tests/Feature/Console/MutationContractTest.php b/tests/Feature/Console/MutationContractTest.php new file mode 100644 index 0000000..e9f3d32 --- /dev/null +++ b/tests/Feature/Console/MutationContractTest.php @@ -0,0 +1,133 @@ +succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('OK app/Models/User.php $fillable +1'); + expect($result->output)->toContain('@@ '); + expect($result->output)->toContain("+ 'nickname',"); +}); + +it('suppresses the diff when asked', function () { + $result = Console::run('archetype:add-to-property app/Models/User.php fillable nickname --no-diff'); + + expect($result->lines())->toBe(['OK app/Models/User.php $fillable +1']); +}); + +it('skips work already done instead of failing', function () { + $first = Console::run('archetype:add-to-property app/Models/User.php fillable nickname'); + $second = Console::run('archetype:add-to-property app/Models/User.php fillable nickname'); + + expect($first->succeeded())->toBeTrue(); + expect($second->succeeded())->toBeTrue(); + expect($second->lines())->toBe(['SKIP app/Models/User.php $fillable unchanged']); +}); + +it('writes nothing on a dry run', function () { + $before = Console::read('app/Models/User.php'); + $result = Console::run('archetype:add-to-property app/Models/User.php fillable nickname --dry-run'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('DRY app/Models/User.php $fillable +1'); + expect($result->output)->toContain("+ 'nickname',"); + expect(Console::read('app/Models/User.php'))->toBe($before); +}); + +it('exits non-zero when the change matched nothing', function () { + Console::write('app/helpers.php', "succeeded())->toBeFalse(); + expect($result->output)->toContain('but the file did not change'); +}); + +it('refuses a change the construct cannot take', function () { + Console::write('app/Enums/Status.php', <<<'PHP' + succeeded())->toBeFalse(); + expect($result->output)->toContain('an enum cannot have properties'); + expect(Console::read('app/Enums/Status.php'))->not->toContain('table'); +}); + +it('applies one change across a whole directory', function () { + Console::write('app/Models/Project.php', modelSource('Project')); + Console::write('app/Models/Task.php', modelSource('Task')); + + $result = Console::run('archetype:add-trait', [ + 'target' => 'app/Models', + 'traits' => ['Illuminate\Database\Eloquent\SoftDeletes'], + ]); + + expect($result->succeeded())->toBeTrue(); + expect($result->output)->toContain('3 changed, 0 unchanged, 0 failed of 3 files'); + expect(Console::read('app/Models/Project.php'))->toContain('use SoftDeletes;'); + expect(Console::read('app/Models/Task.php'))->toContain('use SoftDeletes;'); +}); + +it('narrows a directory change with a filter', function () { + Console::write('app/Models/Project.php', modelSource('Project')); + + $result = Console::run('archetype:add-to-property app/Models fillable slug --extends=Model'); + + expect($result->succeeded())->toBeTrue(); + expect(Console::read('app/Models/Project.php'))->toContain("'slug'"); + expect(Console::read('app/Models/User.php'))->not->toContain("'slug'"); +}); + +it('refuses a filter when the target is a single file', function () { + $result = Console::run('archetype:add-to-property app/Models/User.php fillable slug --extends=Model'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('--extends only applies when the target is a directory'); +}); + +it('reports a mutation as json', function () { + $payload = Console::run('archetype:add-to-property app/Models/User.php fillable nickname --json')->json(); + + expect($payload['ok'])->toBeTrue(); + expect($payload['changed'])->toBe(1); + expect($payload['dryRun'])->toBeFalse(); + expect($payload['results'][0]['status'])->toBe('changed'); + expect($payload['results'][0]['diff'])->toContain('nickname'); +}); + +it('fails on a target that does not exist', function () { + $result = Console::run('archetype:add-to-property app/Models/Nope.php fillable slug'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toStartWith('ERR app/Models/Nope.php'); +}); + +function modelSource(string $name): string +{ + return <<toContain("protected \$table = 'gdpr_users';"); +}); + +it('sets a property from json', function () { + Console::run('archetype:set-property app/Models/User.php with \'["profile","posts"]\''); + + expect(Console::read('app/Models/User.php'))->toContain("protected \$with = [\n 'profile',\n 'posts',\n ];"); +}); + +it('honours the visibility asked for', function () { + Console::run('archetype:set-property app/Models/User.php perPage 25 --visibility=public'); + + expect(Console::read('app/Models/User.php'))->toContain('public $perPage = 25;'); +}); + +it('declares a property with no default when no value is given', function () { + Console::run('archetype:set-property app/Models/User.php connection'); + + expect(Console::read('app/Models/User.php'))->toContain('protected $connection;'); +}); + +it('rejects a visibility that is not one', function () { + $result = Console::run('archetype:set-property app/Models/User.php table x --visibility=internal'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('--visibility must be public, protected or private'); +}); + +it('appends only the values that are missing', function () { + $result = Console::run('archetype:add-to-property app/Models/User.php fillable name nickname'); + + expect($result->lines()[0])->toBe('OK app/Models/User.php $fillable +1'); + expect(Console::read('app/Models/User.php'))->toContain("'nickname',"); +}); + +it('creates an array property that was not there', function () { + Console::run('archetype:add-to-property app/Models/User.php appends full_name'); + + expect(Console::read('app/Models/User.php'))->toContain("protected \$appends = [\n 'full_name',\n ];"); +}); + +it('empties a property but keeps the declaration and its visibility', function () { + Console::run('archetype:empty-property app/Models/User.php fillable'); + + $source = Console::read('app/Models/User.php'); + + expect($source)->toContain('protected $fillable = [];'); + expect($source)->not->toContain("'email',"); +}); + +it('leaves visibility alone unless it is told to change it', function () { + Console::run('archetype:set-property app/Models/User.php visible \'["id"]\' --visibility=public'); + Console::run('archetype:add-to-property app/Models/User.php visible name'); + + expect(Console::read('app/Models/User.php'))->toContain('public $visible'); +}); + +it('removes a property', function () { + Console::run('archetype:remove-property app/Models/User.php hidden'); + + expect(Console::read('app/Models/User.php'))->not->toContain('$hidden'); +}); + +it('reports a property that was never there rather than failing', function () { + $result = Console::run('archetype:remove-property app/Models/User.php nope'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Models/User.php no $nope']); +}); diff --git a/tests/Feature/Console/SetArrayKeyCommandTest.php b/tests/Feature/Console/SetArrayKeyCommandTest.php new file mode 100644 index 0000000..615d8bf --- /dev/null +++ b/tests/Feature/Console/SetArrayKeyCommandTest.php @@ -0,0 +1,122 @@ + 'required|string|max:255', + ]; + } + } + PHP); +}); + +it('adds a key to the array a method returns', function () { + $result = Console::run('archetype:set-array-key app/Http/Requests/StoreTaskRequest.php rules due_at \'nullable|date\''); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('OK app/Http/Requests/StoreTaskRequest.php rules()[due_at] added'); + expect(Console::read('app/Http/Requests/StoreTaskRequest.php')) + ->toContain("'due_at' => 'nullable|date',") + ->toContain("'title' => 'required|string|max:255',"); +}); + +it('takes any php expression as the value', function () { + Console::run('archetype:set-array-key app/Http/Requests/StoreTaskRequest.php rules tags "[\'array\', \'max:5\']"'); + + expect(Console::read('app/Http/Requests/StoreTaskRequest.php')) + ->toContain("'tags' => [\n 'array',\n 'max:5',\n ],"); +}); + +it('updates a key that is already there', function () { + $result = Console::run('archetype:set-array-key app/Http/Requests/StoreTaskRequest.php rules title required'); + + expect($result->lines()[0])->toContain('rules()[title] updated'); + expect(Console::read('app/Http/Requests/StoreTaskRequest.php')) + ->toContain("'title' => 'required',") + ->not->toContain('max:255'); +}); + +it('skips a key already set to that value', function () { + $result = Console::run('archetype:set-array-key app/Http/Requests/StoreTaskRequest.php rules title \'required|string|max:255\''); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Http/Requests/StoreTaskRequest.php rules()[title] unchanged']); +}); + +it('removes a key', function () { + Console::run('archetype:set-array-key app/Http/Requests/StoreTaskRequest.php rules title --remove'); + + expect(Console::read('app/Http/Requests/StoreTaskRequest.php'))->not->toContain("'title'"); +}); + +it('appends a value with no key', function () { + Console::write('app/Providers/Listener.php', <<<'PHP' + toContain("'second',"); +}); + +it('insists on a value unless it is removing', function () { + $result = Console::run('archetype:set-array-key app/Http/Requests/StoreTaskRequest.php rules title'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('a value is required unless --remove is given'); +}); + +it('fails when the method does not return an array literal', function () { + $result = Console::run('archetype:set-array-key app/Models/User.php getTable name x'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('getTable() does not return an array literal'); +}); + +it('reaches the array a laravel 11 casts method returns', function () { + Console::write('app/Models/Project.php', <<<'PHP' + 'datetime', + ]; + } + } + PHP); + + Console::run('archetype:set-array-key app/Models/Project.php casts archived boolean'); + + expect(Console::read('app/Models/Project.php'))->toContain("'archived' => 'boolean',"); +}); diff --git a/tests/Feature/Console/SetCastsCommandTest.php b/tests/Feature/Console/SetCastsCommandTest.php new file mode 100644 index 0000000..19b8ea1 --- /dev/null +++ b/tests/Feature/Console/SetCastsCommandTest.php @@ -0,0 +1,92 @@ +succeeded())->toBeTrue(); + expect($result->lines()[0])->toBe('OK app/Models/User.php casts +2 ~0 in $casts'); + expect(Console::read('app/Models/User.php')) + ->toContain("'email_verified_at' => 'datetime',") + ->toContain("'is_admin' => 'boolean',") + ->toContain("'password' => 'hashed',"); +}); + +it('writes to the casts() method when the model has one', function () { + Console::write('app/Models/Project.php', <<<'PHP' + 'datetime', + ]; + } + } + PHP); + + $result = Console::run('archetype:set-casts app/Models/Project.php archived=boolean'); + + expect($result->lines()[0])->toBe('OK app/Models/Project.php casts +1 ~0 in casts()'); + + $source = Console::read('app/Models/Project.php'); + + expect($source)->toContain("'archived' => 'boolean',"); + expect($source)->not->toContain('protected $casts'); +}); + +it('takes an expression as the cast', function () { + Console::run('archetype:set-casts app/Models/User.php status=Status::class role=\'AsEnum:role\''); + + expect(Console::read('app/Models/User.php')) + ->toContain("'status' => Status::class,") + ->toContain("'role' => 'AsEnum:role',"); +}); + +it('creates the property when the model casts nothing yet', function () { + Console::write('app/Models/Task.php', <<<'PHP' + toContain("protected \$casts = [\n 'done' => 'boolean',\n ];"); +}); + +it('updates a cast rather than duplicating it', function () { + $result = Console::run('archetype:set-casts app/Models/User.php email_verified_at=immutable_datetime'); + + expect($result->lines()[0])->toBe('OK app/Models/User.php casts +0 ~1 in $casts'); + expect(Console::read('app/Models/User.php')) + ->toContain("'email_verified_at' => 'immutable_datetime',") + ->not->toContain("'email_verified_at' => 'datetime',"); +}); + +it('skips casts already set', function () { + $result = Console::run('archetype:set-casts app/Models/User.php email_verified_at=datetime'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Models/User.php casts unchanged']); +}); + +it('rejects a pair that is not one', function () { + $result = Console::run('archetype:set-casts app/Models/User.php nonsense'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain("expected field=cast, got 'nonsense'"); +}); diff --git a/tests/Feature/Console/ShowCommandTest.php b/tests/Feature/Console/ShowCommandTest.php new file mode 100644 index 0000000..fcefaf1 --- /dev/null +++ b/tests/Feature/Console/ShowCommandTest.php @@ -0,0 +1,56 @@ + 'required|string|max:255', + ]; + } + } + PHP); +}); + +it('prints a method exactly as written, doc block included', function () { + $result = Console::run('archetype:show app/Http/Requests/StoreTaskRequest.php rules'); + + expect($result->succeeded())->toBeTrue(); + expect($result->output)->toContain('app/Http/Requests/StoreTaskRequest.php::rules'); + expect($result->output)->toContain(' * The validation rules.'); + expect($result->output)->toContain(" 'title' => 'required|string|max:255',"); +}); + +it('returns the source under a json key', function () { + $payload = Console::run('archetype:show app/Http/Requests/StoreTaskRequest.php rules --json')->json(); + + expect($payload['method'])->toBe('rules'); + expect($payload['source'])->toContain('public function rules(): array'); +}); + +it('fails when the method is not there', function () { + $result = Console::run('archetype:show app/Http/Requests/StoreTaskRequest.php missing'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain("no method 'missing'"); +}); + +it('finds the method across a directory', function () { + $result = Console::run('archetype:show app/Http/Requests rules'); + + expect($result->succeeded())->toBeTrue(); + expect($result->output)->toContain('StoreTaskRequest.php::rules'); +}); diff --git a/tests/Feature/Console/StructureCommandsTest.php b/tests/Feature/Console/StructureCommandsTest.php new file mode 100644 index 0000000..47ef482 --- /dev/null +++ b/tests/Feature/Console/StructureCommandsTest.php @@ -0,0 +1,138 @@ + 'app/Models/User.php', + 'imports' => ['App\Contracts\Auditable', 'Illuminate\Support\Str'], + ]); + + expect($result->lines()[0])->toBe('OK app/Models/User.php import +2'); + expect(Console::read('app/Models/User.php')) + ->toContain('use App\Contracts\Auditable;') + ->toContain('use Illuminate\Support\Str;'); +}); + +it('skips imports already there', function () { + $result = Console::run('archetype:add-use', [ + 'target' => 'app/Models/User.php', + 'imports' => ['Illuminate\Notifications\Notifiable'], + ]); + + expect($result->lines())->toBe(['SKIP app/Models/User.php imports unchanged']); +}); + +it('removes imports', function () { + Console::run('archetype:remove-use', [ + 'target' => 'app/Models/User.php', + 'imports' => ['Illuminate\Contracts\Auth\MustVerifyEmail'], + ]); + + expect(Console::read('app/Models/User.php'))->not->toContain('MustVerifyEmail'); +}); + +it('uses a trait and imports it in one step', function () { + Console::run('archetype:add-trait', [ + 'target' => 'app/Models/User.php', + 'traits' => ['Illuminate\Database\Eloquent\SoftDeletes'], + ]); + + expect(Console::read('app/Models/User.php')) + ->toContain('use Illuminate\Database\Eloquent\SoftDeletes;') + ->toContain('use SoftDeletes;'); +}); + +it('implements an interface and imports it in one step', function () { + Console::run('archetype:add-implements', [ + 'target' => 'app/Models/User.php', + 'interfaces' => ['Illuminate\Contracts\Auth\MustVerifyEmail'], + ]); + + expect(Console::read('app/Models/User.php')) + ->toContain('class User extends Authenticatable implements MustVerifyEmail'); +}); + +it('sets the parent class', function () { + Console::run('archetype:set-extends', [ + 'target' => 'app/Models/User.php', + 'parent' => 'Illuminate\Database\Eloquent\Model', + ]); + + expect(Console::read('app/Models/User.php')) + ->toContain('use Illuminate\Database\Eloquent\Model;') + ->toContain('class User extends Model'); +}); + +it('skips a parent class already set', function () { + $result = Console::run('archetype:set-extends app/Models/User.php Authenticatable'); + + expect($result->lines())->toBe(['SKIP app/Models/User.php extends unchanged']); +}); + +it('sets the namespace', function () { + Console::run('archetype:set-namespace', [ + 'target' => 'app/Models/User.php', + 'namespace' => 'App\Domain\Models', + ]); + + expect(Console::read('app/Models/User.php'))->toContain('namespace App\Domain\Models;'); +}); + +it('renames the class', function () { + Console::run('archetype:rename-class app/Models/User.php Account'); + + expect(Console::read('app/Models/User.php'))->toContain('class Account extends Authenticatable'); +}); + +it('renames an enum', function () { + Console::write('app/Enums/Status.php', <<<'PHP' + toContain('enum ProjectStatus: string'); +}); + +it('sets and removes a class constant', function () { + Console::run('archetype:set-const app/Models/User.php HOME /dashboard'); + + expect(Console::read('app/Models/User.php'))->toContain("const HOME = '/dashboard';"); + + Console::run('archetype:remove-const app/Models/User.php HOME'); + + expect(Console::read('app/Models/User.php'))->not->toContain('HOME'); +}); + +it('sets a constant on an interface', function () { + Console::write('app/Contracts/Payable.php', <<<'PHP' + succeeded())->toBeTrue(); + expect(Console::read('app/Contracts/Payable.php'))->toContain("const CURRENCY = 'EUR';"); +}); + +it('skips a constant already set', function () { + Console::run('archetype:set-const app/Models/User.php HOME /dashboard'); + $again = Console::run('archetype:set-const app/Models/User.php HOME /dashboard'); + + expect($again->lines())->toBe(['SKIP app/Models/User.php HOME unchanged']); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 85c43af..adc9cb0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -5,3 +5,10 @@ use Archetype\Tests\TestCase; uses(TestCase::class)->in(__DIR__); + +// The console writes in place, so its tests run against an application whose +// output root is the application itself rather than the isolated `.output` +// directory the rest of the suite writes to. +uses()->beforeEach(function () { + config(['archetype.roots.output.root' => base_path()]); +})->in(__DIR__.'/Feature/Console'); diff --git a/tests/Support/Console.php b/tests/Support/Console.php new file mode 100644 index 0000000..6eb1ea0 --- /dev/null +++ b/tests/Support/Console.php @@ -0,0 +1,64 @@ +status = Artisan::call($command, $arguments, $buffer); + $this->output = trim($buffer->fetch()); + } + + public static function run(string $command, array $arguments = []): self + { + return new self($command, $arguments); + } + + /** @return array */ + public function lines(): array + { + return $this->output === '' ? [] : explode("\n", $this->output); + } + + public function json(): array + { + return json_decode($this->output, true) ?? []; + } + + public function succeeded(): bool + { + return $this->status === 0; + } + + /** Put a file into the application under test. Wiped again by the next test's setUp. */ + public static function write(string $path, string $contents): string + { + File::ensureDirectoryExists(dirname(base_path($path))); + File::put(base_path($path), $contents); + + return $path; + } + + public static function read(string $path): string + { + return File::get(base_path($path)); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index b4c3780..26a5148 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -66,7 +66,11 @@ protected function cleanupDirectories() Config::get('archetype.roots.debug.root'), Config::get('archetype.roots.output.root'), ])->filter(function ($directory) { - return File::isDirectory($directory); + // The console tests point the output root at the application + // itself, and emptying that would take the fixture with it. + return $directory + && File::isDirectory($directory) + && realpath($directory) !== realpath(base_path()); })->each(function ($directory) { File::deleteDirectory($directory); }); From b734d9ecdc363c1ff0c218db6608dc70125a8517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20J=C3=BCrisoo?= Date: Sat, 29 Aug 2026 14:11:33 +0200 Subject: [PATCH 2/4] Keep the CLI out of the PHP API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit widened five endpoints from `class` to `classLike`, changed the PSR-2 printer's return-type spacing, added enum() and enumCase() to the query builder, and put --json on archetype:errors. None of that belongs in a change whose job is to add a command line, and one of them was an outright regression: on a file declaring both an interface and a class, className() started answering about the interface and property() started writing into it, which is not valid PHP. All of it is reverted. `src/` is now the new `src/Console` tree plus two lines in the service provider, and nothing an existing user calls behaves differently. The console pays for that in reach, and says so rather than pretending otherwise. The endpoints it drives address `class` declarations, so the property, constant, interface, trait, parent-class and rename operations refuse an enum, interface or trait outright: ERR app/Enums/Status.php archetype:add-implements only works on classes, and this is an enum Refusing up front rather than after the fact matters for the three operations that import a name before using it. add-implements on an enum used to write the import, fail to add the interface, and report OK — a half-done change is indistinguishable from a whole one once the file has changed, so the render-and-compare check cannot catch it. inspect, show, find, the method operations, add-case and set-array-key have no such limit; they read and write the declaration through the console's own code whatever it is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x --- CHANGELOG.md | 26 ++++++------- docs.md | 22 ++++++++++- readme.md | 3 +- src/Commands/ErrorsCommand.php | 11 +----- src/Console/Commands/AddImplementsCommand.php | 2 + src/Console/Commands/AddToPropertyCommand.php | 2 +- src/Console/Commands/AddTraitCommand.php | 2 + src/Console/Commands/EmptyPropertyCommand.php | 2 +- src/Console/Commands/RemoveConstCommand.php | 2 + .../Commands/RemovePropertyCommand.php | 2 + src/Console/Commands/RenameClassCommand.php | 2 + src/Console/Commands/SetCastsCommand.php | 2 +- src/Console/Commands/SetConstCommand.php | 2 + src/Console/Commands/SetExtendsCommand.php | 2 + src/Console/Commands/SetPropertyCommand.php | 2 +- src/Console/MutationCommand.php | 39 +++++++++++++++---- src/Console/Support/Manifest.php | 15 ++++++- src/Endpoints/PHP/ClassConstant.php | 10 ++--- src/Endpoints/PHP/ClassName.php | 4 +- src/Endpoints/PHP/Implements_.php | 34 ++++++---------- src/Endpoints/PHP/Property.php | 10 ++--- src/Endpoints/PHP/UseTrait.php | 4 +- src/ServiceProvider.php | 6 ++- src/Support/PSR2PrettyPrinter.php | 2 +- src/Traits/PHPParserClassMap.php | 18 --------- tests/Feature/Console/EnumCommandsTest.php | 12 +++--- tests/Feature/Console/MethodCommandsTest.php | 4 +- .../Feature/Console/MutationContractTest.php | 9 +++-- .../Feature/Console/StructureCommandsTest.php | 15 ++++--- 29 files changed, 153 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f539dbb..c0e061f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.1.0] - 2026-08-29 -Adds a command line to Archetype. Every existing PHP API is untouched; a handful -of endpoints now reach constructs they previously matched but silently ignored. +Adds a command line to Archetype. The PHP API is untouched — no endpoint, no +printer and no query builder changes. Everything new lives in `src/Console`. ### Added @@ -28,13 +28,17 @@ of endpoints now reach constructs they previously matched but silently ignored. - Every operation takes a single target, which is a path, a class name, or a directory — where a directory means every class beneath it, narrowed with `--extends`, `--implements`, `--uses-trait` or `--matching`. -- Every operation takes `--json`. +- Every operation but `errors` takes `--json`. - Every mutation re-renders the file and compares before reporting. One that matched nothing exits non-zero rather than reporting a success that wrote nothing; one whose change is already present reports `SKIP`, which makes the operations safe to repeat. - Every mutation answers with a diff of what it changed, and takes `--dry-run` to show that diff without writing. +- An operation that cannot act on the construct it was pointed at refuses before + writing anything, rather than writing the part it can. `add-implements`, + `add-trait` and `set-extends` import a name before using it, so a half-done + change would otherwise look like a whole one. - `archetype apply` runs a script of operations in one invocation, reading a file or standard input. - `set-casts` writes to whichever casting mechanism a model already uses — the @@ -44,18 +48,14 @@ of endpoints now reach constructs they previously matched but silently ignored. `toArray()`, `casts()` and `definition()` keep their contents. - `add-relation` covers all eleven Eloquent relation types, with pivot tables, explicit keys, `withPivot`, `withTimestamps` and a custom pivot model. -- `enum()` and `enumCase()` query methods on the `ASTQueryBuilder`. -- `php artisan archetype:errors` takes `--json`. -### Changed +### Known limits -- `className()`, `classConstant()`, `useTrait()` and `property()` now match any - class-like declaration rather than only `class`, so they work on enums, - interfaces and traits. `implements()` matches classes and enums. Previously - these silently did nothing on anything but a class. -- The PSR-2 pretty printer prints `function name(): Type` rather than - `function name() : Type`. Only newly printed declarations are affected; - untouched code keeps its own formatting. +- The property, constant, interface, trait, parent-class and rename operations + work on classes only, because the endpoints they drive address `class` + declarations. On an enum, interface or trait they refuse and write nothing. + `inspect`, `show`, `find`, the method operations, `add-case` and + `set-array-key` have no such limit. ## [2.0.1] - 2026-08-25 diff --git a/docs.md b/docs.md index 27e875a..ed05b38 100644 --- a/docs.md +++ b/docs.md @@ -200,6 +200,8 @@ These options are rejected on a single-file target rather than ignored. |---|---| | `--json` | Emit JSON instead of the compact line format | +`errors` predates this console and does not take it. + ### Options every mutation takes | Option | Effect | @@ -367,8 +369,7 @@ archetype add-case app/Enums/ProjectStatus.php OnHold on_hold archetype add-case app/Enums/Suit.php Spades # pure enum, no backing value ``` -Constants work on classes, interfaces, enums and traits. New enum cases are -added after the ones already there. +New enum cases are added after the ones already there. ### Methods @@ -383,6 +384,23 @@ archetype remove-method app/Models/Project.php isActive Methods can be added to a class, enum, interface or trait, and are appended after the methods already there. +### What the console will not do + +`set-property`, `add-to-property`, `empty-property`, `remove-property`, +`set-casts`, `set-const`, `remove-const`, `add-implements`, `add-trait`, +`set-extends` and `rename-class` work on classes only. On an enum, interface or +trait they refuse and write nothing, rather than writing the part they can and +reporting success: + +``` +$ archetype add-implements app/Enums/Status.php 'App\Contracts\HasColor' +ERR app/Enums/Status.php archetype:add-implements only works on classes, and this is an enum +``` + +`inspect`, `show`, `find`, `add-method`, `replace-method`, `remove-method`, +`add-case` and `set-array-key` have no such limit — they read or write the +declaration whatever it is. + ### Several operations in one call ```bash diff --git a/readme.md b/readme.md index eec179b..6454ff4 100644 --- a/readme.md +++ b/readme.md @@ -256,7 +256,8 @@ OK app/Models/User.php $fillable +1 Three rules hold for every operation that writes: * it re-renders the file and compares, so a change that matched nothing is an - error and exits non-zero — never a success that wrote nothing; + error and exits non-zero — never a success that wrote nothing, and never half + a change reported as a whole one; * it answers with a diff, so you do not have to read the file back to see what happened; * a change already applied is `SKIP`, not `OK` and not an error, so operations diff --git a/src/Commands/ErrorsCommand.php b/src/Commands/ErrorsCommand.php index f2c4dc3..a50b579 100644 --- a/src/Commands/ErrorsCommand.php +++ b/src/Commands/ErrorsCommand.php @@ -9,7 +9,7 @@ class ErrorsCommand extends Command { - protected $signature = 'archetype:errors {--json : Emit JSON instead of a table}'; + protected $signature = 'archetype:errors'; protected $description = 'List dirty files'; protected $result; protected $errors; @@ -33,15 +33,6 @@ public function handle() } }); - if ($this->option('json')) { - $this->output->writeln(json_encode([ - 'ok' => $this->errors->isEmpty(), - 'errors' => $this->errors->values()->all(), - ], JSON_UNESCAPED_SLASHES)); - - return; - } - if ($this->errors->isEmpty()) { $this->info('No errors found!'); return; diff --git a/src/Console/Commands/AddImplementsCommand.php b/src/Console/Commands/AddImplementsCommand.php index a6232c5..4524a5c 100644 --- a/src/Console/Commands/AddImplementsCommand.php +++ b/src/Console/Commands/AddImplementsCommand.php @@ -18,6 +18,8 @@ protected function perform(): int $interfaces = $this->argument('interfaces'); return $this->mutate(function (LaravelFile $file) use ($interfaces) { + $this->requireKind($file, ['class']); + $existing = array_map(fn ($name) => class_basename($name), $file->implements()); $wanted = array_values(array_filter( diff --git a/src/Console/Commands/AddToPropertyCommand.php b/src/Console/Commands/AddToPropertyCommand.php index c741ac1..b2925a3 100644 --- a/src/Console/Commands/AddToPropertyCommand.php +++ b/src/Console/Commands/AddToPropertyCommand.php @@ -25,7 +25,7 @@ protected function perform(): int $values = $this->argument('values'); return $this->mutate(function (LaravelFile $file) use ($name, $values) { - $this->requirePropertyHolder($file); + $this->requireKind($file, ['class']); $visibility = $this->visibilityOf($file, $name, $this->option('visibility')); $existing = $file->property($name); diff --git a/src/Console/Commands/AddTraitCommand.php b/src/Console/Commands/AddTraitCommand.php index 4a44cc8..f73adeb 100644 --- a/src/Console/Commands/AddTraitCommand.php +++ b/src/Console/Commands/AddTraitCommand.php @@ -18,6 +18,8 @@ protected function perform(): int $traits = $this->argument('traits'); return $this->mutate(function (LaravelFile $file) use ($traits) { + $this->requireKind($file, ['class']); + $existing = array_map(fn ($trait) => class_basename($trait), $file->useTrait()); $wanted = array_values(array_filter( diff --git a/src/Console/Commands/EmptyPropertyCommand.php b/src/Console/Commands/EmptyPropertyCommand.php index f1df961..ee1dc89 100644 --- a/src/Console/Commands/EmptyPropertyCommand.php +++ b/src/Console/Commands/EmptyPropertyCommand.php @@ -19,7 +19,7 @@ protected function perform(): int $name = $this->argument('name'); return $this->mutate(function (LaravelFile $file) use ($name) { - $this->requirePropertyHolder($file); + $this->requireKind($file, ['class']); if (! (new Introspector($file))->hasProperty($name)) { return $this->unchanged("no \$$name"); diff --git a/src/Console/Commands/RemoveConstCommand.php b/src/Console/Commands/RemoveConstCommand.php index 5fc79f6..fada2f0 100644 --- a/src/Console/Commands/RemoveConstCommand.php +++ b/src/Console/Commands/RemoveConstCommand.php @@ -19,6 +19,8 @@ protected function perform(): int $name = $this->argument('name'); return $this->mutate(function (LaravelFile $file) use ($name) { + $this->requireKind($file, ['class']); + $present = collect((new Introspector($file))->constants()) ->contains(fn ($constant) => $constant['name'] === $name); diff --git a/src/Console/Commands/RemovePropertyCommand.php b/src/Console/Commands/RemovePropertyCommand.php index 1e1c2c0..c123840 100644 --- a/src/Console/Commands/RemovePropertyCommand.php +++ b/src/Console/Commands/RemovePropertyCommand.php @@ -19,6 +19,8 @@ protected function perform(): int $name = $this->argument('name'); return $this->mutate(function (LaravelFile $file) use ($name) { + $this->requireKind($file, ['class']); + if (! (new Introspector($file))->hasProperty($name)) { return $this->unchanged("no \$$name"); } diff --git a/src/Console/Commands/RenameClassCommand.php b/src/Console/Commands/RenameClassCommand.php index bcfaa5d..5a21da0 100644 --- a/src/Console/Commands/RenameClassCommand.php +++ b/src/Console/Commands/RenameClassCommand.php @@ -24,6 +24,8 @@ protected function perform(): int $name = $this->argument('name'); return $this->mutate(function (LaravelFile $file) use ($name) { + $this->requireKind($file, ['class']); + if ((new Introspector($file))->name() === $name) { return $this->unchanged('class name unchanged'); } diff --git a/src/Console/Commands/SetCastsCommand.php b/src/Console/Commands/SetCastsCommand.php index ba0c50d..4fc75de 100644 --- a/src/Console/Commands/SetCastsCommand.php +++ b/src/Console/Commands/SetCastsCommand.php @@ -32,7 +32,7 @@ protected function perform(): int $casts = $this->casts(); return $this->mutate(function (LaravelFile $file) use ($casts) { - $this->requirePropertyHolder($file); + $this->requireKind($file, ['class']); [$array, $where] = $this->literal($file); diff --git a/src/Console/Commands/SetConstCommand.php b/src/Console/Commands/SetConstCommand.php index b17a5e1..d1fdb4d 100644 --- a/src/Console/Commands/SetConstCommand.php +++ b/src/Console/Commands/SetConstCommand.php @@ -22,6 +22,8 @@ protected function perform(): int $raw = $this->argument('value'); return $this->mutate(function (LaravelFile $file) use ($name, $raw) { + $this->requireKind($file, ['class']); + foreach ((new Introspector($file))->constants() as $constant) { if ($constant['name'] === $name && $constant['evaluated'] && $constant['value'] === Code::value($raw)) { return $this->unchanged("$name unchanged"); diff --git a/src/Console/Commands/SetExtendsCommand.php b/src/Console/Commands/SetExtendsCommand.php index c86c075..37b3a1d 100644 --- a/src/Console/Commands/SetExtendsCommand.php +++ b/src/Console/Commands/SetExtendsCommand.php @@ -18,6 +18,8 @@ protected function perform(): int $parent = $this->argument('parent'); return $this->mutate(function (LaravelFile $file) use ($parent) { + $this->requireKind($file, ['class']); + if ($file->extends() === class_basename($parent)) { return $this->unchanged('extends unchanged'); } diff --git a/src/Console/Commands/SetPropertyCommand.php b/src/Console/Commands/SetPropertyCommand.php index 59cf57f..fa0c89a 100644 --- a/src/Console/Commands/SetPropertyCommand.php +++ b/src/Console/Commands/SetPropertyCommand.php @@ -23,7 +23,7 @@ protected function perform(): int $raw = $this->argument('value'); return $this->mutate(function (LaravelFile $file) use ($name, $raw) { - $this->requirePropertyHolder($file); + $this->requireKind($file, ['class']); $visibility = $this->visibilityOf($file, $name, $this->option('visibility')); diff --git a/src/Console/MutationCommand.php b/src/Console/MutationCommand.php index 127c26b..2ea6d27 100644 --- a/src/Console/MutationCommand.php +++ b/src/Console/MutationCommand.php @@ -6,6 +6,7 @@ use Archetype\Console\Support\Introspector; use Archetype\Facades\LaravelFile; use Archetype\LaravelFile as File; +use Illuminate\Support\Str; use InvalidArgumentException; use Symfony\Component\Console\Input\InputOption; use Throwable; @@ -135,20 +136,42 @@ protected function report(string $status, string $path, string $detail, string $ } /** - * Refuse a property write on a construct that cannot hold one. + * Refuse an operation on a construct it does not support. * - * The endpoints match any class-like now, so an enum or an interface would - * otherwise accept a property and produce a file PHP cannot parse. + * The endpoints this console drives address `class` declarations, so on an + * enum, interface or trait most of them match nothing. That alone would be + * caught by the did-anything-change check — but an operation that imports a + * name before using it writes the import either way, and a file that + * changed by half looks exactly like a file that changed. Saying no up + * front is the only version of this that cannot mislead. + * + * @param array $kinds the constructs the operation supports */ - protected function requirePropertyHolder(File $file): void + protected function requireKind(File $file, array $kinds): void { $kind = (new Introspector($file))->kind(); - if (in_array($kind, ['enum', 'interface'], true)) { - throw new InvalidArgumentException( - ($kind === 'enum' ? 'an' : 'a')." $kind cannot have properties" - ); + if (in_array($kind, $kinds, true)) { + return; } + + throw new InvalidArgumentException(sprintf( + '%s only works on %s, and this is %s %s', + $this->getName(), + $this->list($kinds), + in_array($kind[0], ['a', 'e', 'i', 'o', 'u'], true) ? 'an' : 'a', + $kind + )); + } + + /** @param array $items */ + protected function list(array $items): string + { + $items = array_map(fn ($item) => Str::plural($item), $items); + + return count($items) < 2 + ? $items[0] + : implode(', ', array_slice($items, 0, -1)).' and '.end($items); } /** diff --git a/src/Console/Support/Manifest.php b/src/Console/Support/Manifest.php index 7aa907e..a461b79 100644 --- a/src/Console/Support/Manifest.php +++ b/src/Console/Support/Manifest.php @@ -147,12 +147,23 @@ class Manifest ], ]; - /** @return array every command the service provider registers */ + /** + * The console's own commands. + * + * `errors` is listed above so it shows up in the operation map, but it + * predates this console and the service provider registers it directly, so + * it is not returned here. + * + * @return array + */ public static function commands(): array { return array_merge( [Commands\HelpCommand::class], - array_values(array_map(fn ($operation) => $operation[0], self::OPERATIONS)) + array_values(array_filter( + array_map(fn ($operation) => $operation[0], self::OPERATIONS), + fn ($class) => str_starts_with($class, 'Archetype\\Console\\') + )) ); } diff --git a/src/Endpoints/PHP/ClassConstant.php b/src/Endpoints/PHP/ClassConstant.php index 5e63e35..f96e818 100755 --- a/src/Endpoints/PHP/ClassConstant.php +++ b/src/Endpoints/PHP/ClassConstant.php @@ -122,7 +122,7 @@ protected function addToNumeric(string $key, $new, $existing = 0) protected function remove(string $key) { return $this->file->astQuery() - ->classLike() + ->class() ->classConst() ->where(function ($query) use ($key) { return $query->const() @@ -170,7 +170,7 @@ protected function getWithReflection(string $name) protected function getWithParser(string $key) { return $this->file->astQuery() - ->classLike() + ->class() ->classConst()->consts ->where('name->name', $key) ->value @@ -183,7 +183,7 @@ protected function set(string $key, $value = Types::NO_VALUE) $value = $this->prepareValue($value); $propertyExists = $this->file->astQuery() - ->classLike() + ->class() ->classConst()->consts ->where('name->name', $key) ->isNotEmpty(); @@ -194,7 +194,7 @@ protected function set(string $key, $value = Types::NO_VALUE) protected function create(string $key, $value) { return $this->file->astQuery() - ->classLike() + ->class() ->insertStmt($this->makeConstant($key, $value)) ->commit() ->end() @@ -204,7 +204,7 @@ protected function create(string $key, $value) protected function update(string $key, $value) { return $this->file->astQuery() - ->classLike() + ->class() ->classConst()->consts ->where('name->name', $key) ->replaceProperty( diff --git a/src/Endpoints/PHP/ClassName.php b/src/Endpoints/PHP/ClassName.php index f7ad964..9bac073 100644 --- a/src/Endpoints/PHP/ClassName.php +++ b/src/Endpoints/PHP/ClassName.php @@ -31,7 +31,7 @@ public function className(?string $name = null) protected function get() { $className = $this->file->astQuery() - ->classLike() + ->class() ->name ->name ->first(); @@ -48,7 +48,7 @@ protected function get() protected function set(string $newClassName) { return $this->file->astQuery() - ->classLike() + ->class() ->name ->replaceProperty('name', $newClassName) ->commit() diff --git a/src/Endpoints/PHP/Implements_.php b/src/Endpoints/PHP/Implements_.php index ee22be0..ff55de0 100644 --- a/src/Endpoints/PHP/Implements_.php +++ b/src/Endpoints/PHP/Implements_.php @@ -33,33 +33,23 @@ public function implements($name = null) protected function get() { - return collect(['class', 'enum'])->flatMap(function ($construct) { - return $this->file->astQuery() - ->$construct() - ->implements - ->get() - ->map(fn ($node) => $node->name); - })->toArray(); + return $this->file->astQuery() + ->class() + ->implements + ->get() + ->map(fn ($node) => $node->name)->toArray(); } - /** - * Classes and enums, because those are the two constructs that implement. - * An interface extends rather than implements, and giving it an `implements` - * would produce something PHP cannot parse. - */ protected function set($newImplements) { $newImplements = $this->makeNameObject($newImplements); - - foreach (['class', 'enum'] as $construct) { - $this->file->astQuery() - ->$construct() - ->replaceProperty('implements', $newImplements) - ->commit() - ->end(); - } - - return $this->file->continue(); + + return $this->file->astQuery() + ->class() + ->replaceProperty('implements', $newImplements) + ->commit() + ->end() + ->continue(); } protected function add($newImplements) diff --git a/src/Endpoints/PHP/Property.php b/src/Endpoints/PHP/Property.php index 18d83a2..7481128 100644 --- a/src/Endpoints/PHP/Property.php +++ b/src/Endpoints/PHP/Property.php @@ -148,7 +148,7 @@ protected function addToNumeric(string $key, $new, $existing = 0) protected function remove(string $key) { return $this->file->astQuery() - ->classLike() + ->class() ->property() ->where(function ($query) use ($key) { return $query->propertyProperty() @@ -196,7 +196,7 @@ protected function getWithReflection(string $name) protected function getWithParser(string $key) { return $this->file->astQuery() - ->classLike() + ->class() ->propertyProperty() ->where('name->name', $key) ->default @@ -209,7 +209,7 @@ protected function set(string $key, $value = Types::NO_VALUE) $value = $this->prepareValue($value); $propertyExists = $this->file->astQuery() - ->classLike() + ->class() ->propertyProperty() ->where('name->name', $key) ->get()->isNotEmpty(); @@ -220,7 +220,7 @@ protected function set(string $key, $value = Types::NO_VALUE) protected function create(string $key, $value) { return $this->file->astQuery() - ->classLike() + ->class() ->insertStmt($this->makeProperty($key, $value)) ->commit() ->end() @@ -230,7 +230,7 @@ protected function create(string $key, $value) protected function update(string $key, $value) { return $this->file->astQuery() - ->classLike() + ->class() ->property() ->where->propertyProperty('name->name')->is($key)->get() ->replace(function ($property) { diff --git a/src/Endpoints/PHP/UseTrait.php b/src/Endpoints/PHP/UseTrait.php index 1fb5ad4..2d0f36d 100644 --- a/src/Endpoints/PHP/UseTrait.php +++ b/src/Endpoints/PHP/UseTrait.php @@ -28,7 +28,7 @@ public function useTrait($value = null) protected function get() { $r = $this->file->astQuery() - ->classLike() + ->class() ->traitUse() ->name() ->get() @@ -40,7 +40,7 @@ protected function get() protected function add($newUseTraitNames) { return $this->file->astQuery() - ->classLike() + ->class() ->insertStmts( collect(Arr::wrap($newUseTraitNames)) ->reverse() diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 33557e0..bf4c7f4 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\ServiceProvider as BaseServiceProvider; +use Archetype\Commands\ErrorsCommand; use Archetype\Console\Support\Manifest; use Archetype\Factories\LaravelFileFactory; use Archetype\Factories\PHPFileFactory; @@ -42,6 +43,9 @@ protected function publishConfig() protected function registerCommands() { - $this->commands(Manifest::commands()); + $this->commands([ + ErrorsCommand::class, + ...Manifest::commands(), + ]); } } diff --git a/src/Support/PSR2PrettyPrinter.php b/src/Support/PSR2PrettyPrinter.php index 671e8ef..4adbe23 100644 --- a/src/Support/PSR2PrettyPrinter.php +++ b/src/Support/PSR2PrettyPrinter.php @@ -30,7 +30,7 @@ protected function pStmt_ClassMethod(ClassMethod $node): string . $this->pModifiers($node->flags) . 'function ' . ($node->byRef ? '&' : '') . $node->name . '(' . $this->pMaybeMultiline($node->params) . ')' - . (null !== $node->returnType ? ': ' . $this->p($node->returnType) : '') + . (null !== $node->returnType ? ' : ' . $this->p($node->returnType) : '') . (null !== $node->stmts ? $this->nl . '{' . $this->pStmts($node->stmts) . $this->nl . '}' : ';'); diff --git a/src/Traits/PHPParserClassMap.php b/src/Traits/PHPParserClassMap.php index 40052d7..12a5740 100644 --- a/src/Traits/PHPParserClassMap.php +++ b/src/Traits/PHPParserClassMap.php @@ -42,8 +42,6 @@ trait PHPParserClassMap 'empty' => \PhpParser\Node\Expr\Empty_::class, 'encapsed' => \PhpParser\Node\Scalar\Encapsed::class, 'encapsedStringPart' => \PhpParser\Node\Scalar\EncapsedStringPart::class, - 'enum' => \PhpParser\Node\Stmt\Enum_::class, - 'enumCase' => \PhpParser\Node\Stmt\EnumCase::class, 'error' => \PhpParser\Node\Expr\Error::class, 'errorSuppress' => \PhpParser\Node\Expr\ErrorSuppress::class, 'eval' => \PhpParser\Node\Expr\Eval_::class, @@ -405,22 +403,6 @@ public function encapsedStringPart($path = ''): self ); } - public function enum($path = ''): self - { - return $this->traverseIntoClass( - $this->phpParserClassMap[__FUNCTION__], - $path - ); - } - - public function enumCase($path = ''): self - { - return $this->traverseIntoClass( - $this->phpParserClassMap[__FUNCTION__], - $path - ); - } - public function error($path = ''): self { return $this->traverseIntoClass( diff --git a/tests/Feature/Console/EnumCommandsTest.php b/tests/Feature/Console/EnumCommandsTest.php index 5b5ced7..e72611c 100644 --- a/tests/Feature/Console/EnumCommandsTest.php +++ b/tests/Feature/Console/EnumCommandsTest.php @@ -71,14 +71,16 @@ enum Suit expect($result->output)->toContain('not an enum, it is a class'); }); -it('adds an interface and its import to an enum', function () { +it('refuses to add an interface to an enum, and writes nothing at all', function () { + // The implements endpoint addresses classes, so this cannot be done here. + // What matters is that it is refused before the import is written: half a + // change that reports success is worse than no change at all. $result = Console::run('archetype:add-implements', [ 'target' => 'app/Enums/ProjectStatus.php', 'interfaces' => ['App\Contracts\HasColor'], ]); - expect($result->succeeded())->toBeTrue(); - expect(Console::read('app/Enums/ProjectStatus.php')) - ->toContain('use App\Contracts\HasColor;') - ->toContain('enum ProjectStatus: string implements HasColor'); + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('archetype:add-implements only works on classes, and this is an enum'); + expect(Console::read('app/Enums/ProjectStatus.php'))->not->toContain('HasColor'); }); diff --git a/tests/Feature/Console/MethodCommandsTest.php b/tests/Feature/Console/MethodCommandsTest.php index a03ff3a..40f4c7a 100644 --- a/tests/Feature/Console/MethodCommandsTest.php +++ b/tests/Feature/Console/MethodCommandsTest.php @@ -50,7 +50,7 @@ public function isActive() expect($result->succeeded())->toBeTrue(); expect(Console::read('app/Models/Project.php')) - ->toContain('public function isActive(): bool') + ->toContain('public function isActive() : bool') ->not->toContain('return true;'); }); @@ -94,5 +94,5 @@ enum Status: string $result = Console::run('archetype:add-method app/Enums/Status.php --code="public function label(): string { return ucfirst(\$this->value); }"'); expect($result->succeeded())->toBeTrue(); - expect(Console::read('app/Enums/Status.php'))->toContain('public function label(): string'); + expect(Console::read('app/Enums/Status.php'))->toContain('public function label() : string'); }); diff --git a/tests/Feature/Console/MutationContractTest.php b/tests/Feature/Console/MutationContractTest.php index e9f3d32..cc7965a 100644 --- a/tests/Feature/Console/MutationContractTest.php +++ b/tests/Feature/Console/MutationContractTest.php @@ -36,17 +36,18 @@ expect(Console::read('app/Models/User.php'))->toBe($before); }); -it('exits non-zero when the change matched nothing', function () { +it('exits non-zero when there is nothing it could act on', function () { Console::write('app/helpers.php', "succeeded())->toBeFalse(); - expect($result->output)->toContain('but the file did not change'); + expect($result->output)->toContain('only works on classes, and this is a file'); + expect(Console::read('app/helpers.php'))->toContain('function thing()'); }); -it('refuses a change the construct cannot take', function () { +it('refuses a change the construct cannot take, before writing any of it', function () { Console::write('app/Enums/Status.php', <<<'PHP' succeeded())->toBeFalse(); - expect($result->output)->toContain('an enum cannot have properties'); + expect($result->output)->toContain('archetype:set-property only works on classes, and this is an enum'); expect(Console::read('app/Enums/Status.php'))->not->toContain('table'); }); diff --git a/tests/Feature/Console/StructureCommandsTest.php b/tests/Feature/Console/StructureCommandsTest.php index 47ef482..901aaf5 100644 --- a/tests/Feature/Console/StructureCommandsTest.php +++ b/tests/Feature/Console/StructureCommandsTest.php @@ -85,7 +85,7 @@ expect(Console::read('app/Models/User.php'))->toContain('class Account extends Authenticatable'); }); -it('renames an enum', function () { +it('refuses to rename an enum', function () { Console::write('app/Enums/Status.php', <<<'PHP' toContain('enum ProjectStatus: string'); + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('only works on classes, and this is an enum'); + expect(Console::read('app/Enums/Status.php'))->toContain('enum Status: string'); }); it('sets and removes a class constant', function () { @@ -112,7 +114,7 @@ enum Status: string expect(Console::read('app/Models/User.php'))->not->toContain('HOME'); }); -it('sets a constant on an interface', function () { +it('refuses to set a constant on an interface', function () { Console::write('app/Contracts/Payable.php', <<<'PHP' succeeded())->toBeTrue(); - expect(Console::read('app/Contracts/Payable.php'))->toContain("const CURRENCY = 'EUR';"); + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('only works on classes, and this is an interface'); + expect(Console::read('app/Contracts/Payable.php'))->not->toContain('CURRENCY'); }); it('skips a constant already set', function () { From c52c465ecc2d5ec9a47c852c2a77c39d205bdafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20J=C3=BCrisoo?= Date: Sat, 29 Aug 2026 14:40:14 +0200 Subject: [PATCH 3/4] Name the commands after the endpoints they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut invented a second vocabulary: set-property, add-to-property, rename-class, add-use, set-extends. Half of those rename an endpoint that already had a name, which means anyone who knows the PHP API has to learn the CLI's dialect of it too. The directive system was sitting right there and maps onto flags almost exactly. So a command named after an endpoint is now that endpoint. Same arguments, same directives as flags, same result: `archetype property fillable nickname --add` is `$file->add()->property('fillable', 'nickname')`. Give a value and it writes, give none and it reads. property className extends implements namespace use useTrait classConstant methodNames make fillable hidden visible guarded unguarded casts dates table connection timestamps hasOne hasMany belongsTo belongsToMany Operations with names of their own are the ones with no PHP equivalent — inspect, show, find, set-array-key, add-case, the method operations, apply, and the seven relationship types LaravelFile does not cover. The operation map prints in those two halves so the rule is visible rather than documented somewhere else. Two tests hold the line: every operation listed as an endpoint must be a real method on LaravelFile, and `archetype hasMany Task` must produce byte-identical output to `$file->hasMany('Task')`. With no options given the relationship commands call the endpoint rather than reimplementing it. Two bugs fell out of the rewrite, both from reading a value off a file that already carried a directive — the endpoints read directives off the file, so `$file->add()->use()` as a getter appends instead of answering. State is now read from the syntax tree, and directives are applied at the write itself. The second was mine from the first cut: useTrait() answers with Node\Name objects, so class_basename() on them returned "Name" and the already-uses check never matched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x --- CHANGELOG.md | 44 +-- docs.md | 302 ++++++++++-------- readme.md | 76 +++-- src/Console/Commands/AddImplementsCommand.php | 41 --- src/Console/Commands/AddRelationCommand.php | 79 ----- src/Console/Commands/AddToPropertyCommand.php | 45 --- src/Console/Commands/AddTraitCommand.php | 41 --- src/Console/Commands/AddUseCommand.php | 32 -- src/Console/Commands/ClassConstantCommand.php | 82 +++++ src/Console/Commands/ClassNameCommand.php | 47 +++ src/Console/Commands/EmptyPropertyCommand.php | 33 -- src/Console/Commands/ExtendsCommand.php | 57 ++++ src/Console/Commands/HelpCommand.php | 22 +- src/Console/Commands/ImplementsCommand.php | 75 +++++ src/Console/Commands/MethodNamesCommand.php | 36 +++ src/Console/Commands/ModelPropertyCommand.php | 132 ++++++++ src/Console/Commands/NamespaceCommand.php | 54 ++++ src/Console/Commands/PropertyCommand.php | 132 ++++++++ src/Console/Commands/RelationCommand.php | 104 ++++++ src/Console/Commands/RemoveConstCommand.php | 36 --- .../Commands/RemovePropertyCommand.php | 33 -- src/Console/Commands/RemoveUseCommand.php | 33 -- src/Console/Commands/RenameClassCommand.php | 38 --- src/Console/Commands/SetCastsCommand.php | 107 ------- src/Console/Commands/SetConstCommand.php | 40 --- src/Console/Commands/SetExtendsCommand.php | 34 -- src/Console/Commands/SetNamespaceCommand.php | 30 -- src/Console/Commands/SetPropertyCommand.php | 56 ---- src/Console/Commands/UseCommand.php | 62 ++++ src/Console/Commands/UseTraitCommand.php | 80 +++++ src/Console/Concerns/HasDirectiveFlags.php | 108 +++++++ src/Console/EndpointCommand.php | 99 ++++++ src/Console/Support/Manifest.php | 266 +++++++-------- .../Console/AddRelationCommandTest.php | 114 ------- tests/Feature/Console/ApplyCommandTest.php | 24 +- tests/Feature/Console/EnumCommandsTest.php | 7 +- tests/Feature/Console/HelpCommandTest.php | 41 ++- .../Console/ModelPropertyCommandTest.php | 84 +++++ .../Feature/Console/MutationContractTest.php | 35 +- tests/Feature/Console/PropertyCommandTest.php | 108 +++++++ .../Feature/Console/PropertyCommandsTest.php | 76 ----- tests/Feature/Console/RelationCommandTest.php | 125 ++++++++ tests/Feature/Console/SetCastsCommandTest.php | 92 ------ .../Feature/Console/StructureCommandsTest.php | 162 ++++++---- 44 files changed, 1925 insertions(+), 1399 deletions(-) delete mode 100644 src/Console/Commands/AddImplementsCommand.php delete mode 100644 src/Console/Commands/AddRelationCommand.php delete mode 100644 src/Console/Commands/AddToPropertyCommand.php delete mode 100644 src/Console/Commands/AddTraitCommand.php delete mode 100644 src/Console/Commands/AddUseCommand.php create mode 100644 src/Console/Commands/ClassConstantCommand.php create mode 100644 src/Console/Commands/ClassNameCommand.php delete mode 100644 src/Console/Commands/EmptyPropertyCommand.php create mode 100644 src/Console/Commands/ExtendsCommand.php create mode 100644 src/Console/Commands/ImplementsCommand.php create mode 100644 src/Console/Commands/MethodNamesCommand.php create mode 100644 src/Console/Commands/ModelPropertyCommand.php create mode 100644 src/Console/Commands/NamespaceCommand.php create mode 100644 src/Console/Commands/PropertyCommand.php create mode 100644 src/Console/Commands/RelationCommand.php delete mode 100644 src/Console/Commands/RemoveConstCommand.php delete mode 100644 src/Console/Commands/RemovePropertyCommand.php delete mode 100644 src/Console/Commands/RemoveUseCommand.php delete mode 100644 src/Console/Commands/RenameClassCommand.php delete mode 100644 src/Console/Commands/SetCastsCommand.php delete mode 100644 src/Console/Commands/SetConstCommand.php delete mode 100644 src/Console/Commands/SetExtendsCommand.php delete mode 100644 src/Console/Commands/SetNamespaceCommand.php delete mode 100644 src/Console/Commands/SetPropertyCommand.php create mode 100644 src/Console/Commands/UseCommand.php create mode 100644 src/Console/Commands/UseTraitCommand.php create mode 100644 src/Console/Concerns/HasDirectiveFlags.php create mode 100644 src/Console/EndpointCommand.php delete mode 100644 tests/Feature/Console/AddRelationCommandTest.php create mode 100644 tests/Feature/Console/ModelPropertyCommandTest.php create mode 100644 tests/Feature/Console/PropertyCommandTest.php delete mode 100644 tests/Feature/Console/PropertyCommandsTest.php create mode 100644 tests/Feature/Console/RelationCommandTest.php delete mode 100644 tests/Feature/Console/SetCastsCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e061f..820c459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,17 +13,26 @@ printer and no query builder changes. Everything new lives in `src/Console`. ### Added -- **A command line.** 26 operations, each an Artisan command under `archetype:`, +- **A command line.** Each operation is an Artisan command under `archetype:`, plus an `archetype` binary that finds the application and forwards to it. Run `archetype` with no arguments for the list, or see the [reference](docs.md#command-line-reference). - Reading: `inspect`, `show`, `find`, `errors`. - Writing: `make`, `set-property`, `add-to-property`, `empty-property`, - `remove-property`, `set-casts`, `add-relation`, `set-array-key`, `add-use`, - `remove-use`, `add-trait`, `add-implements`, `set-extends`, `set-namespace`, - `rename-class`, `set-const`, `remove-const`, `add-case`, `add-method`, - `replace-method`, `remove-method`, `apply`. +- **An operation named after an endpoint is that endpoint.** Same arguments, same + directives as flags, same result — `archetype property fillable + nickname --add` is `$file->add()->property('fillable', 'nickname')`. Give a + value and it writes, give none and it reads. Endpoint commands: `property`, + `className`, `extends`, `implements`, `namespace`, `use`, `useTrait`, + `classConstant`, `methodNames`, `make`, the ten `LaravelFile` model properties + (`fillable`, `hidden`, `visible`, `guarded`, `unguarded`, `casts`, `dates`, + `table`, `connection`, `timestamps`) and the four relationships (`hasOne`, + `hasMany`, `belongsTo`, `belongsToMany`). + +- **Operations with names of their own, which have no PHP equivalent**: + `inspect`, `show`, `find`, `set-array-key`, `add-case`, `add-method`, + `replace-method`, `remove-method`, `apply`, and the seven relationship types + `LaravelFile` does not cover (`hasOneThrough`, `hasManyThrough`, `morphOne`, + `morphMany`, `morphTo`, `morphToMany`, `morphedByMany`). - Every operation takes a single target, which is a path, a class name, or a directory — where a directory means every class beneath it, narrowed with @@ -36,25 +45,22 @@ printer and no query builder changes. Everything new lives in `src/Console`. - Every mutation answers with a diff of what it changed, and takes `--dry-run` to show that diff without writing. - An operation that cannot act on the construct it was pointed at refuses before - writing anything, rather than writing the part it can. `add-implements`, - `add-trait` and `set-extends` import a name before using it, so a half-done - change would otherwise look like a whole one. + writing anything, rather than writing the part it can. `implements`, + `useTrait` and `extends` import a name before using it, so a half-done change + would otherwise look like a whole one. - `archetype apply` runs a script of operations in one invocation, reading a file or standard input. -- `set-casts` writes to whichever casting mechanism a model already uses — the - `casts()` method Laravel 11 generates, or the `$casts` property — instead of - adding a second one beside the first. - `set-array-key` edits the array a method returns, which is where `rules()`, `toArray()`, `casts()` and `definition()` keep their contents. -- `add-relation` covers all eleven Eloquent relation types, with pivot tables, - explicit keys, `withPivot`, `withTimestamps` and a custom pivot model. +- `archetype casts` refuses to write `$casts` on a model that declares the + `casts()` method Laravel 11 generates, rather than leaving it with two casting + mechanisms, and points at `set-array-key` instead. ### Known limits -- The property, constant, interface, trait, parent-class and rename operations - work on classes only, because the endpoints they drive address `class` - declarations. On an enum, interface or trait they refuse and write nothing. - `inspect`, `show`, `find`, the method operations, `add-case` and +- The endpoints address `class` declarations, so the endpoint-named operations + work on classes only. On an enum, interface or trait they refuse and write + nothing. `inspect`, `show`, `find`, the method operations, `add-case` and `set-array-key` have no such limit. ## [2.0.1] - 2026-08-25 diff --git a/docs.md b/docs.md index ed05b38..3920549 100644 --- a/docs.md +++ b/docs.md @@ -169,10 +169,36 @@ Every operation is an Artisan command named `archetype:`. The application's `artisan` file and forwards to it, so these are the same call: ```bash -./vendor/bin/archetype inspect app/Models/User.php -php artisan archetype:inspect app/Models/User.php +./vendor/bin/archetype fillable app/Models/User.php +php artisan archetype:fillable app/Models/User.php ``` +### The naming rule + +`archetype` prints its operations in two halves, and the split is the rule: + +* **An operation named after a `PHPFile` or `LaravelFile` endpoint is that + endpoint.** Same arguments, same directives — as flags — same result. Give a + value and it writes; give none and it reads. +* **An operation with a name of its own has no PHP equivalent** and belongs to + the console alone. + +Nothing is renamed on the way through. If you know the PHP API you already know +the commands. + +| PHP | Command | +|---|---| +| `$file->property('table')` | `archetype property table` | +| `$file->property('table', 'gdpr_users')` | `archetype property table gdpr_users` | +| `$file->add()->property('fillable', 'nickname')` | `archetype property fillable nickname --add` | +| `$file->remove()->property('table')` | `archetype property table --remove` | +| `$file->empty()->property('fillable')` | `archetype property fillable --empty` | +| `$file->private()->property('key', 'v')` | `archetype property key v --private` | +| `$file->className()` | `archetype className ` | +| `$file->full()->className()` | `archetype className --full` | +| `$file->add()->use([...])` | `archetype use ... --add` | +| `$file->hasMany('Task')` | `archetype hasMany Task` | + ### Targets Every operation but `make` and `apply` takes one target, which is any of: @@ -194,20 +220,17 @@ A directory target can be narrowed: These options are rejected on a single-file target rather than ignored. -### Options every operation takes +### Options -| Option | Effect | -|---|---| -| `--json` | Emit JSON instead of the compact line format | - -`errors` predates this console and does not take it. - -### Options every mutation takes +| Option | Effect | On | +|---|---|---| +| `--json` | Emit JSON instead of the compact line format | every operation but `errors` | +| `--dry-run` | Show the diff without writing | every mutation | +| `--no-diff` | Suppress the diff | every mutation | -| Option | Effect | -|---|---| -| `--dry-run` | Show the diff without writing | -| `--no-diff` | Suppress the diff | +Directive flags — `--add`, `--remove`, `--empty`, `--clear`, `--full`, +`--public`, `--protected`, `--private`, `--static` — appear only on the +operations whose endpoint honours them. ### Exit codes and statuses @@ -221,11 +244,95 @@ These options are rejected on a single-file target rather than ignored. A mutation that matches nothing reports `ERR`, never `OK`. That is what makes it safe not to read the file back. -### Reading +### Reading an endpoint + +With no value, an endpoint command answers with its value. A single file answers +with the value alone, so it can be piped; a directory answers with one +`path value` line per file. + +```bash +$ archetype fillable app/Models/User.php +["name","email","password"] + +$ archetype table app/Models/User.php +gdpr_users + +$ archetype className app/Models/User.php --full +App\Models\User + +$ archetype fillable app/Models +app/Models/User.php ["name","email","password"] +app/Models/Project.php ["name"] +``` + +Scalars print raw; arrays and objects print as compact JSON. `--json` gives the +typed value. + +### The endpoints -#### Summarise a file +```bash +# PHPFile +archetype property [] # --add --remove --empty --clear --public --protected --private --static +archetype className [] # --full +archetype extends [] +archetype implements [...] # --add +archetype namespace [] # --remove +archetype use [...] # --add +archetype useTrait [...] # --add +archetype classConstant [] # --add --remove --empty --clear +archetype methodNames +archetype make # --file --extends= --implements= --trait= --force +archetype errors + +# LaravelFile model properties +archetype fillable [] # --add --remove --empty --clear +archetype hidden [] +archetype visible [] +archetype guarded [] +archetype unguarded [] +archetype casts [] +archetype dates [] +archetype table [] +archetype connection [] +archetype timestamps [] + +# LaravelFile relationships +archetype hasOne +archetype hasMany +archetype belongsTo +archetype belongsToMany +``` + +Without `--add`, `use`, `useTrait` and `implements` replace the list wholesale, +exactly as the endpoints do. With it they append. + +`useTrait`, `implements` and `extends` add the import when given a fully +qualified name, because a name used without one is never valid PHP. +`--no-import` leaves that to you. + +With nothing but a related class, the four relationship commands call the +endpoint, so `archetype hasMany Task` and `$file->hasMany('Task')` +produce byte-identical output. Given options the endpoint cannot express, the +method is generated instead: + +```bash +archetype belongsTo User --name=owner --foreign-key=owner_id +archetype belongsToMany Label --table=label_project --with-pivot=sort,note --with-timestamps +archetype hasMany Task --foreign-key=project_id --local-key=uuid +``` + +`archetype casts` writes the `$casts` property. On a model that declares the +`casts()` method Laravel 11 generates, it refuses rather than leaving the model +with two casting mechanisms, and points at `set-array-key` instead. + +### The console's own operations + +These have no PHP equivalent. + +#### inspect — structure, without method bodies ```bash archetype inspect app/Models/User.php +archetype inspect app/Models/User.php props relations ``` ``` app/Models/User.php @@ -233,26 +340,21 @@ class App\Models\User extends Authenticatable uses HasApiTokens, HasFactory, Notifiable import Illuminate\Foundation\Auth\User as Authenticatable prop protected $fillable = ["name","email","password"] -prop protected $casts = {"email_verified_at":"datetime"} fn public posts() [4 lines] rel posts hasMany Post ``` -Limit it to the sections you need — `meta`, `traits`, `uses`, `consts`, `cases`, -`props`, `methods`, `relations`: +Sections: `meta`, `traits`, `uses`, `consts`, `cases`, `props`, `methods`, +`relations`. -```bash -archetype inspect app/Models/User.php props relations -``` - -#### Print one method +#### show — the source of one method ```bash archetype show app/Http/Requests/StoreTaskRequest.php rules ``` `inspect` deliberately leaves method bodies out; this is how you get one. -#### Find files +#### find — which files are there, and what they are ```bash archetype find app archetype find app --type=models @@ -266,71 +368,13 @@ The class types use reflection, so they only see classes the application can autoload; the other filters read the syntax tree and work on anything that parses. -#### List files that do not parse -```bash -archetype errors -``` - -### Creating - -```bash -archetype make 'App\Services\Billing' -archetype make app/Services/Billing.php -archetype make 'App\Models\Invoice' \ - --extends='Illuminate\Database\Eloquent\Model' \ - --implements='App\Contracts\Payable' \ - --trait='Illuminate\Database\Eloquent\Factories\HasFactory' -archetype make app/helpers.php --file -``` - -Refuses to overwrite an existing file unless given `--force`. - -### Properties - -```bash -archetype set-property app/Models/User.php table gdpr_users -archetype set-property app/Models/User.php with '["profile","posts"]' -archetype set-property app/Models/User.php perPage 25 --visibility=public -archetype set-property app/Models/User.php connection # no default value - -archetype add-to-property app/Models/User.php fillable nickname avatar -archetype empty-property app/Models/User.php fillable -archetype remove-property app/Models/User.php hidden -``` - -Values are read as JSON when they are valid JSON, and as a plain string -otherwise. Visibility is left as it is unless `--visibility` says otherwise. - -### Eloquent - -```bash -archetype set-casts app/Models/User.php archived_at=datetime status=Status::class - -archetype add-relation app/Models/Project.php hasMany Task -archetype add-relation app/Models/Project.php belongsTo User --name=owner --foreign-key=owner_id -archetype add-relation app/Models/Project.php belongsToMany Label \ - --table=label_project --with-pivot=sort,note --with-timestamps -archetype add-relation app/Models/Project.php morphMany Comment --morph-name=commentable -archetype add-relation app/Models/Project.php hasManyThrough Comment --through=Task -``` - -`set-casts` writes to whichever mechanism the model already uses — the `casts()` -method Laravel 11 generates, or the `$casts` property — rather than adding a -second one beside it. - -`add-relation` covers all eleven relation types: `hasOne`, `hasMany`, -`belongsTo`, `belongsToMany`, `hasOneThrough`, `hasManyThrough`, `morphOne`, -`morphMany`, `morphTo`, `morphToMany`, `morphedByMany`. The related class is -imported when it needs to be. - -### Arrays returned from methods - +#### set-array-key — the array a method returns ```bash archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules due_at 'nullable|date' archetype set-array-key app/Http/Resources/TaskResource.php toArray budget '$this->budget_cents' +archetype set-array-key app/Models/Project.php casts archived boolean archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules tags "['array', 'max:5']" archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules title --remove -archetype set-array-key app/Providers/AppServiceProvider.php policies ignored Policy::class --append ``` This reaches `rules()`, `toArray()`, `casts()`, `definition()` and everything @@ -341,38 +385,15 @@ A bare word is a string, so `nullable|date` is a validation rule rather than a bitwise or. Brackets, quotes, `$variables`, calls, `Class::constants`, numbers and booleans are read as PHP. -### Structure - -```bash -archetype add-use app/Models/User.php 'App\Contracts\Auditable' 'Illuminate\Support\Str' -archetype remove-use app/Models/User.php 'Illuminate\Support\Str' -archetype add-trait app/Models/User.php 'Illuminate\Database\Eloquent\SoftDeletes' -archetype add-implements app/Models/User.php 'App\Contracts\Auditable' -archetype set-extends app/Models/User.php 'Illuminate\Database\Eloquent\Model' -archetype set-namespace app/Models/User.php 'App\Domain\Models' -archetype rename-class app/Models/User.php Account -``` - -`add-trait`, `add-implements` and `set-extends` add the import too, since a name -used without one is never valid PHP. - -`rename-class` renames the declaration only. It does not move the file or update -references elsewhere. - -### Constants and enum cases - +#### add-case — an enum case ```bash -archetype set-const app/Models/User.php HOME /dashboard -archetype remove-const app/Models/User.php HOME - archetype add-case app/Enums/ProjectStatus.php OnHold on_hold archetype add-case app/Enums/Suit.php Spades # pure enum, no backing value ``` -New enum cases are added after the ones already there. - -### Methods +New cases are added after the ones already there. +#### The method operations ```bash archetype add-method app/Models/Project.php \ --code='public function scopeActive($query) { return $query->where("active", true); }' @@ -384,25 +405,18 @@ archetype remove-method app/Models/Project.php isActive Methods can be added to a class, enum, interface or trait, and are appended after the methods already there. -### What the console will not do - -`set-property`, `add-to-property`, `empty-property`, `remove-property`, -`set-casts`, `set-const`, `remove-const`, `add-implements`, `add-trait`, -`set-extends` and `rename-class` work on classes only. On an enum, interface or -trait they refuse and write nothing, rather than writing the part they can and -reporting success: - -``` -$ archetype add-implements app/Enums/Status.php 'App\Contracts\HasColor' -ERR app/Enums/Status.php archetype:add-implements only works on classes, and this is an enum +#### The relations the endpoints do not have +```bash +archetype hasOneThrough --through=Task +archetype hasManyThrough --through=Task +archetype morphOne --morph-name=commentable +archetype morphMany --morph-name=commentable +archetype morphTo [--morph-name=commentable] +archetype morphToMany --morph-name=taggable +archetype morphedByMany --morph-name=taggable ``` -`inspect`, `show`, `find`, `add-method`, `replace-method`, `remove-method`, -`add-case` and `set-array-key` have no such limit — they read or write the -declaration whatever it is. - -### Several operations in one call - +#### apply — several operations in one call ```bash archetype apply operations.txt archetype apply < operations.txt @@ -412,23 +426,41 @@ One operation per line, `#` for comments, the `archetype:` prefix optional: ```text # what this change needs -add-to-property app/Models/Project.php fillable budget_cents -set-casts app/Models/Project.php budget_cents=integer -add-relation app/Models/Project.php hasMany Task +fillable app/Models/Project.php budget_cents --add +casts app/Models/Project.php '{"budget_cents":"integer"}' --add +hasMany app/Models/Project.php Task ``` Each operation keeps its own verification, diff and exit status. `apply` exits non-zero if any of them failed, and `--stop-on-failure` stops at the first. +### What the console will not do + +The endpoints address `class` declarations, so `property`, the model +properties, `classConstant`, `implements`, `useTrait`, `extends`, `className` +and the relationships work on classes only. On an enum, interface or trait they +refuse and write nothing, rather than writing the part they can and reporting +success: + +``` +$ archetype implements app/Enums/Status.php 'App\Contracts\HasColor' --add +ERR app/Enums/Status.php archetype:implements only works on classes, and this is an enum +``` + +`inspect`, `show`, `find`, the method operations, `add-case` and `set-array-key` +have no such limit — they read or write the declaration whatever it is. + ### JSON -Every operation takes `--json`: +Every operation but `errors` takes `--json`: ```bash -archetype add-to-property app/Models/User.php fillable nickname --json +archetype fillable app/Models/User.php nickname --add --json ``` ```json -{"ok":true,"dryRun":false,"changed":1,"skipped":0,"failed":0,"results":[{"file":"app/Models/User.php","status":"changed","detail":"$fillable +1","diff":"@@ 24 @@\n+ 'nickname',\n ];"}]} +{"ok":true,"dryRun":false,"changed":1,"skipped":0,"failed":0,"results":[{"file":"app/Models/User.php","status":"changed","detail":"$fillable added to","diff":"@@ 24 @@\n+ 'nickname',\n ];"}]} ``` -An error answers with `{"ok":false,"error":"..."}` and exit code 1. +A read answers with `{"file":"...","value":...}`, or `{"values":{...},"count":n}` +for a directory. An error answers with `{"ok":false,"error":"..."}` and exit +code 1. diff --git a/readme.md b/readme.md index 6454ff4..80e42b4 100644 --- a/readme.md +++ b/readme.md @@ -199,33 +199,59 @@ $file->astQuery() ## Command line -Everything above is also a command. Each operation is an Artisan command under +The same API, from a terminal. Each operation is an Artisan command under `archetype:`, and the `archetype` binary is a shorthand that finds your application and forwards to it: ```bash -./vendor/bin/archetype inspect app/Models/User.php +./vendor/bin/archetype fillable app/Models/User.php # is the same as -php artisan archetype:inspect app/Models/User.php +php artisan archetype:fillable app/Models/User.php ``` -Run `archetype` with no arguments for the full list. There are 26 operations; -these are the shape of them: +**A command named after an endpoint is that endpoint.** It takes the same +arguments, honours the same directives as flags, and returns what the PHP call +returns. There is one vocabulary, not two: + +```php +$file->property('table'); // read +$file->property('table', 'gdpr_users'); // write +$file->add()->property('fillable', 'nickname'); // directive +$file->remove()->property('table'); +``` + +```bash +archetype property app/Models/User.php table +archetype property app/Models/User.php table gdpr_users +archetype property app/Models/User.php fillable nickname --add +archetype property app/Models/User.php table --remove +``` + +Give a value and it writes; give none and it reads. So the endpoints you already +know are already commands: + +```bash +archetype className app/Models/User.php +archetype fillable app/Models/User.php nickname --add +archetype casts app/Models/User.php '{"archived_at":"datetime"}' --add +archetype useTrait app/Models/User.php 'Illuminate\Database\Eloquent\SoftDeletes' --add +archetype implements app/Models/User.php 'App\Contracts\Auditable' --add +archetype extends app/Models/User.php 'Illuminate\Database\Eloquent\Model' +archetype classConstant app/Models/User.php HOME /dashboard +archetype hasMany app/Models/Project.php Task +archetype belongsToMany app/Models/Project.php Label --table=label_project +``` + +Run `archetype` with no arguments for the whole list. It prints in two halves, +and the split is the naming rule: everything above the line is an endpoint, +everything below it has no PHP equivalent and is the console's own. ```bash -# read archetype inspect app/Models/User.php # structure, without method bodies -archetype inspect app/Models/User.php props methods # only the parts you want -archetype show app/Http/Requests/StoreTask.php rules -archetype find app --type=models --uses-trait=SoftDeletes - -# write -archetype add-to-property app/Models/User.php fillable nickname -archetype set-casts app/Models/User.php archived_at=datetime status=Status::class -archetype add-relation app/Models/Project.php belongsToMany Label --table=label_project --with-timestamps +archetype show app/Http/Requests/StoreTask.php rules +archetype find app --type=models --uses-trait=SoftDeletes archetype set-array-key app/Http/Requests/StoreTask.php rules due_at 'nullable|date' -archetype add-case app/Enums/Status.php OnHold on_hold -archetype add-method app/Models/User.php --code='public function scopeActive($q) { return $q->where("active", true); }' +archetype add-case app/Enums/Status.php OnHold on_hold ``` The full reference is in [docs.md](docs.md#command-line-reference). @@ -235,9 +261,9 @@ The full reference is in [docs.md](docs.md#command-line-reference). Every operation takes one target, which is a path, a class name, or a directory: ```bash -archetype add-trait app/Models/User.php Auditable # one file -archetype add-trait 'App\Models\User' Auditable # the same file -archetype add-trait app/Models Auditable # every class under app/Models +archetype useTrait app/Models/User.php Auditable --add # one file +archetype useTrait 'App\Models\User' Auditable --add # the same file +archetype useTrait app/Models Auditable --add # every class under app/Models ``` A directory target can be narrowed with `--extends`, `--implements`, @@ -246,8 +272,8 @@ A directory target can be narrowed with `--extends`, `--implements`, ### What a mutation answers with ```bash -$ archetype add-to-property app/Models/User.php fillable nickname -OK app/Models/User.php $fillable +1 +$ archetype fillable app/Models/User.php nickname --add +OK app/Models/User.php $fillable added to @@ 24 @@ + 'nickname', ]; @@ -270,10 +296,10 @@ machine-readable answer instead. ```bash archetype apply <<'EOF' -add-to-property app/Models/Project.php fillable budget_cents -set-casts app/Models/Project.php budget_cents=integer -add-relation app/Models/Project.php hasMany Task -add-implements app/Models/Project.php 'App\Contracts\Auditable' +fillable app/Models/Project.php budget_cents --add +casts app/Models/Project.php '{"budget_cents":"integer"}' --add +hasMany app/Models/Project.php Task +implements app/Models/Project.php 'App\Contracts\Auditable' --add EOF ``` diff --git a/src/Console/Commands/AddImplementsCommand.php b/src/Console/Commands/AddImplementsCommand.php deleted file mode 100644 index 4524a5c..0000000 --- a/src/Console/Commands/AddImplementsCommand.php +++ /dev/null @@ -1,41 +0,0 @@ -argument('interfaces'); - - return $this->mutate(function (LaravelFile $file) use ($interfaces) { - $this->requireKind($file, ['class']); - - $existing = array_map(fn ($name) => class_basename($name), $file->implements()); - - $wanted = array_values(array_filter( - $interfaces, - fn ($name) => ! in_array(class_basename($name), $existing, true) - )); - - if (! $wanted) { - return $this->unchanged('implements unchanged'); - } - - $imported = $this->import($file, $wanted); - - $file->add()->implements(array_map(fn ($name) => class_basename($name), $wanted)); - - return 'implements +'.count($wanted).($imported ? " (+$imported use)" : ''); - }); - } -} diff --git a/src/Console/Commands/AddRelationCommand.php b/src/Console/Commands/AddRelationCommand.php deleted file mode 100644 index 1d12fa1..0000000 --- a/src/Console/Commands/AddRelationCommand.php +++ /dev/null @@ -1,79 +0,0 @@ -argument('type'), - $this->argument('related'), - [ - 'name' => $this->option('name'), - 'morph-name' => $this->option('morph-name'), - 'through' => $this->option('through'), - 'table' => $this->option('table'), - 'foreign-key' => $this->option('foreign-key'), - 'related-key' => $this->option('related-key'), - 'local-key' => $this->option('local-key'), - 'owner-key' => $this->option('owner-key'), - 'first-key' => $this->option('first-key'), - 'second-key' => $this->option('second-key'), - 'type-column' => $this->option('type-column'), - 'id-column' => $this->option('id-column'), - 'using' => $this->option('using'), - 'with-pivot' => $this->option('with-pivot'), - 'with-timestamps' => $this->option('with-timestamps'), - ] - ); - - $name = $relation->name(); - $method = Code::method($relation->source()); - - return $this->mutate(function (LaravelFile $file) use ($relation, $name, $method) { - if (in_array($name, $file->methodNames(), true)) { - return $this->unchanged("$name exists"); - } - - $imported = $this->import($file, $relation->imports()); - - Member::add($file, Code::copy($method)); - - return sprintf( - '%s %s%s', - $this->argument('type'), - $name, - $imported ? " (+$imported use)" : '' - ); - }); - } -} diff --git a/src/Console/Commands/AddToPropertyCommand.php b/src/Console/Commands/AddToPropertyCommand.php deleted file mode 100644 index b2925a3..0000000 --- a/src/Console/Commands/AddToPropertyCommand.php +++ /dev/null @@ -1,45 +0,0 @@ -argument('name'); - $values = $this->argument('values'); - - return $this->mutate(function (LaravelFile $file) use ($name, $values) { - $this->requireKind($file, ['class']); - - $visibility = $this->visibilityOf($file, $name, $this->option('visibility')); - $existing = $file->property($name); - $existing = is_array($existing) ? $existing : []; - - $missing = array_values(array_diff($values, $existing)); - - if (! $missing) { - return $this->unchanged("\$$name unchanged"); - } - - $file->assumeType('array')->{$visibility}()->add()->property($name, $missing); - - return "\$$name +".count($missing); - }); - } -} diff --git a/src/Console/Commands/AddTraitCommand.php b/src/Console/Commands/AddTraitCommand.php deleted file mode 100644 index f73adeb..0000000 --- a/src/Console/Commands/AddTraitCommand.php +++ /dev/null @@ -1,41 +0,0 @@ -argument('traits'); - - return $this->mutate(function (LaravelFile $file) use ($traits) { - $this->requireKind($file, ['class']); - - $existing = array_map(fn ($trait) => class_basename($trait), $file->useTrait()); - - $wanted = array_values(array_filter( - $traits, - fn ($trait) => ! in_array(class_basename($trait), $existing, true) - )); - - if (! $wanted) { - return $this->unchanged('traits unchanged'); - } - - $imported = $this->import($file, $wanted); - - $file->add()->useTrait(array_map(fn ($trait) => class_basename($trait), $wanted)); - - return 'uses +'.count($wanted).($imported ? " (+$imported use)" : ''); - }); - } -} diff --git a/src/Console/Commands/AddUseCommand.php b/src/Console/Commands/AddUseCommand.php deleted file mode 100644 index 6f0fb47..0000000 --- a/src/Console/Commands/AddUseCommand.php +++ /dev/null @@ -1,32 +0,0 @@ -argument('imports'); - - return $this->mutate(function (LaravelFile $file) use ($imports) { - $missing = array_values(array_diff($imports, $file->use())); - - if (! $missing) { - return $this->unchanged('imports unchanged'); - } - - $file->add()->use($missing); - - return 'import +'.count($missing); - }); - } -} diff --git a/src/Console/Commands/ClassConstantCommand.php b/src/Console/Commands/ClassConstantCommand.php new file mode 100644 index 0000000..e3d0bc4 --- /dev/null +++ b/src/Console/Commands/ClassConstantCommand.php @@ -0,0 +1,82 @@ +classConstant($name)` and `$file->classConstant($name, $value)`. */ +class ClassConstantCommand extends EndpointCommand +{ + protected $signature = 'archetype:classConstant + {target : '.self::TARGET_DESCRIPTION.'} + {name : Constant name} + {value? : The value, as JSON when it is not a plain string. Omit to read it}'; + + protected $description = 'Read or write a class constant'; + + protected function directives(): array + { + return ['add', 'remove', 'clear', 'empty']; + } + + protected function hasValue(): bool + { + return $this->argument('value') !== null; + } + + protected function get(File $file) + { + return $file->classConstant($this->argument('name')); + } + + protected function set(File $file) + { + $this->requireKind($file, ['class']); + + $name = $this->argument('name'); + $raw = $this->argument('value'); + + if ($outcome = $this->alreadyDone($file, $name, $raw)) { + return $outcome; + } + + $this->withDirectives($file) + ->classConstant($name, $raw === null ? Types::NO_VALUE : Code::value($raw)); + + return "const $name ".$this->verb(); + } + + /** @return array|null */ + protected function alreadyDone(File $file, string $name, ?string $raw): ?array + { + $constants = collect((new Introspector($file))->constants()); + $present = $constants->firstWhere('name', $name); + + if (($this->option('remove') || $this->option('empty') || $this->option('clear')) && ! $present) { + return $this->unchanged("no const $name"); + } + + $writingValue = $raw !== null && ! $this->option('add'); + + if ($writingValue && $present && $present['evaluated'] && $present['value'] === Code::value($raw)) { + return $this->unchanged("$name unchanged"); + } + + return null; + } + + protected function verb(): string + { + return match (true) { + (bool) $this->option('remove') => 'removed', + (bool) $this->option('empty') => 'emptied', + (bool) $this->option('clear') => 'cleared', + (bool) $this->option('add') => 'added to', + default => 'set', + }; + } +} diff --git a/src/Console/Commands/ClassNameCommand.php b/src/Console/Commands/ClassNameCommand.php new file mode 100644 index 0000000..4edd2fa --- /dev/null +++ b/src/Console/Commands/ClassNameCommand.php @@ -0,0 +1,47 @@ +className()` and `$file->className($name)`. */ +class ClassNameCommand extends EndpointCommand +{ + protected $signature = 'archetype:className + {target : '.self::TARGET_DESCRIPTION.'} + {name? : The new class name. Omit to read it}'; + + protected $description = 'Read or set the name of the class a file declares'; + + protected function directives(): array + { + return ['full']; + } + + protected function hasValue(): bool + { + return $this->argument('name') !== null; + } + + protected function get(File $file) + { + return $file->className(); + } + + protected function set(File $file) + { + $this->requireKind($file, ['class']); + + $name = $this->argument('name'); + + if ((new Introspector($file))->name() === $name) { + return $this->unchanged('class name unchanged'); + } + + $file->className($name); + + return "class $name"; + } +} diff --git a/src/Console/Commands/EmptyPropertyCommand.php b/src/Console/Commands/EmptyPropertyCommand.php deleted file mode 100644 index ee1dc89..0000000 --- a/src/Console/Commands/EmptyPropertyCommand.php +++ /dev/null @@ -1,33 +0,0 @@ -argument('name'); - - return $this->mutate(function (LaravelFile $file) use ($name) { - $this->requireKind($file, ['class']); - - if (! (new Introspector($file))->hasProperty($name)) { - return $this->unchanged("no \$$name"); - } - - $file->{$this->visibilityOf($file, $name)}()->empty()->property($name); - - return "\$$name emptied"; - }); - } -} diff --git a/src/Console/Commands/ExtendsCommand.php b/src/Console/Commands/ExtendsCommand.php new file mode 100644 index 0000000..d5725cf --- /dev/null +++ b/src/Console/Commands/ExtendsCommand.php @@ -0,0 +1,57 @@ +extends()` and `$file->extends($name)`. */ +class ExtendsCommand extends EndpointCommand +{ + protected $signature = 'archetype:extends + {target : '.self::TARGET_DESCRIPTION.'} + {name? : The parent class. Omit to read it}'; + + protected $description = 'Read or set the parent class'; + + protected function directives(): array + { + return []; + } + + protected function hasValue(): bool + { + return $this->argument('name') !== null; + } + + protected function get(File $file) + { + return $file->extends(); + } + + protected function set(File $file) + { + $this->requireKind($file, ['class']); + + $parent = $this->argument('name'); + + if ($file->extends() === class_basename($parent)) { + return $this->unchanged('extends unchanged'); + } + + $imported = $this->option('no-import') ? 0 : $this->import($file, [$parent]); + + $file->extends(class_basename($parent)); + + return 'extends '.class_basename($parent).($imported ? ' (+use)' : ''); + } + + /** @return array */ + protected function sharedOptions(): array + { + return array_merge(parent::sharedOptions(), [ + new InputOption('no-import', null, InputOption::VALUE_NONE, 'Do not import the parent class'), + ]); + } +} diff --git a/src/Console/Commands/HelpCommand.php b/src/Console/Commands/HelpCommand.php index 63ba44b..75fbb36 100644 --- a/src/Console/Commands/HelpCommand.php +++ b/src/Console/Commands/HelpCommand.php @@ -29,15 +29,21 @@ protected function perform(): int $this->emit('answer with a diff, skip work already done, and exit non-zero if they'); $this->emit('could not do what was asked.'); + $describe = fn (array $operations, string $kind) => collect($operations) + ->map(fn ($operation, $name) => [ + 'operation' => $name, + 'usage' => $operation[0], + 'description' => $operation[1], + 'kind' => $kind, + ]) + ->values() + ->all(); + $this->payload = [ - 'operations' => collect(Manifest::OPERATIONS) - ->map(fn ($operation, $name) => [ - 'operation' => $name, - 'usage' => $operation[1], - 'description' => $operation[2], - ]) - ->values() - ->all(), + 'operations' => array_merge( + $describe(Manifest::ENDPOINTS, 'endpoint'), + $describe(Manifest::ADDITIONS, 'console') + ), ]; return self::SUCCESS; diff --git a/src/Console/Commands/ImplementsCommand.php b/src/Console/Commands/ImplementsCommand.php new file mode 100644 index 0000000..efd2fc5 --- /dev/null +++ b/src/Console/Commands/ImplementsCommand.php @@ -0,0 +1,75 @@ +implements()`, `$file->implements($names)` and + * `$file->add()->implements($names)`. + * + * Given a fully qualified name it also adds the import, because an interface + * named without one is never valid PHP. `--no-import` leaves that to you. + */ +class ImplementsCommand extends EndpointCommand +{ + protected $signature = 'archetype:implements + {target : '.self::TARGET_DESCRIPTION.'} + {names?* : Interface names, fully qualified to have the import added too. Omit to read them}'; + + protected $description = 'Read or set the interfaces a class implements'; + + protected function directives(): array + { + return ['add']; + } + + protected function hasValue(): bool + { + return (bool) $this->argument('names'); + } + + protected function get(File $file) + { + return $file->implements(); + } + + protected function set(File $file) + { + $this->requireKind($file, ['class']); + + $names = $this->argument('names'); + $short = fn ($name) => class_basename($name); + + if (! $this->option('add')) { + $imported = $this->option('no-import') ? 0 : $this->import($file, $names); + + $file->implements(array_map($short, $names)); + + return 'implements set to '.count($names).($imported ? " (+$imported use)" : ''); + } + + $existing = array_map($short, $file->implements()); + $wanted = array_values(array_filter($names, fn ($name) => ! in_array($short($name), $existing, true))); + + if (! $wanted) { + return $this->unchanged('implements unchanged'); + } + + $imported = $this->option('no-import') ? 0 : $this->import($file, $wanted); + + $file->add()->implements(array_map($short, $wanted)); + + return 'implements +'.count($wanted).($imported ? " (+$imported use)" : ''); + } + + /** @return array */ + protected function sharedOptions(): array + { + return array_merge(parent::sharedOptions(), [ + new InputOption('no-import', null, InputOption::VALUE_NONE, 'Do not import the interfaces'), + ]); + } +} diff --git a/src/Console/Commands/MethodNamesCommand.php b/src/Console/Commands/MethodNamesCommand.php new file mode 100644 index 0000000..609c9db --- /dev/null +++ b/src/Console/Commands/MethodNamesCommand.php @@ -0,0 +1,36 @@ +methodNames()`. Read only, as the endpoint is. */ +class MethodNamesCommand extends EndpointCommand +{ + protected $signature = 'archetype:methodNames + {target : '.self::TARGET_DESCRIPTION.'}'; + + protected $description = 'List the names of the methods a file declares'; + + protected function directives(): array + { + return []; + } + + protected function hasValue(): bool + { + return false; + } + + protected function get(File $file) + { + return $file->methodNames(); + } + + protected function set(File $file) + { + throw new LogicException('methodNames is read only'); + } +} diff --git a/src/Console/Commands/ModelPropertyCommand.php b/src/Console/Commands/ModelPropertyCommand.php new file mode 100644 index 0000000..9794cac --- /dev/null +++ b/src/Console/Commands/ModelPropertyCommand.php @@ -0,0 +1,132 @@ +protected()->property(...)`. + * The name is a constructor argument so the service provider can register them + * without ten near-identical files. + */ +class ModelPropertyCommand extends EndpointCommand +{ + /** endpoint => the type it assumes, mirroring ModelProperties */ + const PROPERTIES = [ + 'casts' => 'array', + 'connection' => 'string', + 'table' => 'string', + 'dates' => 'array', + 'timestamps' => 'boolean', + 'visible' => 'array', + 'guarded' => 'array', + 'unguarded' => 'array', + 'fillable' => 'array', + 'hidden' => 'array', + ]; + + public function __construct(protected string $property = 'fillable') + { + $this->signature = "archetype:$this->property + {target : ".self::TARGET_DESCRIPTION."} + {value? : The value, as JSON when it is not a plain string. Omit to read it}"; + + $this->description = "Read or write \$$this->property on an Eloquent model"; + + parent::__construct(); + } + + protected function directives(): array + { + return ['add', 'remove', 'clear', 'empty']; + } + + protected function hasValue(): bool + { + return $this->argument('value') !== null; + } + + protected function get(File $file) + { + return $file->{$this->property}(); + } + + protected function set(File $file) + { + $this->requireKind($file, ['class']); + $this->guardAgainstTheOtherMechanism($file); + + $raw = $this->argument('value'); + + if ($outcome = $this->alreadyDone($file, $raw)) { + return $outcome; + } + + $raw === null + ? $this->withDirectives($file)->{$this->property}() + : $this->withDirectives($file)->{$this->property}(Code::value($raw)); + + return "\$$this->property ".$this->verb(); + } + + /** + * Laravel 11 generates `protected function casts(): array`, and `getCasts()` + * merges it with the `$casts` property. Writing the property beside an + * existing method is honoured by the merge and still leaves a model with two + * casting mechanisms, which no reviewer would accept — so say where the + * change belongs instead of quietly making that mess. + */ + protected function guardAgainstTheOtherMechanism(File $file): void + { + if ($this->property !== 'casts' || ! (new Introspector($file))->method('casts')) { + return; + } + + throw new RuntimeException( + 'this model declares a casts() method, so writing $casts would leave it with two ' + .'casting mechanisms — use archetype:set-array-key casts instead' + ); + } + + /** + * @return array|null + * + * Read off the syntax tree, not through the endpoint: the directives are + * already on the file here, so the endpoint would treat a read as a write. + */ + protected function alreadyDone(File $file, ?string $raw): ?array + { + $current = collect((new Introspector($file))->properties())->firstWhere('name', $this->property); + + if (($this->option('remove') || $this->option('empty')) && ! $current) { + return $this->unchanged("no \$$this->property"); + } + + if ($this->option('add') && $current && is_array($current['value']) && $raw !== null) { + return array_diff((array) Code::value($raw), $current['value']) + ? null + : $this->unchanged("\$$this->property unchanged"); + } + + return null; + } + + protected function verb(): string + { + return match (true) { + (bool) $this->option('remove') => 'removed', + (bool) $this->option('empty') => 'emptied', + (bool) $this->option('clear') => 'cleared', + (bool) $this->option('add') => 'added to', + default => 'set', + }; + } +} diff --git a/src/Console/Commands/NamespaceCommand.php b/src/Console/Commands/NamespaceCommand.php new file mode 100644 index 0000000..750b481 --- /dev/null +++ b/src/Console/Commands/NamespaceCommand.php @@ -0,0 +1,54 @@ +namespace()`, `$file->namespace($value)` and `$file->remove()->namespace()`. */ +class NamespaceCommand extends EndpointCommand +{ + protected $signature = 'archetype:namespace + {target : '.self::TARGET_DESCRIPTION.'} + {value? : The new namespace. Omit to read it}'; + + protected $description = 'Read, set or remove the namespace of a file'; + + protected function directives(): array + { + return ['remove']; + } + + protected function hasValue(): bool + { + return $this->argument('value') !== null; + } + + protected function get(File $file) + { + return (string) $file->namespace(); + } + + protected function set(File $file) + { + $value = $this->argument('value'); + + if ($this->option('remove')) { + if ((string) $file->namespace() === '') { + return $this->unchanged('no namespace'); + } + + $file->remove()->namespace(); + + return 'namespace removed'; + } + + if ((string) $file->namespace() === $value) { + return $this->unchanged('namespace unchanged'); + } + + $file->namespace($value); + + return "namespace $value"; + } +} diff --git a/src/Console/Commands/PropertyCommand.php b/src/Console/Commands/PropertyCommand.php new file mode 100644 index 0000000..d5cf1a0 --- /dev/null +++ b/src/Console/Commands/PropertyCommand.php @@ -0,0 +1,132 @@ +property($name)` and `$file->property($name, $value)`, with the + * directives that endpoint honours as flags. + */ +class PropertyCommand extends EndpointCommand +{ + protected $signature = 'archetype:property + {target : '.self::TARGET_DESCRIPTION.'} + {name : Property name, without the $} + {value? : The value, as JSON when it is not a plain string. Omit to read it}'; + + protected $description = 'Read or write a class property'; + + protected function directives(): array + { + return ['add', 'remove', 'clear', 'empty', 'public', 'protected', 'private', 'static']; + } + + protected function hasValue(): bool + { + return $this->argument('value') !== null; + } + + protected function get(File $file) + { + return $file->property($this->name()); + } + + protected function set(File $file) + { + $this->requireKind($file, ['class']); + + $name = $this->name(); + $raw = $this->argument('value'); + + if ($outcome = $this->alreadyDone($file, $name, $raw)) { + return $outcome; + } + + // The endpoint reads `add`, `remove`, `empty` and `clear` off the file, + // so by the time this runs the directives are already on it and the + // value is all that is left to hand over. `--clear` with no value is + // how the API declares a property without a default. + $this->withDirectives($this->withVisibility($file, $name)) + ->property($name, $raw === null ? Types::NO_VALUE : Code::value($raw)); + + return $this->describe($name); + } + + protected function name(): string + { + return ltrim($this->argument('name'), '$'); + } + + /** + * The property endpoint rewrites the modifiers on every set, defaulting to + * public, so saying nothing about visibility would quietly widen a + * protected property. Only an explicit flag changes it. + */ + protected function withVisibility(File $file, string $name): File + { + foreach (['public', 'protected', 'private'] as $flag) { + if ($this->option($flag)) { + return $file; + } + } + + foreach ((new Introspector($file))->properties() as $property) { + if ($property['name'] === $name) { + return $file->{$property['visibility']}(); + } + } + + return $file->protected(); + } + + /** + * @return array|null the unchanged marker, when there is nothing to do + * + * The current value is read off the syntax tree rather than through + * `$file->property()`, because by this point the directives are already on + * the file and the endpoint would treat the read as another write. + */ + protected function alreadyDone(File $file, string $name, ?string $raw): ?array + { + $current = collect((new Introspector($file))->properties())->firstWhere('name', $name); + + // `--clear` on a property that is not there declares it, which is how + // the API writes one with no default, so it is not nothing to do. + if (($this->option('remove') || $this->option('empty')) && ! $current) { + return $this->unchanged("no \$$name"); + } + + if ($this->option('add') && $current && is_array($current['value'])) { + return array_diff((array) Code::value($raw), $current['value']) + ? null + : $this->unchanged("\$$name unchanged"); + } + + return null; + } + + protected function describe(string $name): string + { + return match (true) { + (bool) $this->option('remove') => "\$$name removed", + (bool) $this->option('empty') => "\$$name emptied", + (bool) $this->option('clear') => "\$$name cleared", + (bool) $this->option('add') => "\$$name added to", + default => "\$$name set", + }; + } + + /** @return array */ + protected function sharedOptions(): array + { + return array_merge(parent::sharedOptions(), [ + new InputOption('assume-type', null, InputOption::VALUE_REQUIRED, 'Type to assume when the property does not exist yet, e.g. array'), + ]); + } +} diff --git a/src/Console/Commands/RelationCommand.php b/src/Console/Commands/RelationCommand.php new file mode 100644 index 0000000..592e2d8 --- /dev/null +++ b/src/Console/Commands/RelationCommand.php @@ -0,0 +1,104 @@ + Task` and `$file->hasMany('Task')` produce the same + * method. The remaining seven types have no endpoint, and every type accepts + * options the endpoints cannot express — a pivot table, explicit keys — in + * which case the method is generated here instead. + */ +class RelationCommand extends MutationCommand +{ + /** The four that exist as LaravelFile endpoints. */ + const ENDPOINTS = ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany']; + + public function __construct(protected string $type = 'hasMany') + { + $this->signature = "archetype:$this->type + {target : ".self::TARGET_DESCRIPTION."} + {related? : The related class} + {--name= : Method name, defaulting to the conventional one} + {--morph-name= : The polymorphic name, e.g. commentable} + {--through= : The intermediate model, for the through relations} + {--table= : Pivot table} + {--foreign-key= : Foreign key, or the foreign pivot key for belongsToMany} + {--related-key= : Related pivot key, for belongsToMany} + {--local-key= : Local key} + {--owner-key= : Owner key, for belongsTo} + {--first-key= : First key, for the through relations} + {--second-key= : Second key, for the through relations} + {--type-column= : Morph type column} + {--id-column= : Morph id column} + {--using= : Custom pivot model} + {--with-pivot= : Comma separated pivot columns} + {--with-timestamps : Add withTimestamps() to a pivot relation} + {--no-import : Do not import the related class}"; + + $this->description = "Add a $this->type relationship method"; + + parent::__construct(); + } + + protected function perform(): int + { + $relation = new Relation($this->type, $this->argument('related'), $this->relationOptions()); + + $name = $relation->name(); + + // With nothing but a related class to go on, the endpoint is the + // authority: this is then literally `$file->hasMany('Task')`. + $useEndpoint = in_array($this->type, self::ENDPOINTS, true) + && ! array_filter($this->relationOptions()); + + $method = $useEndpoint ? null : Code::method($relation->source()); + + return $this->mutate(function (File $file) use ($relation, $name, $method, $useEndpoint) { + $this->requireKind($file, ['class']); + + if (in_array($name, $file->methodNames(), true)) { + return $this->unchanged("$name exists"); + } + + $imported = $this->option('no-import') ? 0 : $this->import($file, $relation->imports()); + + $useEndpoint + ? $file->{$this->type}($this->argument('related')) + : Member::add($file, Code::copy($method)); + + return sprintf('%s %s%s', $this->type, $name, $imported ? " (+$imported use)" : ''); + }); + } + + /** @return array */ + protected function relationOptions(): array + { + return [ + 'name' => $this->option('name'), + 'morph-name' => $this->option('morph-name'), + 'through' => $this->option('through'), + 'table' => $this->option('table'), + 'foreign-key' => $this->option('foreign-key'), + 'related-key' => $this->option('related-key'), + 'local-key' => $this->option('local-key'), + 'owner-key' => $this->option('owner-key'), + 'first-key' => $this->option('first-key'), + 'second-key' => $this->option('second-key'), + 'type-column' => $this->option('type-column'), + 'id-column' => $this->option('id-column'), + 'using' => $this->option('using'), + 'with-pivot' => $this->option('with-pivot'), + 'with-timestamps' => $this->option('with-timestamps'), + ]; + } +} diff --git a/src/Console/Commands/RemoveConstCommand.php b/src/Console/Commands/RemoveConstCommand.php deleted file mode 100644 index fada2f0..0000000 --- a/src/Console/Commands/RemoveConstCommand.php +++ /dev/null @@ -1,36 +0,0 @@ -argument('name'); - - return $this->mutate(function (LaravelFile $file) use ($name) { - $this->requireKind($file, ['class']); - - $present = collect((new Introspector($file))->constants()) - ->contains(fn ($constant) => $constant['name'] === $name); - - if (! $present) { - return $this->unchanged("no const $name"); - } - - $file->remove()->classConstant($name); - - return "const $name removed"; - }); - } -} diff --git a/src/Console/Commands/RemovePropertyCommand.php b/src/Console/Commands/RemovePropertyCommand.php deleted file mode 100644 index c123840..0000000 --- a/src/Console/Commands/RemovePropertyCommand.php +++ /dev/null @@ -1,33 +0,0 @@ -argument('name'); - - return $this->mutate(function (LaravelFile $file) use ($name) { - $this->requireKind($file, ['class']); - - if (! (new Introspector($file))->hasProperty($name)) { - return $this->unchanged("no \$$name"); - } - - $file->remove()->property($name); - - return "\$$name removed"; - }); - } -} diff --git a/src/Console/Commands/RemoveUseCommand.php b/src/Console/Commands/RemoveUseCommand.php deleted file mode 100644 index 0cd7dcf..0000000 --- a/src/Console/Commands/RemoveUseCommand.php +++ /dev/null @@ -1,33 +0,0 @@ -argument('imports'); - - return $this->mutate(function (LaravelFile $file) use ($imports) { - $existing = $file->use(); - $keep = array_values(array_diff($existing, $imports)); - - if (count($keep) === count($existing)) { - return $this->unchanged('imports unchanged'); - } - - $file->use($keep); - - return 'import -'.(count($existing) - count($keep)); - }); - } -} diff --git a/src/Console/Commands/RenameClassCommand.php b/src/Console/Commands/RenameClassCommand.php deleted file mode 100644 index 5a21da0..0000000 --- a/src/Console/Commands/RenameClassCommand.php +++ /dev/null @@ -1,38 +0,0 @@ -argument('name'); - - return $this->mutate(function (LaravelFile $file) use ($name) { - $this->requireKind($file, ['class']); - - if ((new Introspector($file))->name() === $name) { - return $this->unchanged('class name unchanged'); - } - - $file->className($name); - - return "class $name"; - }); - } -} diff --git a/src/Console/Commands/SetCastsCommand.php b/src/Console/Commands/SetCastsCommand.php deleted file mode 100644 index 4fc75de..0000000 --- a/src/Console/Commands/SetCastsCommand.php +++ /dev/null @@ -1,107 +0,0 @@ -casts(); - - return $this->mutate(function (LaravelFile $file) use ($casts) { - $this->requireKind($file, ['class']); - - [$array, $where] = $this->literal($file); - - $counts = ['added' => 0, 'updated' => 0, 'unchanged' => 0]; - - foreach ($casts as $field => $cast) { - $counts[ArrayLiteral::set($array, $field, $cast)]++; - } - - if ($counts['added'] === 0 && $counts['updated'] === 0) { - return $this->unchanged('casts unchanged'); - } - - return sprintf( - 'casts +%d ~%d in %s', - $counts['added'], - $counts['updated'], - $where - ); - }); - } - - /** @return array */ - protected function casts(): array - { - $casts = []; - - foreach ($this->argument('casts') as $pair) { - if (! str_contains($pair, '=')) { - throw new InvalidArgumentException("expected field=cast, got '$pair'"); - } - - [$field, $cast] = explode('=', $pair, 2); - - $casts[$field] = Code::literal($cast); - } - - return $casts; - } - - /** - * Find the array the model actually casts through, creating one if needed. - * - * @return array{0: Node\Expr\Array_, 1: string} - */ - protected function literal(LaravelFile $file): array - { - if ((new Introspector($file))->method('casts')) { - $array = ArrayLiteral::returnedBy($file, 'casts'); - - if (! $array) { - throw new InvalidArgumentException('casts() does not return an array literal directly'); - } - - return [$array, 'casts()']; - } - - if ($array = ArrayLiteral::defaultOf($file, 'casts')) { - return [$array, '$casts']; - } - - $file->assumeType('array')->protected()->property('casts', []); - - $array = ArrayLiteral::defaultOf($file, 'casts'); - - if (! $array) { - throw new \RuntimeException('could not create a $casts property'); - } - - return [$array, '$casts']; - } -} diff --git a/src/Console/Commands/SetConstCommand.php b/src/Console/Commands/SetConstCommand.php deleted file mode 100644 index d1fdb4d..0000000 --- a/src/Console/Commands/SetConstCommand.php +++ /dev/null @@ -1,40 +0,0 @@ -argument('name'); - $raw = $this->argument('value'); - - return $this->mutate(function (LaravelFile $file) use ($name, $raw) { - $this->requireKind($file, ['class']); - - foreach ((new Introspector($file))->constants() as $constant) { - if ($constant['name'] === $name && $constant['evaluated'] && $constant['value'] === Code::value($raw)) { - return $this->unchanged("$name unchanged"); - } - } - - $raw === null - ? $file->setClassConstant($name) - : $file->classConstant($name, Code::value($raw)); - - return "const $name"; - }); - } -} diff --git a/src/Console/Commands/SetExtendsCommand.php b/src/Console/Commands/SetExtendsCommand.php deleted file mode 100644 index 37b3a1d..0000000 --- a/src/Console/Commands/SetExtendsCommand.php +++ /dev/null @@ -1,34 +0,0 @@ -argument('parent'); - - return $this->mutate(function (LaravelFile $file) use ($parent) { - $this->requireKind($file, ['class']); - - if ($file->extends() === class_basename($parent)) { - return $this->unchanged('extends unchanged'); - } - - $imported = $this->import($file, [$parent]); - - $file->extends(class_basename($parent)); - - return 'extends '.class_basename($parent).($imported ? ' (+use)' : ''); - }); - } -} diff --git a/src/Console/Commands/SetNamespaceCommand.php b/src/Console/Commands/SetNamespaceCommand.php deleted file mode 100644 index 9a681c2..0000000 --- a/src/Console/Commands/SetNamespaceCommand.php +++ /dev/null @@ -1,30 +0,0 @@ -argument('namespace'); - - return $this->mutate(function (LaravelFile $file) use ($namespace) { - if ((string) $file->namespace() === $namespace) { - return $this->unchanged('namespace unchanged'); - } - - $file->namespace($namespace); - - return "namespace $namespace"; - }); - } -} diff --git a/src/Console/Commands/SetPropertyCommand.php b/src/Console/Commands/SetPropertyCommand.php deleted file mode 100644 index fa0c89a..0000000 --- a/src/Console/Commands/SetPropertyCommand.php +++ /dev/null @@ -1,56 +0,0 @@ -argument('name'); - $raw = $this->argument('value'); - - return $this->mutate(function (LaravelFile $file) use ($name, $raw) { - $this->requireKind($file, ['class']); - - $visibility = $this->visibilityOf($file, $name, $this->option('visibility')); - - if ($this->alreadySet($file, $name, $raw, $visibility)) { - return $this->unchanged("\$$name unchanged"); - } - - $raw === null - ? $file->{$visibility}()->setProperty($name) - : $file->{$visibility}()->property($name, Code::value($raw)); - - return "\$$name set"; - }); - } - - protected function alreadySet(LaravelFile $file, string $name, ?string $raw, string $visibility): bool - { - foreach ((new Introspector($file))->properties() as $property) { - if ($property['name'] !== $name) { - continue; - } - - return $property['visibility'] === $visibility - && $property['evaluated'] - && $property['value'] === ($raw === null ? null : Code::value($raw)); - } - - return false; - } -} diff --git a/src/Console/Commands/UseCommand.php b/src/Console/Commands/UseCommand.php new file mode 100644 index 0000000..2bf113f --- /dev/null +++ b/src/Console/Commands/UseCommand.php @@ -0,0 +1,62 @@ +use()`, `$file->use($names)` and `$file->add()->use($names)`. + * + * Without `--add` this replaces the import list wholesale, exactly as the + * endpoint does. + */ +class UseCommand extends EndpointCommand +{ + protected $signature = 'archetype:use + {target : '.self::TARGET_DESCRIPTION.'} + {names?* : Fully qualified names, optionally "Name as Alias". Omit to read them}'; + + protected $description = 'Read or set the import statements of a file'; + + protected function directives(): array + { + return ['add']; + } + + protected function hasValue(): bool + { + return (bool) $this->argument('names'); + } + + protected function get(File $file) + { + return $file->use(); + } + + protected function set(File $file) + { + $names = $this->argument('names'); + $existing = $file->use(); + + if ($this->option('add')) { + $missing = array_values(array_diff($names, $existing)); + + if (! $missing) { + return $this->unchanged('imports unchanged'); + } + + $file->add()->use($missing); + + return 'import +'.count($missing); + } + + if ($existing === $names) { + return $this->unchanged('imports unchanged'); + } + + $file->use($names); + + return 'imports set to '.count($names); + } +} diff --git a/src/Console/Commands/UseTraitCommand.php b/src/Console/Commands/UseTraitCommand.php new file mode 100644 index 0000000..373132a --- /dev/null +++ b/src/Console/Commands/UseTraitCommand.php @@ -0,0 +1,80 @@ +useTrait()`, `$file->useTrait($names)` and + * `$file->add()->useTrait($names)`. + * + * Given a fully qualified name it also adds the import, because a trait used + * without one is never valid PHP. `--no-import` leaves that to you. + */ +class UseTraitCommand extends EndpointCommand +{ + protected $signature = 'archetype:useTrait + {target : '.self::TARGET_DESCRIPTION.'} + {names?* : Trait names, fully qualified to have the import added too. Omit to read them}'; + + protected $description = 'Read or set the traits a class uses'; + + protected function directives(): array + { + return ['add']; + } + + protected function hasValue(): bool + { + return (bool) $this->argument('names'); + } + + /** + * The endpoint answers with `PhpParser\Node\Name` objects, which is right + * for PHP and useless on a command line, so they are printed as the names + * they stand for. + */ + protected function get(File $file) + { + return array_map(fn ($name) => (string) $name, $file->useTrait()); + } + + protected function set(File $file) + { + $this->requireKind($file, ['class']); + + $names = $this->argument('names'); + $short = fn ($name) => class_basename($name); + + if (! $this->option('add')) { + $imported = $this->option('no-import') ? 0 : $this->import($file, $names); + + $file->useTrait(array_map($short, $names)); + + return 'uses set to '.count($names).($imported ? " (+$imported use)" : ''); + } + + $existing = array_map($short, $this->get($file)); + $wanted = array_values(array_filter($names, fn ($name) => ! in_array($short($name), $existing, true))); + + if (! $wanted) { + return $this->unchanged('traits unchanged'); + } + + $imported = $this->option('no-import') ? 0 : $this->import($file, $wanted); + + $file->add()->useTrait(array_map($short, $wanted)); + + return 'uses +'.count($wanted).($imported ? " (+$imported use)" : ''); + } + + /** @return array */ + protected function sharedOptions(): array + { + return array_merge(parent::sharedOptions(), [ + new InputOption('no-import', null, InputOption::VALUE_NONE, 'Do not import the traits'), + ]); + } +} diff --git a/src/Console/Concerns/HasDirectiveFlags.php b/src/Console/Concerns/HasDirectiveFlags.php new file mode 100644 index 0000000..93173b9 --- /dev/null +++ b/src/Console/Concerns/HasDirectiveFlags.php @@ -0,0 +1,108 @@ +add()->property('fillable', 'nickname')` becomes + * `archetype property fillable nickname --add`. The flags are named + * after the directive methods so there is one vocabulary to learn, not two, + * and a command declares only the ones its endpoint actually honours. + */ +trait HasDirectiveFlags +{ + /** directive method => flag description */ + const DIRECTIVES = [ + 'add' => 'Add to what is there instead of replacing it', + 'remove' => 'Remove it', + 'clear' => 'Clear the default value, keeping the declaration', + 'empty' => 'Empty it, keeping the declaration', + 'full' => 'Answer with the fully qualified name', + 'public' => 'Declare it public', + 'protected' => 'Declare it protected', + 'private' => 'Declare it private', + 'static' => 'Declare it static', + ]; + + /** The directives that make an operation a write rather than a read. */ + const WRITING_DIRECTIVES = ['add', 'remove', 'clear', 'empty']; + + /** Which directives this command's endpoint honours. */ + abstract protected function directives(): array; + + /** Apply the flags the caller gave to the file, in the order the API would. */ + protected function withDirectives(File $file): File + { + foreach ($this->directives() as $directive) { + if ($this->option($directive)) { + $file = $file->{$directive}(); + } + } + + if ($this->hasOption('assume-type') && $type = $this->option('assume-type')) { + $file = $file->assumeType($type); + } + + return $file; + } + + /** True when the caller asked a question rather than for a change. */ + protected function isRead(): bool + { + foreach (array_intersect($this->directives(), self::WRITING_DIRECTIVES) as $directive) { + if ($this->option($directive)) { + return false; + } + } + + return ! $this->hasValue(); + } + + /** Reject flag combinations the endpoint cannot act on together. */ + protected function guardDirectives(): void + { + $given = array_values(array_filter( + array_intersect($this->directives(), self::WRITING_DIRECTIVES), + fn ($directive) => $this->option($directive) + )); + + if (count($given) > 1) { + throw new InvalidArgumentException( + 'only one of '.implode(', ', array_map(fn ($d) => "--$d", $given)).' at a time' + ); + } + + $visibility = array_values(array_filter( + ['public', 'protected', 'private'], + fn ($flag) => in_array($flag, $this->directives(), true) && $this->option($flag) + )); + + if (count($visibility) > 1) { + throw new InvalidArgumentException( + 'only one of '.implode(', ', array_map(fn ($f) => "--$f", $visibility)).' at a time' + ); + } + } + + /** @return array */ + protected function directiveOptions(): array + { + $options = []; + + foreach ($this->directives() as $directive) { + $options[] = new InputOption( + $directive, + null, + InputOption::VALUE_NONE, + self::DIRECTIVES[$directive] + ); + } + + return $options; + } +} diff --git a/src/Console/EndpointCommand.php b/src/Console/EndpointCommand.php new file mode 100644 index 0000000..2384e44 --- /dev/null +++ b/src/Console/EndpointCommand.php @@ -0,0 +1,99 @@ +add()->property('fillable', 'nickname')` and + * `archetype property fillable nickname --add` are the same call. + */ +abstract class EndpointCommand extends MutationCommand +{ + use HasDirectiveFlags; + + /** Read the endpoint. Return the value. */ + abstract protected function get(File $file); + + /** Write the endpoint. Return a short description of what changed. */ + abstract protected function set(File $file); + + /** Whether the caller supplied something to write. */ + abstract protected function hasValue(): bool; + + protected function perform(): int + { + $this->guardDirectives(); + + // Directives are applied at the write itself, not here. They live on + // the file, and the endpoints read them off it — so a file carrying + // `add` would treat a read taken along the way as another write. + return $this->isRead() + ? $this->readEach(fn (File $file) => $this->get($this->withDirectives($file))) + : $this->mutate(fn (File $file) => $this->set($file)); + } + + /** + * Answer the same question for every target. + * + * A single file answers with the bare value, so it can be used in a script + * without trimming anything off. A directory answers with one `path value` + * line per file, because otherwise the values would not say what they + * belong to. + */ + protected function readEach(callable $read): int + { + $targets = $this->targets(); + $single = count($targets) === 1 && ! Target::isDirectory($this->argument('target')); + $values = []; + + foreach ($targets as $path) { + $value = $read(LaravelFile::load($path)); + $values[$path] = $value; + + $this->emit(trim(($single ? '' : $path.' ').$this->present($value))); + } + + $this->payload = $single + ? ['file' => array_key_first($values), 'value' => reset($values)] + : ['values' => $values, 'count' => count($values)]; + + return self::SUCCESS; + } + + /** Scalars raw so they can be piped; anything structured as compact JSON. */ + protected function present($value): string + { + if (is_string($value)) { + return $value; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value === null) { + return 'null'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** @return array */ + protected function sharedOptions(): array + { + return array_merge(parent::sharedOptions(), $this->directiveOptions()); + } +} diff --git a/src/Console/Support/Manifest.php b/src/Console/Support/Manifest.php index a461b79..9f0062d 100644 --- a/src/Console/Support/Manifest.php +++ b/src/Console/Support/Manifest.php @@ -8,173 +8,143 @@ * The console's contract, in one screen. * * A caller decides how to work before it decides which tool to use, so the - * whole surface has to be legible in a single, cheap answer. Every command is - * listed here with its shape; there is deliberately nowhere else to look. + * whole surface has to be legible in a single, cheap answer. There is + * deliberately nowhere else to look. + * + * The list is in two halves, and the split is the rule the naming follows: a + * command named after a `PHPFile` or `LaravelFile` endpoint *is* that endpoint, + * with its directives as flags, and behaves as the PHP API does. A command with + * a name of its own has no PHP equivalent and is the console's alone. */ class Manifest { - /** operation => [class, usage, description] */ - const OPERATIONS = [ - 'inspect' => [ - Commands\InspectCommand::class, - ' [meta|traits|uses|consts|cases|props|methods|relations]...', - 'Structure of a file, without method bodies', - ], - 'show' => [ - Commands\ShowCommand::class, - ' ', - 'Source of one method', - ], - 'find' => [ - Commands\FindCommand::class, - '[] [--type=all|models|controllers|providers|migrations]', - 'List files, narrowed by what they are', - ], - 'errors' => [ - \Archetype\Commands\ErrorsCommand::class, - '', - 'Files that do not parse', - ], - 'make' => [ - Commands\MakeCommand::class, - ' [--file] [--extends=] [--implements=]... [--trait=]...', - 'Create a file or class', - ], - 'set-property' => [ - Commands\SetPropertyCommand::class, - ' [] [--visibility=]', - 'Set a property', - ], - 'add-to-property' => [ - Commands\AddToPropertyCommand::class, - ' ...', - 'Append to an array property, $fillable included', - ], - 'empty-property' => [ - Commands\EmptyPropertyCommand::class, - ' ', - 'Empty a property, keeping the declaration', - ], - 'remove-property' => [ - Commands\RemovePropertyCommand::class, - ' ', - 'Remove a property', - ], - 'set-casts' => [ - Commands\SetCastsCommand::class, - ' =...', - 'Set casts, writing to casts() or $casts, whichever the model uses', - ], - 'add-relation' => [ - Commands\AddRelationCommand::class, - ' [] [--name=] [--morph-name=] [--through=] [--table=] [--with-pivot=] …', - 'Add an Eloquent relationship, any of the eleven types', - ], - 'set-array-key' => [ - Commands\SetArrayKeyCommand::class, - ' [] [--append] [--remove]', - 'Edit the array a method returns — rules(), toArray(), casts()', - ], - 'add-use' => [ - Commands\AddUseCommand::class, - ' ...', - 'Add imports', - ], - 'remove-use' => [ - Commands\RemoveUseCommand::class, - ' ...', - 'Remove imports', - ], - 'add-trait' => [ - Commands\AddTraitCommand::class, - ' ...', - 'Use a trait, importing it too', - ], - 'add-implements' => [ - Commands\AddImplementsCommand::class, - ' ...', - 'Implement interfaces, importing them too', - ], - 'set-extends' => [ - Commands\SetExtendsCommand::class, - ' ', - 'Set the parent class', - ], - 'set-namespace' => [ - Commands\SetNamespaceCommand::class, - ' ', - 'Set the namespace', - ], - 'rename-class' => [ - Commands\RenameClassCommand::class, - ' ', - 'Rename the declared class', - ], - 'set-const' => [ - Commands\SetConstCommand::class, - ' []', - 'Set a class constant', - ], - 'remove-const' => [ - Commands\RemoveConstCommand::class, - ' ', - 'Remove a class constant', - ], - 'add-case' => [ - Commands\AddCaseCommand::class, - ' []', - 'Add an enum case', - ], - 'add-method' => [ - Commands\AddMethodCommand::class, - ' --code=', - 'Add a method to a class, enum, interface or trait', - ], - 'replace-method' => [ - Commands\ReplaceMethodCommand::class, - ' --code=', - 'Replace a method', - ], - 'remove-method' => [ - Commands\RemoveMethodCommand::class, - ' ', - 'Remove a method', - ], - 'apply' => [ - Commands\ApplyCommand::class, - '[]', - 'Run several operations from a script or standard input', - ], + /** endpoint => [usage, description] — these mirror the PHP API */ + const ENDPOINTS = [ + 'property' => [' [] [--add|--remove|--empty|--clear] [--public|--protected|--private]', 'A class property'], + 'className' => [' [] [--full]', 'The declared class name'], + 'extends' => [' []', 'The parent class'], + 'implements' => [' [...] [--add]', 'The interfaces a class implements'], + 'namespace' => [' [] [--remove]', 'The namespace of a file'], + 'use' => [' [...] [--add]', 'The import statements'], + 'useTrait' => [' [...] [--add]', 'The traits a class uses'], + 'classConstant' => [' [] [--add|--remove|--empty|--clear]', 'A class constant'], + 'methodNames' => ['', 'The names of the declared methods'], + 'fillable' => [' [] [--add|--remove|--empty|--clear]', '$fillable'], + 'hidden' => [' [] [--add|--remove|--empty|--clear]', '$hidden'], + 'visible' => [' [] [--add|--remove|--empty|--clear]', '$visible'], + 'guarded' => [' [] [--add|--remove|--empty|--clear]', '$guarded'], + 'unguarded' => [' [] [--add|--remove|--empty|--clear]', '$unguarded'], + 'casts' => [' [] [--add|--remove|--empty|--clear]', '$casts'], + 'dates' => [' [] [--add|--remove|--empty|--clear]', '$dates'], + 'table' => [' []', '$table'], + 'connection' => [' []', '$connection'], + 'timestamps' => [' []', '$timestamps'], + 'hasOne' => [' [--name=] [--foreign-key=] [--local-key=]', 'A hasOne relationship'], + 'hasMany' => [' [--name=] [--foreign-key=] [--local-key=]', 'A hasMany relationship'], + 'belongsTo' => [' [--name=] [--foreign-key=] [--owner-key=]', 'A belongsTo relationship'], + 'belongsToMany' => [' [--table=] [--with-pivot=] [--with-timestamps]', 'A belongsToMany relationship'], + 'make' => [' [--file] [--extends=] [--implements=]... [--trait=]...', 'A new file or class'], + 'errors' => ['', 'Files that do not parse'], + ]; + + /** operation => [usage, description] — these have no PHP equivalent */ + const ADDITIONS = [ + 'inspect' => [' [meta|traits|uses|consts|cases|props|methods|relations]...', 'Structure of a file, without method bodies'], + 'show' => [' ', 'Source of one method'], + 'find' => ['[] [--type=all|models|controllers|providers|migrations]', 'List files, narrowed by what they are'], + 'set-array-key' => [' [] [--append] [--remove]', 'Edit the array a method returns — rules(), toArray(), casts()'], + 'add-case' => [' []', 'Add an enum case'], + 'add-method' => [' --code=', 'Add a method to a class, enum, interface or trait'], + 'replace-method' => [' --code=', 'Replace a method'], + 'remove-method' => [' ', 'Remove a method'], + 'apply' => ['[]', 'Run several operations from a script or standard input'], + 'hasOneThrough' => [' --through=', 'A hasOneThrough relationship'], + 'hasManyThrough' => [' --through=', 'A hasManyThrough relationship'], + 'morphOne' => [' --morph-name=', 'A morphOne relationship'], + 'morphMany' => [' --morph-name=', 'A morphMany relationship'], + 'morphTo' => [' [--morph-name=]', 'A morphTo relationship'], + 'morphToMany' => [' --morph-name=', 'A morphToMany relationship'], + 'morphedByMany' => [' --morph-name=', 'A morphedByMany relationship'], + ]; + + /** Relation types the console offers beyond the four LaravelFile endpoints. */ + const EXTRA_RELATIONS = [ + 'hasOneThrough', 'hasManyThrough', + 'morphOne', 'morphMany', 'morphTo', 'morphToMany', 'morphedByMany', + ]; + + /** Commands that are one class each. */ + const SINGLETONS = [ + Commands\HelpCommand::class, + Commands\PropertyCommand::class, + Commands\ClassNameCommand::class, + Commands\ExtendsCommand::class, + Commands\ImplementsCommand::class, + Commands\NamespaceCommand::class, + Commands\UseCommand::class, + Commands\UseTraitCommand::class, + Commands\ClassConstantCommand::class, + Commands\MethodNamesCommand::class, + Commands\MakeCommand::class, + Commands\InspectCommand::class, + Commands\ShowCommand::class, + Commands\FindCommand::class, + Commands\SetArrayKeyCommand::class, + Commands\AddCaseCommand::class, + Commands\AddMethodCommand::class, + Commands\ReplaceMethodCommand::class, + Commands\RemoveMethodCommand::class, + Commands\ApplyCommand::class, ]; /** - * The console's own commands. + * Everything the service provider registers. * - * `errors` is listed above so it shows up in the operation map, but it - * predates this console and the service provider registers it directly, so - * it is not returned here. + * The model properties and the relations are one class each, named by a + * constructor argument, because they are one endpoint underneath and ten + * near-identical files would say nothing that this does not. * - * @return array + * `errors` is listed in the map above so it appears in the operation list, + * but it predates this console and the provider registers it directly. + * + * @return array */ public static function commands(): array { return array_merge( - [Commands\HelpCommand::class], - array_values(array_filter( - array_map(fn ($operation) => $operation[0], self::OPERATIONS), - fn ($class) => str_starts_with($class, 'Archetype\\Console\\') - )) + self::SINGLETONS, + array_map( + fn ($property) => new Commands\ModelPropertyCommand($property), + array_keys(Commands\ModelPropertyCommand::PROPERTIES) + ), + array_map( + fn ($type) => new Commands\RelationCommand($type), + array_merge(Commands\RelationCommand::ENDPOINTS, self::EXTRA_RELATIONS) + ), ); } - /** @return array */ + /** Every operation name, in the order the map prints them. */ + public static function operations(): array + { + return array_merge(array_keys(self::ENDPOINTS), array_keys(self::ADDITIONS)); + } + + /** @return array the operation map, as printed by `archetype` */ public static function lines(): array { - $width = max(array_map('strlen', array_keys(self::OPERATIONS))); + $width = max(array_map('strlen', self::operations())); - return collect(self::OPERATIONS) - ->map(fn ($operation, $name) => rtrim(' '.str_pad($name, $width).' '.$operation[1])) + $render = fn (array $operations) => collect($operations) + ->map(fn ($operation, $name) => rtrim(' '.str_pad($name, $width).' '.$operation[0])) ->values() ->all(); + + return array_merge( + ['These are the PHP API endpoints. Give a value to write, none to read.', ''], + $render(self::ENDPOINTS), + ['', 'These have no PHP equivalent.', ''], + $render(self::ADDITIONS) + ); } } diff --git a/tests/Feature/Console/AddRelationCommandTest.php b/tests/Feature/Console/AddRelationCommandTest.php deleted file mode 100644 index 3f6437d..0000000 --- a/tests/Feature/Console/AddRelationCommandTest.php +++ /dev/null @@ -1,114 +0,0 @@ -succeeded())->toBeTrue(); - expect($result->lines()[0])->toBe('OK app/Models/Project.php hasMany tasks'); - expect(Console::read('app/Models/Project.php')) - ->toContain('return $this->hasMany(Task::class);') - ->toContain('Get the associated Tasks'); -}); - -it('appends the method after what is already there', function () { - Console::run('archetype:add-relation app/Models/Project.php hasMany Task'); - - $source = Console::read('app/Models/Project.php'); - - expect(strpos($source, 'public function tasks'))->toBeGreaterThan(strpos($source, '$fillable')); -}); - -it('imports a related class from another namespace', function () { - Console::run('archetype:add-relation', [ - 'target' => 'app/Models/Project.php', - 'type' => 'belongsTo', - 'related' => 'App\Domain\Owner', - ]); - - expect(Console::read('app/Models/Project.php')) - ->toContain('use App\Domain\Owner;') - ->toContain('return $this->belongsTo(Owner::class);'); -}); - -it('overrides the method name', function () { - Console::run('archetype:add-relation app/Models/Project.php belongsTo User --name=owner --foreign-key=owner_id'); - - expect(Console::read('app/Models/Project.php')) - ->toContain('public function owner()') - ->toContain("return \$this->belongsTo(User::class, 'owner_id');"); -}); - -it('writes a belongsToMany with a pivot', function () { - Console::run('archetype:add-relation app/Models/Project.php belongsToMany Label --table=label_project --with-pivot=sort,note --with-timestamps'); - - expect(Console::read('app/Models/Project.php'))->toContain( - "return \$this->belongsToMany(Label::class, 'label_project')->withPivot('sort', 'note')->withTimestamps();" - ); -}); - -it('writes the polymorphic relations', function () { - Console::run('archetype:add-relation app/Models/Project.php morphMany Comment --morph-name=commentable'); - - expect(Console::read('app/Models/Project.php')) - ->toContain("return \$this->morphMany(Comment::class, 'commentable');") - ->toContain('public function comments()'); -}); - -it('writes a through relation', function () { - Console::run('archetype:add-relation app/Models/Project.php hasManyThrough Comment --through=Task'); - - expect(Console::read('app/Models/Project.php')) - ->toContain('return $this->hasManyThrough(Comment::class, Task::class);'); -}); - -it('will not add a relation that is already there', function () { - Console::run('archetype:add-relation app/Models/Project.php hasMany Task'); - $again = Console::run('archetype:add-relation app/Models/Project.php hasMany Task'); - - expect($again->succeeded())->toBeTrue(); - expect($again->lines())->toBe(['SKIP app/Models/Project.php tasks exists']); -}); - -it('rejects a relation type it does not have', function () { - $result = Console::run('archetype:add-relation app/Models/Project.php hasSome Task'); - - expect($result->succeeded())->toBeFalse(); - expect($result->output)->toContain("unknown relation type 'hasSome'"); -}); - -it('insists on the arguments a relation needs', function () { - expect(Console::run('archetype:add-relation app/Models/Project.php morphMany Comment')->output) - ->toContain('needs --morph-name'); - - expect(Console::run('archetype:add-relation app/Models/Project.php hasManyThrough Comment')->output) - ->toContain('needs --through'); - - expect(Console::run('archetype:add-relation app/Models/Project.php hasMany')->output) - ->toContain('needs a related class'); -}); - -it('refuses to guess an argument the caller skipped', function () { - $result = Console::run('archetype:add-relation app/Models/Project.php hasMany Task --local-key=uuid'); - - expect($result->succeeded())->toBeFalse(); - expect($result->output)->toContain('--local-key cannot be given without the arguments before it'); -}); diff --git a/tests/Feature/Console/ApplyCommandTest.php b/tests/Feature/Console/ApplyCommandTest.php index 8bd9b6e..80fc418 100644 --- a/tests/Feature/Console/ApplyCommandTest.php +++ b/tests/Feature/Console/ApplyCommandTest.php @@ -15,9 +15,9 @@ function script(string $contents): string it('runs several operations in one call', function () { $result = Console::run('archetype:apply '.script(<<<'TXT' # everything this change needs, in one call - add-to-property app/Models/User.php fillable nickname - set-casts app/Models/User.php is_admin=boolean - add-relation app/Models/User.php hasMany Post + fillable app/Models/User.php nickname --add + casts app/Models/User.php '{"is_admin":"boolean"}' --add + hasMany app/Models/User.php Post TXT)); expect($result->succeeded())->toBeTrue(); @@ -30,16 +30,16 @@ function script(string $contents): string }); it('accepts operations written with the prefix', function () { - $result = Console::run('archetype:apply '.script('archetype:add-to-property app/Models/User.php fillable nickname')); + $result = Console::run('archetype:apply '.script('archetype:fillable app/Models/User.php nickname --add')); expect($result->succeeded())->toBeTrue(); - expect($result->output)->toContain('OK app/Models/User.php $fillable +1'); + expect($result->output)->toContain('OK app/Models/User.php $fillable added to'); }); it('reports a failing operation and keeps going', function () { $result = Console::run('archetype:apply '.script(<<<'TXT' - add-to-property app/Models/Nope.php fillable slug - add-to-property app/Models/User.php fillable nickname + fillable app/Models/Nope.php slug --add + fillable app/Models/User.php nickname --add TXT)); expect($result->succeeded())->toBeFalse(); @@ -49,8 +49,8 @@ function script(string $contents): string it('stops at the first failure when asked', function () { $result = Console::run('archetype:apply '.script(<<<'TXT' - add-to-property app/Models/Nope.php fillable slug - add-to-property app/Models/User.php fillable nickname + fillable app/Models/Nope.php slug --add + fillable app/Models/User.php nickname --add TXT).' --stop-on-failure'); expect($result->succeeded())->toBeFalse(); @@ -60,13 +60,13 @@ function script(string $contents): string it('reports each operation as json', function () { $payload = Console::run('archetype:apply '.script(<<<'TXT' - add-to-property app/Models/User.php fillable nickname - set-casts app/Models/User.php is_admin=boolean + fillable app/Models/User.php nickname --add + casts app/Models/User.php '{"is_admin":"boolean"}' --add TXT).' --json')->json(); expect($payload['ok'])->toBeTrue(); expect($payload['ran'])->toBe(2); - expect($payload['results'][0]['operation'])->toBe('add-to-property app/Models/User.php fillable nickname'); + expect($payload['results'][0]['operation'])->toBe('fillable app/Models/User.php nickname --add'); expect(json_decode($payload['results'][0]['output'], true)['changed'])->toBe(1); }); diff --git a/tests/Feature/Console/EnumCommandsTest.php b/tests/Feature/Console/EnumCommandsTest.php index e72611c..565dae5 100644 --- a/tests/Feature/Console/EnumCommandsTest.php +++ b/tests/Feature/Console/EnumCommandsTest.php @@ -75,12 +75,13 @@ enum Suit // The implements endpoint addresses classes, so this cannot be done here. // What matters is that it is refused before the import is written: half a // change that reports success is worse than no change at all. - $result = Console::run('archetype:add-implements', [ + $result = Console::run('archetype:implements', [ 'target' => 'app/Enums/ProjectStatus.php', - 'interfaces' => ['App\Contracts\HasColor'], + 'names' => ['App\Contracts\HasColor'], + '--add' => true, ]); expect($result->succeeded())->toBeFalse(); - expect($result->output)->toContain('archetype:add-implements only works on classes, and this is an enum'); + expect($result->output)->toContain('archetype:implements only works on classes, and this is an enum'); expect(Console::read('app/Enums/ProjectStatus.php'))->not->toContain('HasColor'); }); diff --git a/tests/Feature/Console/HelpCommandTest.php b/tests/Feature/Console/HelpCommandTest.php index 4d1ae3a..1876a17 100644 --- a/tests/Feature/Console/HelpCommandTest.php +++ b/tests/Feature/Console/HelpCommandTest.php @@ -2,32 +2,55 @@ use Archetype\Console\Support\Manifest; use Archetype\Tests\Support\Console; +use Illuminate\Support\Facades\Artisan; it('lists every operation in one answer', function () { $result = Console::run('archetype'); expect($result->succeeded())->toBeTrue(); - foreach (array_keys(Manifest::OPERATIONS) as $operation) { + foreach (Manifest::operations() as $operation) { expect($result->output)->toContain($operation); } }); +it('separates the endpoints from the console additions', function () { + $output = Console::run('archetype')->output; + + expect($output)->toContain('These are the PHP API endpoints. Give a value to write, none to read.'); + expect($output)->toContain('These have no PHP equivalent.'); + + // The rule the naming follows has to be visible, or it is not a rule. + expect(strpos($output, 'property'))->toBeLessThan(strpos($output, 'These have no PHP equivalent.')); + expect(strpos($output, 'set-array-key'))->toBeGreaterThan(strpos($output, 'These have no PHP equivalent.')); +}); + it('describes the operations as json', function () { $payload = Console::run('archetype --json')->json(); - expect($payload['operations'])->toHaveCount(count(Manifest::OPERATIONS)); - expect($payload['operations'][0])->toHaveKeys(['operation', 'usage', 'description']); + expect($payload['operations'])->toHaveCount(count(Manifest::operations())); + expect($payload['operations'][0])->toHaveKeys(['operation', 'usage', 'description', 'kind']); + expect(collect($payload['operations'])->firstWhere('operation', 'property')['kind'])->toBe('endpoint'); + expect(collect($payload['operations'])->firstWhere('operation', 'inspect')['kind'])->toBe('console'); }); -it('registers every command the manifest names', function () { - foreach (Manifest::commands() as $class) { - expect(class_exists($class))->toBeTrue("missing $class"); +it('registers a command for every operation it lists', function () { + $registered = array_keys(Artisan::all()); + + foreach (Manifest::operations() as $operation) { + expect($registered)->toContain("archetype:$operation"); } +}); - $registered = array_keys(Illuminate\Support\Facades\Artisan::all()); +it('names every endpoint command after a real endpoint', function () { + $php = get_class_methods(Archetype\LaravelFile::class); - foreach (array_keys(Manifest::OPERATIONS) as $operation) { - expect($registered)->toContain("archetype:$operation"); + foreach (array_keys(Manifest::ENDPOINTS) as $operation) { + if (in_array($operation, ['errors'], true)) { + continue; + } + + expect(in_array($operation, $php, true)) + ->toBeTrue("$operation is listed as an endpoint but LaravelFile has no such method"); } }); diff --git a/tests/Feature/Console/ModelPropertyCommandTest.php b/tests/Feature/Console/ModelPropertyCommandTest.php new file mode 100644 index 0000000..2b68aa6 --- /dev/null +++ b/tests/Feature/Console/ModelPropertyCommandTest.php @@ -0,0 +1,84 @@ +toContain("archetype:$property"); + } +}); + +it('reads fillable', function () { + expect(Console::run('archetype:fillable app/Models/User.php')->output) + ->toBe('["name","email","password"]'); +}); + +it('adds to fillable', function () { + $result = Console::run('archetype:fillable app/Models/User.php nickname --add'); + + expect($result->lines()[0])->toBe('OK app/Models/User.php $fillable added to'); + expect(Console::read('app/Models/User.php'))->toContain("'nickname',"); +}); + +it('sets fillable wholesale without --add, as the endpoint does', function () { + Console::run('archetype:fillable app/Models/User.php \'["only_this"]\''); + + $source = Console::read('app/Models/User.php'); + + expect($source)->toContain("'only_this',"); + // 'password' also lives in $hidden, so 'email' is the one that proves it. + expect($source)->not->toContain("'email',"); +}); + +it('sets the table', function () { + Console::run('archetype:table app/Models/User.php gdpr_users'); + + expect(Console::read('app/Models/User.php'))->toContain("protected \$table = 'gdpr_users';"); +}); + +it('writes casts into the $casts property', function () { + Console::run('archetype:casts app/Models/User.php \'{"is_admin":"boolean"}\' --add'); + + expect(Console::read('app/Models/User.php')) + ->toContain("'email_verified_at' => 'datetime',") + ->toContain("'is_admin' => 'boolean',"); +}); + +it('refuses to write $casts on a model that declares a casts() method', function () { + Console::write('app/Models/Project.php', <<<'PHP' + 'datetime', + ]; + } + } + PHP); + + $result = Console::run('archetype:casts app/Models/Project.php \'{"archived":"boolean"}\' --add'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('two casting mechanisms'); + expect($result->output)->toContain('archetype:set-array-key'); + expect(Console::read('app/Models/Project.php'))->not->toContain('protected $casts'); +}); + +it('empties and removes', function () { + Console::run('archetype:hidden app/Models/User.php --empty'); + expect(Console::read('app/Models/User.php'))->toContain('protected $hidden = [];'); + + Console::run('archetype:hidden app/Models/User.php --remove'); + expect(Console::read('app/Models/User.php'))->not->toContain('$hidden'); +}); diff --git a/tests/Feature/Console/MutationContractTest.php b/tests/Feature/Console/MutationContractTest.php index cc7965a..0615f62 100644 --- a/tests/Feature/Console/MutationContractTest.php +++ b/tests/Feature/Console/MutationContractTest.php @@ -3,23 +3,23 @@ use Archetype\Tests\Support\Console; it('answers with a diff of what it changed', function () { - $result = Console::run('archetype:add-to-property app/Models/User.php fillable nickname'); + $result = Console::run('archetype:fillable app/Models/User.php nickname --add'); expect($result->succeeded())->toBeTrue(); - expect($result->lines()[0])->toBe('OK app/Models/User.php $fillable +1'); + expect($result->lines()[0])->toBe('OK app/Models/User.php $fillable added to'); expect($result->output)->toContain('@@ '); expect($result->output)->toContain("+ 'nickname',"); }); it('suppresses the diff when asked', function () { - $result = Console::run('archetype:add-to-property app/Models/User.php fillable nickname --no-diff'); + $result = Console::run('archetype:fillable app/Models/User.php nickname --add --no-diff'); - expect($result->lines())->toBe(['OK app/Models/User.php $fillable +1']); + expect($result->lines())->toBe(['OK app/Models/User.php $fillable added to']); }); it('skips work already done instead of failing', function () { - $first = Console::run('archetype:add-to-property app/Models/User.php fillable nickname'); - $second = Console::run('archetype:add-to-property app/Models/User.php fillable nickname'); + $first = Console::run('archetype:fillable app/Models/User.php nickname --add'); + $second = Console::run('archetype:fillable app/Models/User.php nickname --add'); expect($first->succeeded())->toBeTrue(); expect($second->succeeded())->toBeTrue(); @@ -28,10 +28,10 @@ it('writes nothing on a dry run', function () { $before = Console::read('app/Models/User.php'); - $result = Console::run('archetype:add-to-property app/Models/User.php fillable nickname --dry-run'); + $result = Console::run('archetype:fillable app/Models/User.php nickname --add --dry-run'); expect($result->succeeded())->toBeTrue(); - expect($result->lines()[0])->toBe('DRY app/Models/User.php $fillable +1'); + expect($result->lines()[0])->toBe('DRY app/Models/User.php $fillable added to'); expect($result->output)->toContain("+ 'nickname',"); expect(Console::read('app/Models/User.php'))->toBe($before); }); @@ -40,7 +40,7 @@ Console::write('app/helpers.php', "succeeded())->toBeFalse(); expect($result->output)->toContain('only works on classes, and this is a file'); @@ -59,10 +59,10 @@ enum Status: string } PHP); - $result = Console::run('archetype:set-property app/Enums/Status.php table users'); + $result = Console::run('archetype:property app/Enums/Status.php table users'); expect($result->succeeded())->toBeFalse(); - expect($result->output)->toContain('archetype:set-property only works on classes, and this is an enum'); + expect($result->output)->toContain('archetype:property only works on classes, and this is an enum'); expect(Console::read('app/Enums/Status.php'))->not->toContain('table'); }); @@ -70,9 +70,10 @@ enum Status: string Console::write('app/Models/Project.php', modelSource('Project')); Console::write('app/Models/Task.php', modelSource('Task')); - $result = Console::run('archetype:add-trait', [ + $result = Console::run('archetype:useTrait', [ 'target' => 'app/Models', - 'traits' => ['Illuminate\Database\Eloquent\SoftDeletes'], + 'names' => ['Illuminate\Database\Eloquent\SoftDeletes'], + '--add' => true, ]); expect($result->succeeded())->toBeTrue(); @@ -84,7 +85,7 @@ enum Status: string it('narrows a directory change with a filter', function () { Console::write('app/Models/Project.php', modelSource('Project')); - $result = Console::run('archetype:add-to-property app/Models fillable slug --extends=Model'); + $result = Console::run('archetype:fillable app/Models slug --add --extends=Model'); expect($result->succeeded())->toBeTrue(); expect(Console::read('app/Models/Project.php'))->toContain("'slug'"); @@ -92,14 +93,14 @@ enum Status: string }); it('refuses a filter when the target is a single file', function () { - $result = Console::run('archetype:add-to-property app/Models/User.php fillable slug --extends=Model'); + $result = Console::run('archetype:fillable app/Models/User.php slug --add --extends=Model'); expect($result->succeeded())->toBeFalse(); expect($result->output)->toContain('--extends only applies when the target is a directory'); }); it('reports a mutation as json', function () { - $payload = Console::run('archetype:add-to-property app/Models/User.php fillable nickname --json')->json(); + $payload = Console::run('archetype:fillable app/Models/User.php nickname --add --json')->json(); expect($payload['ok'])->toBeTrue(); expect($payload['changed'])->toBe(1); @@ -109,7 +110,7 @@ enum Status: string }); it('fails on a target that does not exist', function () { - $result = Console::run('archetype:add-to-property app/Models/Nope.php fillable slug'); + $result = Console::run('archetype:fillable app/Models/Nope.php slug --add'); expect($result->succeeded())->toBeFalse(); expect($result->output)->toStartWith('ERR app/Models/Nope.php'); diff --git a/tests/Feature/Console/PropertyCommandTest.php b/tests/Feature/Console/PropertyCommandTest.php new file mode 100644 index 0000000..0464baf --- /dev/null +++ b/tests/Feature/Console/PropertyCommandTest.php @@ -0,0 +1,108 @@ +succeeded())->toBeTrue(); + expect($result->output)->toBe('null'); + + expect(Console::run('archetype:property app/Models/User.php fillable')->output) + ->toBe('["name","email","password"]'); +}); + +it('reads with the $ in the name, since that is how it is written', function () { + expect(Console::run('archetype:property app/Models/User.php \'$fillable\'')->output) + ->toBe('["name","email","password"]'); +}); + +it('writes a property when given a value', function () { + Console::run('archetype:property app/Models/User.php table gdpr_users'); + + expect(Console::read('app/Models/User.php'))->toContain("protected \$table = 'gdpr_users';"); +}); + +it('takes json for anything that is not a plain string', function () { + Console::run('archetype:property app/Models/User.php with \'["profile","posts"]\''); + + expect(Console::read('app/Models/User.php')) + ->toContain("protected \$with = [\n 'profile',\n 'posts',\n ];"); +}); + +it('adds to an array property with --add', function () { + $result = Console::run('archetype:property app/Models/User.php fillable nickname --add'); + + expect($result->lines()[0])->toBe('OK app/Models/User.php $fillable added to'); + expect(Console::read('app/Models/User.php'))->toContain("'nickname',"); +}); + +it('adds several at once when given json', function () { + Console::run('archetype:property app/Models/User.php fillable \'["nickname","avatar"]\' --add'); + + expect(Console::read('app/Models/User.php')) + ->toContain("'nickname',") + ->toContain("'avatar',"); +}); + +it('skips an --add that is already there', function () { + $result = Console::run('archetype:property app/Models/User.php fillable name --add'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Models/User.php $fillable unchanged']); +}); + +it('empties with --empty and removes with --remove', function () { + Console::run('archetype:property app/Models/User.php fillable --empty'); + expect(Console::read('app/Models/User.php'))->toContain('protected $fillable = [];'); + + Console::run('archetype:property app/Models/User.php hidden --remove'); + expect(Console::read('app/Models/User.php'))->not->toContain('$hidden'); +}); + +it('declares a property without a default with --clear', function () { + Console::run('archetype:property app/Models/User.php connection --clear'); + + expect(Console::read('app/Models/User.php'))->toContain('protected $connection;'); +}); + +it('reports a --remove of something absent rather than failing', function () { + $result = Console::run('archetype:property app/Models/User.php nope --remove'); + + expect($result->succeeded())->toBeTrue(); + expect($result->lines())->toBe(['SKIP app/Models/User.php no $nope']); +}); + +it('takes the visibility directives as flags', function () { + Console::run('archetype:property app/Models/User.php perPage 25 --public'); + + expect(Console::read('app/Models/User.php'))->toContain('public $perPage = 25;'); +}); + +it('leaves visibility alone unless a flag says otherwise', function () { + Console::run('archetype:property app/Models/User.php visible \'["id"]\' --public'); + Console::run('archetype:property app/Models/User.php visible name --add'); + + expect(Console::read('app/Models/User.php'))->toContain('public $visible'); +}); + +it('refuses two directives that contradict each other', function () { + $result = Console::run('archetype:property app/Models/User.php fillable --add --remove'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('only one of --add, --remove at a time'); +}); + +it('refuses two visibilities at once', function () { + $result = Console::run('archetype:property app/Models/User.php table x --public --private'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('only one of --public, --private at a time'); +}); + +it('reads the same property across a directory', function () { + $result = Console::run('archetype:property app/Models fillable'); + + expect($result->succeeded())->toBeTrue(); + expect($result->output)->toBe('app/Models/User.php ["name","email","password"]'); +}); diff --git a/tests/Feature/Console/PropertyCommandsTest.php b/tests/Feature/Console/PropertyCommandsTest.php deleted file mode 100644 index eaebdab..0000000 --- a/tests/Feature/Console/PropertyCommandsTest.php +++ /dev/null @@ -1,76 +0,0 @@ -toContain("protected \$table = 'gdpr_users';"); -}); - -it('sets a property from json', function () { - Console::run('archetype:set-property app/Models/User.php with \'["profile","posts"]\''); - - expect(Console::read('app/Models/User.php'))->toContain("protected \$with = [\n 'profile',\n 'posts',\n ];"); -}); - -it('honours the visibility asked for', function () { - Console::run('archetype:set-property app/Models/User.php perPage 25 --visibility=public'); - - expect(Console::read('app/Models/User.php'))->toContain('public $perPage = 25;'); -}); - -it('declares a property with no default when no value is given', function () { - Console::run('archetype:set-property app/Models/User.php connection'); - - expect(Console::read('app/Models/User.php'))->toContain('protected $connection;'); -}); - -it('rejects a visibility that is not one', function () { - $result = Console::run('archetype:set-property app/Models/User.php table x --visibility=internal'); - - expect($result->succeeded())->toBeFalse(); - expect($result->output)->toContain('--visibility must be public, protected or private'); -}); - -it('appends only the values that are missing', function () { - $result = Console::run('archetype:add-to-property app/Models/User.php fillable name nickname'); - - expect($result->lines()[0])->toBe('OK app/Models/User.php $fillable +1'); - expect(Console::read('app/Models/User.php'))->toContain("'nickname',"); -}); - -it('creates an array property that was not there', function () { - Console::run('archetype:add-to-property app/Models/User.php appends full_name'); - - expect(Console::read('app/Models/User.php'))->toContain("protected \$appends = [\n 'full_name',\n ];"); -}); - -it('empties a property but keeps the declaration and its visibility', function () { - Console::run('archetype:empty-property app/Models/User.php fillable'); - - $source = Console::read('app/Models/User.php'); - - expect($source)->toContain('protected $fillable = [];'); - expect($source)->not->toContain("'email',"); -}); - -it('leaves visibility alone unless it is told to change it', function () { - Console::run('archetype:set-property app/Models/User.php visible \'["id"]\' --visibility=public'); - Console::run('archetype:add-to-property app/Models/User.php visible name'); - - expect(Console::read('app/Models/User.php'))->toContain('public $visible'); -}); - -it('removes a property', function () { - Console::run('archetype:remove-property app/Models/User.php hidden'); - - expect(Console::read('app/Models/User.php'))->not->toContain('$hidden'); -}); - -it('reports a property that was never there rather than failing', function () { - $result = Console::run('archetype:remove-property app/Models/User.php nope'); - - expect($result->succeeded())->toBeTrue(); - expect($result->lines())->toBe(['SKIP app/Models/User.php no $nope']); -}); diff --git a/tests/Feature/Console/RelationCommandTest.php b/tests/Feature/Console/RelationCommandTest.php new file mode 100644 index 0000000..344bd09 --- /dev/null +++ b/tests/Feature/Console/RelationCommandTest.php @@ -0,0 +1,125 @@ +toContain("archetype:$type"); + } +}); + +it('produces exactly what the endpoint produces', function () { + Console::run('archetype:hasMany app/Models/Project.php Task'); + $viaConsole = Console::read('app/Models/Project.php'); + + // Reset, then do the same thing straight through the PHP API. + Console::write('app/Models/Project.php', <<<'PHP' + hasMany('Task')->save(); + + expect($viaConsole)->toBe(Console::read('app/Models/Project.php')); +}); + +it('names the method the way the endpoint does', function () { + $result = Console::run('archetype:hasMany app/Models/Project.php Task'); + + expect($result->lines()[0])->toBe('OK app/Models/Project.php hasMany tasks'); + expect(Console::read('app/Models/Project.php')) + ->toContain('return $this->hasMany(Task::class);'); +}); + +it('imports a related class from another namespace', function () { + Console::run('archetype:belongsTo', [ + 'target' => 'app/Models/Project.php', + 'related' => 'App\Domain\Owner', + ]); + + expect(Console::read('app/Models/Project.php')) + ->toContain('use App\Domain\Owner;') + ->toContain('return $this->belongsTo(Owner::class);'); +}); + +it('takes the arguments the endpoint cannot express', function () { + Console::run('archetype:belongsToMany app/Models/Project.php Label --table=label_project --with-pivot=sort,note --with-timestamps'); + + expect(Console::read('app/Models/Project.php'))->toContain( + "return \$this->belongsToMany(Label::class, 'label_project')->withPivot('sort', 'note')->withTimestamps();" + ); +}); + +it('overrides the method name', function () { + Console::run('archetype:belongsTo app/Models/Project.php User --name=owner --foreign-key=owner_id'); + + expect(Console::read('app/Models/Project.php')) + ->toContain('public function owner()') + ->toContain("return \$this->belongsTo(User::class, 'owner_id');"); +}); + +it('offers the relation types the endpoints do not have', function () { + Console::run('archetype:morphMany app/Models/Project.php Comment --morph-name=commentable'); + // morphMany already claimed `comments`, so name this one. + Console::run('archetype:hasManyThrough app/Models/Project.php Comment --through=Task --name=taskComments'); + + expect(Console::read('app/Models/Project.php')) + ->toContain("return \$this->morphMany(Comment::class, 'commentable');") + ->toContain('return $this->hasManyThrough(Comment::class, Task::class);'); +}); + +it('will not add a relation that is already there', function () { + Console::run('archetype:hasMany app/Models/Project.php Task'); + $again = Console::run('archetype:hasMany app/Models/Project.php Task'); + + expect($again->succeeded())->toBeTrue(); + expect($again->lines())->toBe(['SKIP app/Models/Project.php tasks exists']); +}); + +it('insists on the arguments a relation needs', function () { + expect(Console::run('archetype:morphMany app/Models/Project.php Comment')->output) + ->toContain('needs --morph-name'); + + expect(Console::run('archetype:hasManyThrough app/Models/Project.php Comment')->output) + ->toContain('needs --through'); + + expect(Console::run('archetype:hasMany app/Models/Project.php')->output) + ->toContain('needs a related class'); +}); + +it('refuses to guess an argument the caller skipped', function () { + $result = Console::run('archetype:hasMany app/Models/Project.php Task --local-key=uuid'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toContain('--local-key cannot be given without the arguments before it'); +}); diff --git a/tests/Feature/Console/SetCastsCommandTest.php b/tests/Feature/Console/SetCastsCommandTest.php deleted file mode 100644 index 19b8ea1..0000000 --- a/tests/Feature/Console/SetCastsCommandTest.php +++ /dev/null @@ -1,92 +0,0 @@ -succeeded())->toBeTrue(); - expect($result->lines()[0])->toBe('OK app/Models/User.php casts +2 ~0 in $casts'); - expect(Console::read('app/Models/User.php')) - ->toContain("'email_verified_at' => 'datetime',") - ->toContain("'is_admin' => 'boolean',") - ->toContain("'password' => 'hashed',"); -}); - -it('writes to the casts() method when the model has one', function () { - Console::write('app/Models/Project.php', <<<'PHP' - 'datetime', - ]; - } - } - PHP); - - $result = Console::run('archetype:set-casts app/Models/Project.php archived=boolean'); - - expect($result->lines()[0])->toBe('OK app/Models/Project.php casts +1 ~0 in casts()'); - - $source = Console::read('app/Models/Project.php'); - - expect($source)->toContain("'archived' => 'boolean',"); - expect($source)->not->toContain('protected $casts'); -}); - -it('takes an expression as the cast', function () { - Console::run('archetype:set-casts app/Models/User.php status=Status::class role=\'AsEnum:role\''); - - expect(Console::read('app/Models/User.php')) - ->toContain("'status' => Status::class,") - ->toContain("'role' => 'AsEnum:role',"); -}); - -it('creates the property when the model casts nothing yet', function () { - Console::write('app/Models/Task.php', <<<'PHP' - toContain("protected \$casts = [\n 'done' => 'boolean',\n ];"); -}); - -it('updates a cast rather than duplicating it', function () { - $result = Console::run('archetype:set-casts app/Models/User.php email_verified_at=immutable_datetime'); - - expect($result->lines()[0])->toBe('OK app/Models/User.php casts +0 ~1 in $casts'); - expect(Console::read('app/Models/User.php')) - ->toContain("'email_verified_at' => 'immutable_datetime',") - ->not->toContain("'email_verified_at' => 'datetime',"); -}); - -it('skips casts already set', function () { - $result = Console::run('archetype:set-casts app/Models/User.php email_verified_at=datetime'); - - expect($result->succeeded())->toBeTrue(); - expect($result->lines())->toBe(['SKIP app/Models/User.php casts unchanged']); -}); - -it('rejects a pair that is not one', function () { - $result = Console::run('archetype:set-casts app/Models/User.php nonsense'); - - expect($result->succeeded())->toBeFalse(); - expect($result->output)->toContain("expected field=cast, got 'nonsense'"); -}); diff --git a/tests/Feature/Console/StructureCommandsTest.php b/tests/Feature/Console/StructureCommandsTest.php index 901aaf5..6325a36 100644 --- a/tests/Feature/Console/StructureCommandsTest.php +++ b/tests/Feature/Console/StructureCommandsTest.php @@ -2,10 +2,16 @@ use Archetype\Tests\Support\Console; -it('adds imports', function () { - $result = Console::run('archetype:add-use', [ +it('reads the import statements', function () { + expect(Console::run('archetype:use app/Models/User.php')->output) + ->toContain('Illuminate\\\\Notifications\\\\Notifiable'); +}); + +it('adds imports with --add', function () { + $result = Console::run('archetype:use', [ 'target' => 'app/Models/User.php', - 'imports' => ['App\Contracts\Auditable', 'Illuminate\Support\Str'], + 'names' => ['App\Contracts\Auditable', 'Illuminate\Support\Str'], + '--add' => true, ]); expect($result->lines()[0])->toBe('OK app/Models/User.php import +2'); @@ -14,28 +20,39 @@ ->toContain('use Illuminate\Support\Str;'); }); -it('skips imports already there', function () { - $result = Console::run('archetype:add-use', [ +it('replaces the imports without --add, as the endpoint does', function () { + Console::run('archetype:use', [ 'target' => 'app/Models/User.php', - 'imports' => ['Illuminate\Notifications\Notifiable'], + 'names' => ['App\Contracts\Auditable'], ]); - expect($result->lines())->toBe(['SKIP app/Models/User.php imports unchanged']); + $source = Console::read('app/Models/User.php'); + + expect($source)->toContain('use App\Contracts\Auditable;'); + // The trait use line also says Notifiable, so name the import exactly. + expect($source)->not->toContain('use Illuminate\Notifications\Notifiable;'); }); -it('removes imports', function () { - Console::run('archetype:remove-use', [ +it('skips imports already there', function () { + $result = Console::run('archetype:use', [ 'target' => 'app/Models/User.php', - 'imports' => ['Illuminate\Contracts\Auth\MustVerifyEmail'], + 'names' => ['Illuminate\Notifications\Notifiable'], + '--add' => true, ]); - expect(Console::read('app/Models/User.php'))->not->toContain('MustVerifyEmail'); + expect($result->lines())->toBe(['SKIP app/Models/User.php imports unchanged']); +}); + +it('reads the traits a class uses', function () { + expect(Console::run('archetype:useTrait app/Models/User.php')->output) + ->toBe('["HasApiTokens","HasFactory","Notifiable"]'); }); it('uses a trait and imports it in one step', function () { - Console::run('archetype:add-trait', [ + Console::run('archetype:useTrait', [ 'target' => 'app/Models/User.php', - 'traits' => ['Illuminate\Database\Eloquent\SoftDeletes'], + 'names' => ['Illuminate\Database\Eloquent\SoftDeletes'], + '--add' => true, ]); expect(Console::read('app/Models/User.php')) @@ -43,20 +60,38 @@ ->toContain('use SoftDeletes;'); }); -it('implements an interface and imports it in one step', function () { - Console::run('archetype:add-implements', [ +it('leaves the import alone when told to', function () { + Console::run('archetype:useTrait', [ 'target' => 'app/Models/User.php', - 'interfaces' => ['Illuminate\Contracts\Auth\MustVerifyEmail'], + 'names' => ['Illuminate\Database\Eloquent\SoftDeletes'], + '--add' => true, + '--no-import' => true, + ]); + + expect(Console::read('app/Models/User.php')) + ->toContain('use SoftDeletes;') + ->not->toContain('use Illuminate\Database\Eloquent\SoftDeletes;'); +}); + +it('reads and adds interfaces', function () { + expect(Console::run('archetype:implements app/Models/User.php')->output)->toBe('[]'); + + Console::run('archetype:implements', [ + 'target' => 'app/Models/User.php', + 'names' => ['Illuminate\Contracts\Auth\MustVerifyEmail'], + '--add' => true, ]); expect(Console::read('app/Models/User.php')) ->toContain('class User extends Authenticatable implements MustVerifyEmail'); }); -it('sets the parent class', function () { - Console::run('archetype:set-extends', [ +it('reads and sets the parent class', function () { + expect(Console::run('archetype:extends app/Models/User.php')->output)->toBe('Authenticatable'); + + Console::run('archetype:extends', [ 'target' => 'app/Models/User.php', - 'parent' => 'Illuminate\Database\Eloquent\Model', + 'name' => 'Illuminate\Database\Eloquent\Model', ]); expect(Console::read('app/Models/User.php')) @@ -65,77 +100,78 @@ }); it('skips a parent class already set', function () { - $result = Console::run('archetype:set-extends app/Models/User.php Authenticatable'); - - expect($result->lines())->toBe(['SKIP app/Models/User.php extends unchanged']); + expect(Console::run('archetype:extends app/Models/User.php Authenticatable')->lines()) + ->toBe(['SKIP app/Models/User.php extends unchanged']); }); -it('sets the namespace', function () { - Console::run('archetype:set-namespace', [ +it('reads, sets and removes the namespace', function () { + expect(Console::run('archetype:namespace app/Models/User.php')->output)->toBe('App\Models'); + + Console::run('archetype:namespace', [ 'target' => 'app/Models/User.php', - 'namespace' => 'App\Domain\Models', + 'value' => 'App\Domain\Models', ]); expect(Console::read('app/Models/User.php'))->toContain('namespace App\Domain\Models;'); + + Console::run('archetype:namespace app/Models/User.php --remove'); + + expect(Console::read('app/Models/User.php'))->not->toContain('namespace'); }); -it('renames the class', function () { - Console::run('archetype:rename-class app/Models/User.php Account'); +it('reads and sets the class name', function () { + expect(Console::run('archetype:className app/Models/User.php')->output)->toBe('User'); + + Console::run('archetype:className app/Models/User.php Account'); expect(Console::read('app/Models/User.php'))->toContain('class Account extends Authenticatable'); }); -it('refuses to rename an enum', function () { - Console::write('app/Enums/Status.php', <<<'PHP' +it('answers with the full class name when asked', function () { + expect(Console::run('archetype:className app/Models/User.php --full')->output) + ->toBe('App\Models\User'); +}); + +it('lists the method names', function () { + Console::write('app/Models/Project.php', <<<'PHP' hasMany(Task::class); + } + + public function isActive() + { + return true; + } } PHP); - $result = Console::run('archetype:rename-class app/Enums/Status.php ProjectStatus'); - - expect($result->succeeded())->toBeFalse(); - expect($result->output)->toContain('only works on classes, and this is an enum'); - expect(Console::read('app/Enums/Status.php'))->toContain('enum Status: string'); + expect(Console::run('archetype:methodNames app/Models/Project.php')->output) + ->toBe('["tasks","isActive"]'); }); -it('sets and removes a class constant', function () { - Console::run('archetype:set-const app/Models/User.php HOME /dashboard'); +it('reads, sets and removes a class constant', function () { + Console::run('archetype:classConstant app/Models/User.php HOME /dashboard'); expect(Console::read('app/Models/User.php'))->toContain("const HOME = '/dashboard';"); + expect(Console::run('archetype:classConstant app/Models/User.php HOME')->output)->toBe('/dashboard'); - Console::run('archetype:remove-const app/Models/User.php HOME'); + Console::run('archetype:classConstant app/Models/User.php HOME --remove'); expect(Console::read('app/Models/User.php'))->not->toContain('HOME'); }); -it('refuses to set a constant on an interface', function () { - Console::write('app/Contracts/Payable.php', <<<'PHP' - succeeded())->toBeFalse(); - expect($result->output)->toContain('only works on classes, and this is an interface'); - expect(Console::read('app/Contracts/Payable.php'))->not->toContain('CURRENCY'); -}); - -it('skips a constant already set', function () { - Console::run('archetype:set-const app/Models/User.php HOME /dashboard'); - $again = Console::run('archetype:set-const app/Models/User.php HOME /dashboard'); +it('skips a constant already set to that value', function () { + Console::run('archetype:classConstant app/Models/User.php HOME /dashboard'); - expect($again->lines())->toBe(['SKIP app/Models/User.php HOME unchanged']); + expect(Console::run('archetype:classConstant app/Models/User.php HOME /dashboard')->lines()) + ->toBe(['SKIP app/Models/User.php HOME unchanged']); }); From 11371b3e91ba5ebe200bd7f2c43982226c9304a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20J=C3=BCrisoo?= Date: Sat, 29 Aug 2026 14:45:22 +0200 Subject: [PATCH 4/4] Keep the console inside PHP 8.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constants in traits are PHP 8.2, and this package supports 8.1, so the directive names move from the trait that uses them to a class of their own. CI caught it; my local PHP is 8.4 and the lowest I can install here is 8.2, so nothing on this machine would have. Neither PHPStan with phpVersion 80100 nor php-parser's version-aware parser flags the construct, so there is no local guard to add — the 8.1 job in CI is the check. A scan for the other 8.2-8.4 additions found nothing else. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x --- src/Console/Commands/ClassConstantCommand.php | 3 +- src/Console/Commands/ModelPropertyCommand.php | 3 +- src/Console/Commands/PropertyCommand.php | 5 +-- src/Console/Concerns/HasDirectiveFlags.php | 25 +++------------ src/Console/Support/Directives.php | 31 +++++++++++++++++++ 5 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 src/Console/Support/Directives.php diff --git a/src/Console/Commands/ClassConstantCommand.php b/src/Console/Commands/ClassConstantCommand.php index e3d0bc4..752fabf 100644 --- a/src/Console/Commands/ClassConstantCommand.php +++ b/src/Console/Commands/ClassConstantCommand.php @@ -4,6 +4,7 @@ use Archetype\Console\EndpointCommand; use Archetype\Console\Support\Code; +use Archetype\Console\Support\Directives; use Archetype\Console\Support\Introspector; use Archetype\LaravelFile as File; use Archetype\Support\Types; @@ -20,7 +21,7 @@ class ClassConstantCommand extends EndpointCommand protected function directives(): array { - return ['add', 'remove', 'clear', 'empty']; + return Directives::WRITING; } protected function hasValue(): bool diff --git a/src/Console/Commands/ModelPropertyCommand.php b/src/Console/Commands/ModelPropertyCommand.php index 9794cac..1093656 100644 --- a/src/Console/Commands/ModelPropertyCommand.php +++ b/src/Console/Commands/ModelPropertyCommand.php @@ -4,6 +4,7 @@ use Archetype\Console\EndpointCommand; use Archetype\Console\Support\Code; +use Archetype\Console\Support\Directives; use Archetype\Console\Support\Introspector; use Archetype\LaravelFile as File; use RuntimeException; @@ -46,7 +47,7 @@ public function __construct(protected string $property = 'fillable') protected function directives(): array { - return ['add', 'remove', 'clear', 'empty']; + return Directives::WRITING; } protected function hasValue(): bool diff --git a/src/Console/Commands/PropertyCommand.php b/src/Console/Commands/PropertyCommand.php index d5cf1a0..8efd604 100644 --- a/src/Console/Commands/PropertyCommand.php +++ b/src/Console/Commands/PropertyCommand.php @@ -4,6 +4,7 @@ use Archetype\Console\EndpointCommand; use Archetype\Console\Support\Code; +use Archetype\Console\Support\Directives; use Archetype\Console\Support\Introspector; use Archetype\LaravelFile as File; use Archetype\Support\Types; @@ -24,7 +25,7 @@ class PropertyCommand extends EndpointCommand protected function directives(): array { - return ['add', 'remove', 'clear', 'empty', 'public', 'protected', 'private', 'static']; + return array_merge(Directives::WRITING, Directives::VISIBILITY, ['static']); } protected function hasValue(): bool @@ -70,7 +71,7 @@ protected function name(): string */ protected function withVisibility(File $file, string $name): File { - foreach (['public', 'protected', 'private'] as $flag) { + foreach (Directives::VISIBILITY as $flag) { if ($this->option($flag)) { return $file; } diff --git a/src/Console/Concerns/HasDirectiveFlags.php b/src/Console/Concerns/HasDirectiveFlags.php index 93173b9..b59eb67 100644 --- a/src/Console/Concerns/HasDirectiveFlags.php +++ b/src/Console/Concerns/HasDirectiveFlags.php @@ -2,6 +2,7 @@ namespace Archetype\Console\Concerns; +use Archetype\Console\Support\Directives; use Archetype\LaravelFile as File; use InvalidArgumentException; use Symfony\Component\Console\Input\InputOption; @@ -16,22 +17,6 @@ */ trait HasDirectiveFlags { - /** directive method => flag description */ - const DIRECTIVES = [ - 'add' => 'Add to what is there instead of replacing it', - 'remove' => 'Remove it', - 'clear' => 'Clear the default value, keeping the declaration', - 'empty' => 'Empty it, keeping the declaration', - 'full' => 'Answer with the fully qualified name', - 'public' => 'Declare it public', - 'protected' => 'Declare it protected', - 'private' => 'Declare it private', - 'static' => 'Declare it static', - ]; - - /** The directives that make an operation a write rather than a read. */ - const WRITING_DIRECTIVES = ['add', 'remove', 'clear', 'empty']; - /** Which directives this command's endpoint honours. */ abstract protected function directives(): array; @@ -54,7 +39,7 @@ protected function withDirectives(File $file): File /** True when the caller asked a question rather than for a change. */ protected function isRead(): bool { - foreach (array_intersect($this->directives(), self::WRITING_DIRECTIVES) as $directive) { + foreach (array_intersect($this->directives(), Directives::WRITING) as $directive) { if ($this->option($directive)) { return false; } @@ -67,7 +52,7 @@ protected function isRead(): bool protected function guardDirectives(): void { $given = array_values(array_filter( - array_intersect($this->directives(), self::WRITING_DIRECTIVES), + array_intersect($this->directives(), Directives::WRITING), fn ($directive) => $this->option($directive) )); @@ -78,7 +63,7 @@ protected function guardDirectives(): void } $visibility = array_values(array_filter( - ['public', 'protected', 'private'], + Directives::VISIBILITY, fn ($flag) => in_array($flag, $this->directives(), true) && $this->option($flag) )); @@ -99,7 +84,7 @@ protected function directiveOptions(): array $directive, null, InputOption::VALUE_NONE, - self::DIRECTIVES[$directive] + Directives::ALL[$directive] ); } diff --git a/src/Console/Support/Directives.php b/src/Console/Support/Directives.php new file mode 100644 index 0000000..bbfb62b --- /dev/null +++ b/src/Console/Support/Directives.php @@ -0,0 +1,31 @@ + flag description */ + const ALL = [ + 'add' => 'Add to what is there instead of replacing it', + 'remove' => 'Remove it', + 'clear' => 'Clear the default value, keeping the declaration', + 'empty' => 'Empty it, keeping the declaration', + 'full' => 'Answer with the fully qualified name', + 'public' => 'Declare it public', + 'protected' => 'Declare it protected', + 'private' => 'Declare it private', + 'static' => 'Declare it static', + ]; + + /** The directives that make an operation a write rather than a read. */ + const WRITING = ['add', 'remove', 'clear', 'empty']; + + /** The directives that choose a visibility. */ + const VISIBILITY = ['public', 'protected', 'private']; +}