diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d5fa21..820c459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,63 @@ 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. The PHP API is untouched — no endpoint, no +printer and no query builder changes. Everything new lives in `src/Console`. + +### Added + +- **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). + +- **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 + `--extends`, `--implements`, `--uses-trait` or `--matching`. +- 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. `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-array-key` edits the array a method returns, which is where `rules()`, + `toArray()`, `casts()` and `definition()` keep their contents. +- `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 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 Maintenance only. No API changes, and nothing here can break existing usage. @@ -67,7 +124,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..3920549 100644 --- a/docs.md +++ b/docs.md @@ -160,4 +160,307 @@ $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 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: + +| 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 + +| 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 | + +Directive flags — `--add`, `--remove`, `--empty`, `--clear`, `--full`, +`--public`, `--protected`, `--private`, `--static` — appear only on the +operations whose endpoint honours them. + +### 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 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 + +```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 +class App\Models\User extends Authenticatable +uses HasApiTokens, HasFactory, Notifiable +import Illuminate\Foundation\Auth\User as Authenticatable +prop protected $fillable = ["name","email","password"] +fn public posts() [4 lines] +rel posts hasMany Post +``` + +Sections: `meta`, `traits`, `uses`, `consts`, `cases`, `props`, `methods`, +`relations`. + +#### 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 — which files are there, and what they are +```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. + +#### 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 +``` + +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. + +#### add-case — an enum case +```bash +archetype add-case app/Enums/ProjectStatus.php OnHold on_hold +archetype add-case app/Enums/Suit.php Spades # pure enum, no backing value +``` + +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); }' +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. + +#### 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 +``` + +#### apply — 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 +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 but `errors` takes `--json`: + +```bash +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 added to","diff":"@@ 24 @@\n+ 'nickname',\n ];"}]} +``` + +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 e900a38..80e42b4 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,112 @@ $file->astQuery() ->save() ``` +## Command line + +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 fillable app/Models/User.php +# is the same as +php artisan archetype:fillable app/Models/User.php +``` + +**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 +archetype inspect app/Models/User.php # structure, without method bodies +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 +``` + +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 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`, +`--uses-trait` and `--matching`. + +### What a mutation answers with + +```bash +$ archetype fillable app/Models/User.php nickname --add +OK app/Models/User.php $fillable added to +@@ 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, 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 + 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' +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 +``` + ## 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/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/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/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/ClassConstantCommand.php b/src/Console/Commands/ClassConstantCommand.php new file mode 100644 index 0000000..752fabf --- /dev/null +++ b/src/Console/Commands/ClassConstantCommand.php @@ -0,0 +1,83 @@ +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 Directives::WRITING; + } + + 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/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/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..75fbb36 --- /dev/null +++ b/src/Console/Commands/HelpCommand.php @@ -0,0 +1,51 @@ +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.'); + + $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' => 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/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/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..1093656 --- /dev/null +++ b/src/Console/Commands/ModelPropertyCommand.php @@ -0,0 +1,133 @@ +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 Directives::WRITING; + } + + 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..8efd604 --- /dev/null +++ b/src/Console/Commands/PropertyCommand.php @@ -0,0 +1,133 @@ +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 array_merge(Directives::WRITING, Directives::VISIBILITY, ['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 (Directives::VISIBILITY 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/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/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/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/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..b59eb67 --- /dev/null +++ b/src/Console/Concerns/HasDirectiveFlags.php @@ -0,0 +1,93 @@ +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 +{ + /** 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(), Directives::WRITING) 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(), Directives::WRITING), + 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( + Directives::VISIBILITY, + 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, + Directives::ALL[$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/MutationCommand.php b/src/Console/MutationCommand.php new file mode 100644 index 0000000..2ea6d27 --- /dev/null +++ b/src/Console/MutationCommand.php @@ -0,0 +1,238 @@ +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 an operation on a construct it does not support. + * + * 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 requireKind(File $file, array $kinds): void + { + $kind = (new Introspector($file))->kind(); + + 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); + } + + /** + * 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/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']; +} 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..9f0062d --- /dev/null +++ b/src/Console/Support/Manifest.php @@ -0,0 +1,150 @@ + [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, + ]; + + /** + * Everything the service provider registers. + * + * 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. + * + * `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( + 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) + ), + ); + } + + /** 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', self::operations())); + + $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/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/ServiceProvider.php b/src/ServiceProvider.php index 0e06be6..bf4c7f4 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -5,6 +5,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; @@ -44,6 +45,7 @@ protected function registerCommands() { $this->commands([ ErrorsCommand::class, + ...Manifest::commands(), ]); } } diff --git a/tests/Feature/Console/ApplyCommandTest.php b/tests/Feature/Console/ApplyCommandTest.php new file mode 100644 index 0000000..80fc418 --- /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:fillable app/Models/User.php nickname --add')); + + expect($result->succeeded())->toBeTrue(); + 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' + fillable app/Models/Nope.php slug --add + fillable app/Models/User.php nickname --add + 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' + fillable app/Models/Nope.php slug --add + fillable app/Models/User.php nickname --add + 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' + 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('fillable app/Models/User.php nickname --add'); + 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..565dae5 --- /dev/null +++ b/tests/Feature/Console/EnumCommandsTest.php @@ -0,0 +1,87 @@ +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('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:implements', [ + 'target' => 'app/Enums/ProjectStatus.php', + 'names' => ['App\Contracts\HasColor'], + '--add' => true, + ]); + + expect($result->succeeded())->toBeFalse(); + 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/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..1876a17 --- /dev/null +++ b/tests/Feature/Console/HelpCommandTest.php @@ -0,0 +1,56 @@ +succeeded())->toBeTrue(); + + 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', 'kind']); + expect(collect($payload['operations'])->firstWhere('operation', 'property')['kind'])->toBe('endpoint'); + expect(collect($payload['operations'])->firstWhere('operation', 'inspect')['kind'])->toBe('console'); +}); + +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"); + } +}); + +it('names every endpoint command after a real endpoint', function () { + $php = get_class_methods(Archetype\LaravelFile::class); + + 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/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..40f4c7a --- /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/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 new file mode 100644 index 0000000..0615f62 --- /dev/null +++ b/tests/Feature/Console/MutationContractTest.php @@ -0,0 +1,135 @@ +succeeded())->toBeTrue(); + 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:fillable app/Models/User.php nickname --add --no-diff'); + + 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: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(); + 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:fillable app/Models/User.php nickname --add --dry-run'); + + expect($result->succeeded())->toBeTrue(); + 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); +}); + +it('exits non-zero when there is nothing it could act on', function () { + Console::write('app/helpers.php', "succeeded())->toBeFalse(); + 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, before writing any of it', function () { + Console::write('app/Enums/Status.php', <<<'PHP' + succeeded())->toBeFalse(); + 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'); +}); + +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:useTrait', [ + 'target' => 'app/Models', + 'names' => ['Illuminate\Database\Eloquent\SoftDeletes'], + '--add' => true, + ]); + + 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:fillable app/Models slug --add --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: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:fillable app/Models/User.php nickname --add --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:fillable app/Models/Nope.php slug --add'); + + expect($result->succeeded())->toBeFalse(); + expect($result->output)->toStartWith('ERR app/Models/Nope.php'); +}); + +function modelSource(string $name): string +{ + return <<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/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/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/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..6325a36 --- /dev/null +++ b/tests/Feature/Console/StructureCommandsTest.php @@ -0,0 +1,177 @@ +output) + ->toContain('Illuminate\\\\Notifications\\\\Notifiable'); +}); + +it('adds imports with --add', function () { + $result = Console::run('archetype:use', [ + 'target' => 'app/Models/User.php', + 'names' => ['App\Contracts\Auditable', 'Illuminate\Support\Str'], + '--add' => true, + ]); + + 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('replaces the imports without --add, as the endpoint does', function () { + Console::run('archetype:use', [ + 'target' => 'app/Models/User.php', + 'names' => ['App\Contracts\Auditable'], + ]); + + $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('skips imports already there', function () { + $result = Console::run('archetype:use', [ + 'target' => 'app/Models/User.php', + 'names' => ['Illuminate\Notifications\Notifiable'], + '--add' => true, + ]); + + 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:useTrait', [ + 'target' => 'app/Models/User.php', + 'names' => ['Illuminate\Database\Eloquent\SoftDeletes'], + '--add' => true, + ]); + + expect(Console::read('app/Models/User.php')) + ->toContain('use Illuminate\Database\Eloquent\SoftDeletes;') + ->toContain('use SoftDeletes;'); +}); + +it('leaves the import alone when told to', function () { + Console::run('archetype:useTrait', [ + 'target' => 'app/Models/User.php', + '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('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', + 'name' => '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 () { + expect(Console::run('archetype:extends app/Models/User.php Authenticatable')->lines()) + ->toBe(['SKIP app/Models/User.php extends unchanged']); +}); + +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', + '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('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('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); + + expect(Console::run('archetype:methodNames app/Models/Project.php')->output) + ->toBe('["tasks","isActive"]'); +}); + +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:classConstant app/Models/User.php HOME --remove'); + + expect(Console::read('app/Models/User.php'))->not->toContain('HOME'); +}); + +it('skips a constant already set to that value', function () { + Console::run('archetype:classConstant app/Models/User.php HOME /dashboard'); + + expect(Console::run('archetype:classConstant app/Models/User.php HOME /dashboard')->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); });