Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy.
- **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run --format json` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. Contributed by @calebdw.
- **Qualified names can be converted to imports in one action.** Invoke the refactoring on an absolute or relative qualified class, function, or constant to add the matching `use`, `use function`, or `use const` declaration and shorten every equivalent usage in the file. When the natural short name is already imported from elsewhere, the new import receives a namespace-derived alias instead. A companion action on the same cursor position does the whole namespace at once, importing every qualified class, function, and constant it contains and aliasing the ones whose short names collide. Contributed by @calebdw.
- **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. A move also reports what it could not reach: a namespace named in a Blade template, a YAML config, or a baseline file, and a directory spelled out inside a path string, are all invisible to a rewriter that works on resolved symbols, so the project is scanned as it will look afterwards and every leftover mention of the old name or location is reported with the file and line it sits on. `files_changed` can then be read against a stated list of what was left alone rather than assumed complete. `--no-colour` and `--format github` are accepted alongside `table` and `json`, matching `analyze` and `fix`, and the JSON is shaped like the object `analyze` emits so a script driving a batch of refactors can consume both the same way. Contributed by @calebdw.
- **Document outline for Blade files.** The outline view, breadcrumbs, and go-to-symbol now describe a `.blade.php` file by what it actually writes: the sections and stacks it fills or leaves open (`@section`, `@yield`, `@push`, `@stack` and the rest of that family) and the components it renders, each listed with the class behind it, or, for an anonymous component, the template Laravel renders in its place. A tag no component answers for keeps its bare name. Everything nests the way the template does, so the components inside a section are listed under it, and selecting an entry jumps to the name in the template rather than to a line in the virtual PHP the outline used to be measured against.
Expand All @@ -25,16 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A config key a project sets at runtime is a config key.** `Config::set('filesystems.disks.ondemand', [...])`, the array form of the `config([...])` helper, and `Storage::fake()` declare the keys they name, so reading one afterwards is no longer reported as unknown. A test that configures a disk in its `setUp()` before exercising it is the usual shape. Packages get the same treatment from the other side: the configuration a library reads belongs to the application that installs it, which is a file the analysis never sees, so its keys are left alone rather than judged against config files that were never meant to declare them.
- **A rewritten import keeps its indentation.** A rename or a `phpantom_lsp move` that carried a `use` statement to a new name replaced the whole line it sat on, so an import written inside a Blade `@php` block or a braced `namespace {}` block came back flattened against the left margin. Only the statement itself is rewritten now, and the whitespace around it is left where the file had it.

- **Moving a class into the global namespace removes its `namespace` declaration.** `phpantom_lsp move 'App\Old\Widget' 'Widget'` rewrote the declaring file's namespace name in place, and since the destination has no name to write there, the file was left holding `namespace ;`, which is a syntax error. The whole statement now goes, along with the line it sits on, and the imports the move has to add for the namespace-siblings the class was reaching by short name are written where it was. A file that opens its namespace as a brace block is refused with a message saying so, rather than having the block it wraps mangled.

- **A class leaving the global namespace is no longer reported as left behind.** Moving `Widget` to `App\Casts\Widget` ended with a warning that the old name still appears in the moved file, pointing at its own `class Widget` line. A global class's old fully-qualified name is a bare short name, and the move keeps the declaration spelled exactly that way on purpose, so the scan for leftover mentions now passes over the declaration it names.

- **The Blade lowering's own declarations stay out of the project's symbols.** The PHP a template is read as opened with a prologue declaring the wrapper its body is lowered into and the marker functions its directives compile to. Those were published like a declaration a file had written, so a workspace-symbol search for "blade" answered with ten entries nobody wrote, completion offered them beside the project's own functions, and every template in the project registered another copy of each. The markers are now declared once for the whole project rather than by every template, and neither they nor the wrapper are offered as a symbol of it.

- **Renames and moves reach Blade templates.** A class named in a `.blade.php` file was invisible to both `textDocument/rename` and `phpantom_lsp move`. The workspace index parsed a template as if it were plain PHP, where everything Blade-specific reads as inline HTML, so the template's symbol map held none of the class references it makes and a move left every one of them naming a class that no longer exists, with `files_changed` reading as a complete count when it was not. Worse, a single open template abandoned the rename outright: a template's symbol map describes the PHP Laravel compiles it to, which is longer than the file on disk, and the guard that protects against stale offsets fired on that difference and dropped every file's edits, down to the moved namespace's own declaration. Templates are now indexed as the PHP they compile to, and each edit comes back through the source map onto the template's own line, so a fully-qualified name written in an `@php` block, in a `@var` docblock, or inside a directive's argument follows the move, as do a `use` statement inside `@php`, the `@use` directive in all of its forms, and the short names an import binds. A method or property renamed from a template's call site lands in the right place too. Find All References sees the same templates, so a class is now listed with the views that name it without having to open them first.

- **A namespace served by two PSR-4 roots is refused up front, with both roots named.** Composer accepts an array of directories per prefix, and naming one of them resolves to the namespace both of them serve, from where the second root is indistinguishable from the first. The move planned to carry both roots onto the same destination and stopped on a file the caller never mentioned, reporting it as a missing file. Such a move is now refused before anything is planned, with both roots named, since honouring the directory that was actually passed means moving only the classes declared beneath it and the rewriter works on whole namespace prefixes. A prefix whose other roots are listed in `composer.json` but hold no files is unaffected: there is still only one directory to move. Renaming a namespace segment in the editor and `phpantom_lsp move` are both covered.
- **A namespace move rewrites each reference the way the file holding it actually reads.** Whether a reference was rewritten, and how the replacement was spelled, was decided from the name recorded for it rather than from the text in the file, and neither the recorded name nor its flags say how the file resolves it. A reference written from the global namespace, `\App\Old\Widget::class` in a file that declares a namespace of its own, lost its leading separator and came back as a relative name that resolves inside the enclosing namespace instead. Because `::class` does not require the class to exist, a morph map or a container binding built that way kept running and stored a name that resolves to nothing. In the other direction, a qualified reference written without a leading separator, which is what a file with no `namespace` declaration writes and what Laravel's `config/` is full of, was passed over entirely and kept naming the old namespace, so the breakage surfaced at boot rather than as a diagnostic. Every reference is now resolved through the file's own imports and namespace, and the replacement keeps the qualification the source was written with wherever the file, read as it will be once its own `namespace` and `use` lines are rewritten, still resolves that spelling to the moved class. Where it does not, the name is written out in full from the global namespace. Renaming a namespace segment in the editor and `phpantom_lsp move` are both fixed by this.
- **Renaming a namespace to one under a different autoload mapping moves its files to the right place.** The destination directory was worked out by cutting the *source* namespace's PSR-4 prefix off the destination name, which only holds when both sides sit under the same mapping. Renaming `App\Old` to `Lib\Domain` in a project mapping `App\` to `src/` and `Lib\` to `lib/` left the files under `src/` where the autoloader no longer looks, and a destination outside the autoload map entirely scattered them into a directory named after whatever was left of the name once the wrong prefix was cut. A destination name shorter than the prefix being cut crashed the request rather than renaming anything. The files now follow the destination to the mapping that actually covers it, a destination no mapping covers moves nothing and rewrites the declarations in place, and neither case can end the rename early.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ unlikely to move the needle for most users.
| L49 | [Unguarded Eloquent mass assignment diagnostic](todo/laravel.md#l49-unguarded-eloquent-mass-assignment-diagnostic) | Medium | Medium |
| L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (middleware, assets, validation, Inertia) | Medium | Medium-High |
| L54 | [Audit custom-builder and relation-closure inference against the PHPStan extensions](todo/laravel.md#l54-audit-custom-builder-and-relation-closure-inference-against-the-phpstan-extensions) | Medium | Medium-High |
| L25 | [Storage disk name strings](todo/laravel.md#l25-storage-disk-name-strings) | Low-Medium | Low |
| L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Medium |
| L42 | [Morph alias completion in array positions](todo/laravel.md#l42-morph-alias-completion-in-array-positions) | Low-Medium | Medium |
| L3 | `$dates` array (deprecated) | Low-Medium | Medium |
Expand Down
25 changes: 7 additions & 18 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,18 +616,6 @@ still partially lack:
empty string as the value). No fix when the group file itself doesn't
exist yet; that case still just diagnoses.

#### L25. Storage disk name strings

**Impact: Low-Medium · Complexity: Low**

`Storage::disk('...')` and the `#[Storage]` container attribute already
complete against `filesystems.disks.*`, navigate to the disk's entry in
`config/filesystems.php`, and flag an unknown disk. `Storage::fake()`,
`persistentFake()`, and `forgetDisk()` still name a disk with none of
that: their return type is patched to `FilesystemAdapter`, but the
disk-name argument itself gets no completion, go-to-definition, or
diagnostic.

#### L27. Legacy `Controller@method` action strings

**Impact: Low · Complexity: Low**
Expand Down Expand Up @@ -688,14 +676,15 @@ moving the Blade file — defer that one until the rest is in place.

**Impact: Medium · Complexity: Medium**

L25 (storage disks) is one instance of a general pattern: a method
argument names an entry under a known config subtree, and the config
scanner already parses those files. Auth guards (`auth('...')`,
Storage disks are one instance of a general pattern: a method argument
names an entry under a known config subtree, and the config scanner
already parses those files. Auth guards (`auth('...')`,
`Auth::guard()`, `->middleware('auth:web')`), cache stores
(`Cache::store()`), log channels (`Log::channel()`), and storage disks
(L25) already complete against their config subtree — but all of them
route through the generic `LaravelStringKind::Config` kind rather than
a dedicated one, so they get completion plus the shared config
(`Storage::disk()`, test fakes, disk eviction, and `#[Storage]`) already
complete against their config subtree — but all of them route through
the generic `LaravelStringKind::Config` kind rather than a dedicated
one, so they get completion plus the shared config
diagnostics/go-to-definition and nothing family-specific (a "cache
store" hovers with the same generic wording as any other config key).
`Log::stack()` (array values) isn't recognized at all. Generalize into
Expand Down
30 changes: 26 additions & 4 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Database\Factories\AnnotatedPostFactory;
use Database\Factories\BlogAuthorFactory;
use Database\Factories\EditorialFactory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
Expand Down Expand Up @@ -1155,12 +1156,22 @@ public function mixinModel(): void

// ── Storage::fake() resolves to the concrete adapter ────────────────

public function storageFake(): void
public function storageFake(
#[\Illuminate\Container\Attributes\Storage('avatars')] Filesystem $avatars,
): void
{
// fake() declares the Filesystem contract but always builds a
// FilesystemAdapter, so the adapter-only assertion helpers resolve.
// Disk names complete from config/filesystems.php, hover as their full
// config keys, and navigate back to their declarations — in the
// #[Storage] attribute above as much as in the calls below.
Storage::fake('avatars')->assertExists('me.png');
Storage::persistentFake('logs')->assertMissing('old.log');
Storage::persistentFake(disk: 'logs')->assertMissing('old.log');

// forgetDisk() takes one name or a list of them, and tolerates a disk
// that was never configured, so an unknown name here is not flagged.
Storage::forgetDisk('avatars');
Storage::forgetDisk(['avatars', 'logs']);
}


Expand All @@ -1169,17 +1180,28 @@ public function storageFake(): void
public function storageDisk(): void
{
// disk()/cloud() declare the Filesystem/Cloud contract, but every
// disk config/filesystems.php configures ('local', 's3') builds a
// FilesystemAdapter, so adapter-only methods like download()
// disk config/filesystems.php configures ('local', 's3', ...) builds
// a FilesystemAdapter, so adapter-only methods like download()
// resolve on every configured disk, not just a faked one.
Storage::disk('s3')->download('report.pdf');
Storage::disk(name: 'local')->exists('notes.txt');
Storage::cloud()->assertExists('logo.png');

// The 'pantry' disk uses a driver the framework does not ship. Its
// Storage::extend() closure in DemoServiceProvider builds a
// FilesystemAdapter too, so a custom driver does not cost the rest of
// the project its precise disk type.
Storage::disk('pantry')->download('sourdough.pdf');

// A disk configured at runtime is configured all the same: nothing in
// config/filesystems.php declares 'ondemand' or 'scratch', and neither
// read below is flagged because the write above it establishes the
// disk. Configuring one in a test's setUp() is the usual shape.
Config::set('filesystems.disks.ondemand', ['driver' => 'local']);
Storage::disk('ondemand')->exists('invoice.pdf');

Storage::fake('scratch');
Storage::disk('scratch')->exists('draft.txt');
}


Expand Down
20 changes: 20 additions & 0 deletions examples/laravel/config/filesystems.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@

'disks' => [

'local' => [
'driver' => 'local',
'root' => 'storage/app',
],

'avatars' => [
'driver' => 'local',
'root' => 'storage/app/avatars',
],

'logs' => [
'driver' => 'local',
'root' => 'storage/app/logs',
],

's3' => [
'driver' => 's3',
'bucket' => 'demo',
],

// A disk whose driver the framework does not ship. It is built by the
// `Storage::extend('pantry', ...)` registration in DemoServiceProvider,
// and PHPantom reads that closure's return type rather than giving up
Expand Down
Loading
Loading