diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 90fc2cdc0..ceb6841e8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -38,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Union types in `@param-closure-this`.** Closures bound to more than one possible context now preserve their bindings through nested callbacks and call chains, and type guards narrow the available members. Contributed by @ace-of-aces. + - **Group imports written across several lines are checked for unused members.** An import wrapped over several lines, the shape a long list of members usually takes, was skipped by the unused-import check outright: the opening `use App\Models\{` line carries no `;`, and the check matched a statement only when the whole of it sat on one line. No member of such a group was ever dimmed, however many of them nothing referenced. A `use` statement is now read as a whole statement rather than a line, so a wrapped group is checked member by member the way a single-line one already was, and removing one takes its line with it instead of leaving an empty line inside the braces. A member imported under an alias (`use App\Models\{Post as BlogPost};`) is matched by that alias, and one naming a sub-namespace (`use App\{Models\User};`) by its full name. - **Removing every member of a group import no longer leaves broken syntax behind.** Fixing `use App\Models\{User, Post};` when both `User` and `Post` were unused deleted each member independently and left `use App\Models\{` dangling, a file the parser could no longer read. The whole statement is now removed, the same way a single `use App\Models\User;` import already was. - **An editor answering slowly no longer brings the whole server down.** Asking the editor to re-pull diagnostics is a request the editor answers, and the server raced each one against a ten-second timeout. An editor busy past that deadline (its own plugins, a large project, a machine under load) that answered afterwards delivered its answer to a server no longer listening for it, which crashed the process outright: every feature died at once, and the next go-to-definition or completion simply hung forever. Refresh requests now go through a single owner that waits for each answer however late it arrives, and requests queued up behind a slow one collapse into a single follow-up. The progress-token request sent when a long operation starts had the same flaw on a two-second deadline, and so did the request the "go to prototype" command sends to reveal the file it navigates to, which an editor cancelling the command or shutting down could cut short. All three are now sent by an owner that sees each one through to its answer. @@ -62,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. +- **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. - **Import-class quick fixes are available on the first character of an unresolved class.** Invoking code actions from a normal-mode cursor now treats the cursor as a point inside the class name, rather than requiring a non-empty selection or a cursor farther into the name. - **A class named inside a `@phpstan-type` or `@phpstan-import-type` tag is a reference to it.** Both tags were read for their types — the aliases they declare resolve, expand through inheritance, and drive completion — but the class names written in them were never recorded as references, so everything downstream of that treated them as prose. They took no class highlighting (the tag name was coloured and the whole rest of the line came back as one flat comment), go-to-definition on them did nothing, they did not appear in find-references or document-highlight, and a class rename walked straight past them and left the alias pointing at a name that no longer exists. The type behind a `@phpstan-type` and the class after a `@phpstan-import-type`'s `from` are now recorded like any other docblock type. The alias names themselves are unaffected: `UserRow` in `@phpstan-type UserRow …` and `Row` in `… as Row` are not classes, so nothing claims them, and an alias referenced inside another alias is still not reported as an unknown class. The `@psalm-` spellings behave the same way. - **Renaming a namespace onto one that already exists merges into it instead of filling the working tree with debris.** The move was emitted as a single rename of the source directory onto the destination, which an editor cannot carry out when the destination is already there, and the accompanying text edits had already been re-pointed at the paths that rename was supposed to create — so the rename failed, the edits landed anyway, and the result was a scatter of newly created files holding nothing but a rewritten `namespace` line. Merging `App\Internal` into an existing `App\Support` now moves the files into it one at a time, keeping each one's path relative to the namespace root, and leaves whatever was already there untouched. A destination that does not exist yet still moves as a single directory, as before. Separately, the rewrite that re-points a moved file's edits now requires a path separator after the directory it matched, so renaming `App\Internal` no longer claims the edits belonging to `App\InternalTools`. @@ -85,7 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The branch a truthy test skips knows what the value could still have been.** `if ($x)` was read as proving whatever it could about `$x` on the way in, while the path around it was narrowed to `null` and nothing else, so two kinds of answer came out wrong. A value with no `null` among its members was given one anyway: `bool|string` came out of the else of `if ($v)` as a `null` the declaration never allowed. And a value that does have a falsy half of its own lost it: a plain `bool` stayed `bool` rather than becoming `false`, so nothing below the branch could tell the two paths apart. `$a = null; if ($flag) { $a = makeA(); }` followed by a second `if ($flag)` reported `$a` as possibly null even though only the first branch could have left it so, and `while ($more) { … }` left `$more` open below the loop rather than saying what ended it. Both paths of a condition now go through the same rule, so each keeps exactly the members the other rules out and a union keeps whatever the test says nothing about. - **An `instanceof` written beside an unrelated operand in an `||` no longer types the branch as though it had held.** Entering `if ($v instanceof Variable || $flag)` says one of the two was true, not that the `instanceof` was, so `$v` is whatever it was on the way in. It was read as the checked class regardless, which hid mismatches on every value the guard never ruled out, and two checks on two different subjects (`$a instanceof Variable || $b instanceof Variable`) narrowed both. Every disjunction now goes through the branch join instead: each leg is narrowed on its own and the results are unioned, so `$n instanceof Name || $n instanceof Value` still hands the body `Name|Value`, while a leg the surrounding code has already ruled out contributes nothing rather than putting a type back that could not reach there. - **A relative class name in a static call or `new` expression still finds the by-reference parameters it passed.** `self::fill($key)`, `static::fill($key)`, `parent::fill($key)`, and `new self($key)` all resolve the class name they spell against the class enclosing the call now, the same as every other consumer of a relative name. Previously only a class spelled out by name (`Ops::fill($key)`) was recognised, so `self::fill($key)` reported `$key` as undefined both at the call and everywhere it was read afterwards. -- **A by-reference out-parameter is typed by what the callee writes, and the value it already held is left alone.** A parameter such as `?string &$key` describes what the caller may hand over, so a callee that assigns it on every path was still read back as possibly null and the reads below the call were reported against a type the call had ruled out. The type a by-reference argument holds after a call is now taken from the callee's body, for a plain function, a method, a static call and a constructor alike, and it stays a refinement: a body the walk reads as something the declaration does not allow is discarded, and one that writes on only some paths keeps the null it declared. In the other direction, what such an argument held *before* the call is no longer checked against the parameter type, since that type says what comes back out. `preg_match_all($pattern, $line, $matches, PREG_OFFSET_CAPTURE)` inside a loop is handed the previous iteration's shape and PHP accepts it, so reporting it was a false positive. +- **A by-reference out-parameter is typed by what the callee writes, and the value it already held is left alone.** A parameter such as `?string &$key` describes what the caller may hand over, so a callee that assigns it on every path was still read back as possibly null and the reads below the call were reported against a type the call had ruled out. The type a by-reference argument holds after a call is now taken from the callee's body, for a plain function, a method, a static call and a constructor alike, and it stays a refinement: a body the walk reads as something the declaration does not allow is discarded, and one that writes on only some paths keeps the null it declared. In the other direction, what such an argument held _before_ the call is no longer checked against the parameter type, since that type says what comes back out. `preg_match_all($pattern, $line, $matches, PREG_OFFSET_CAPTURE)` inside a loop is handed the previous iteration's shape and PHP accepts it, so reporting it was a false positive. - **A proof the condition never states outright is reconstructed where it is read.** Four ways a guard's own conclusion went missing further down. Entering a branch guarded by a disjunction proves the disjunction, so a check that rules one leg out leaves the other: `if ($n->keyVar === null || ($n->keyVar instanceof Variable && is_string($n->keyVar->name)))` followed by `$n->keyVar instanceof Variable ? $n->keyVar->name : null` now answers `string|null` rather than dragging the unchecked half along. Testing the same condition a second time re-applies what it proved the first time, so `if (count($args) > 0) { $acceptor = Selector::selectFromArgs(…); }` and a later `if (count($args) > 0)` no longer report the acceptor as possibly null. A closure carries the narrowing of a path read through what it captures, so a `use ($param)` body reading `$param->type` past an `if ($param->type === null) { continue; }` guard sees the non-null type. And a check on a path whose receiver the same `&&` chain narrows is applied at last: `$expr instanceof FuncCall && !$expr->name instanceof Name` now rules the excluded class out of `$expr->name`. Falling past `count($x) > 0` also says the subject is empty, the mirror of the check that was already read the other way round; a bound further out (`count($x) > 1`) still proves nothing on the way past, since one entry is as possible as none. - **An `int|float` value survives a swap-and-guard branch chain without duplicating a member or leaking a `float` the guard already ruled out.** Division and `min()`/`max()` results split correctly across `is_float()`'s two branches on their own, but reaching them through a preceding swap destructuring, or an `if`/`else` that computes the same union two different ways, reported a doubled `int` sitting beside a `float` the guard had already excluded. Two things were wrong: PHP's implicit int-to-float widening let the same collapsing rule that folds `positive-int|int` down to `int` also fold `int|float` down to bare `float`; and a branch or ternary merge only dropped an alternative that exactly duplicated another, leaving one that merely overlapped a sibling (a bare `float` beside a division's `int|float`) sitting alongside it uncollapsed. Both are fixed for every union and every branch merge, not only this one shape. - **A loop over an array the code proved has entries no longer leaves the sentinel above it behind.** `$acc = null;` ahead of a `foreach` that assigns `$acc` on every pass came back nullable even where the loop plainly runs, so the value was reported against the non-null type it was returned or passed as. Two proofs were missing. `count($xs) > 0` (and `!== 0`, `>= 1`, the flipped spellings, and the fall-through of `if (count($xs) === 0) { throw; }`) now says the array has entries. And writing an element, `$xs[] = $v` or `$xs[$k] = $v`, says so too, since the write put one there. A write on only some paths gives the promise back where the paths join, so a loop over what it produced still keeps the sentinel. @@ -149,7 +151,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A closure handed to a constructor still writes back through its `use (&$x)` capture.** `new Wrapper(function () use (&$result) { $result = $node; })` lost the assignment entirely, so `$result` stayed `null` even past an `if ($result !== null)` guard. A closure reached inside a `new` expression's arguments or an array literal is now seen, and since the object invokes it later rather than immediately, the captured type widens the outer variable instead of replacing it. - **A variable handed to a `require`d file is no longer reported as unused.** `(function () use ($container) { require $file; })()` flagged `$container`, but the included file's code runs in the closure's own scope and can read it by name, which is the point of the idiom. No variable in a scope containing an `include`/`require` is flagged now, since nothing can tell which names the target file reads. - **A callback parameter is typed as one element of the array it is handed, not the array itself.** `array_map(fn ($token) => …, $expected)` on an argument that can be one of several array shapes typed `$token` as the whole `array{3}|array{4, 2}`, so every use of it inside the callback was reported as a mismatch. The element type is now read across the alternatives, and a container it cannot read an element out of binds nothing at all rather than binding itself. -- **A key spelled with a backslash no longer widens an array's key type.** `['~\n~' => '|n']` had `array_keys()` report `int|string`, because a shape key was stored the way it was written rather than the value PHP keys on, and a *double*-quoted `"\x38"` really does become the integer key `8`. Keys are now decoded when they are read, so a single-quoted key stays the string it spells and a double-quoted escape becomes what it decodes to. +- **A key spelled with a backslash no longer widens an array's key type.** `['~\n~' => '|n']` had `array_keys()` report `int|string`, because a shape key was stored the way it was written rather than the value PHP keys on, and a _double_-quoted `"\x38"` really does become the integer key `8`. Keys are now decoded when they are read, so a single-quoted key stays the string it spells and a double-quoted escape becomes what it decodes to. - **An argument the text-driven resolver cannot read still fills in a call's generics.** `array_keys($a + $b)` left the key type unresolved even though `$c = $a + $b; array_keys($c)` narrowed it correctly: reading the argument as source text has no rule for the array-union operator. A call reached through the syntax tree now falls back to the type already resolved for the argument expression, which covers every operator and construct at once rather than one at a time. - **`new self(...)` builds the class it is written in, even when a global class shares its short name.** A namespaced `Error`, `Exception`, or `Iterator` had every `new self(...)` and `self::` inside it resolve to the PHP built-in of the same short name, so the constructor whose arguments were checked belonged to the wrong class and a method returning `new self(...)` was reported as returning the wrong type. On PHPStan's own `Analyser\Error` that was eighteen false errors from one line of code. - **`return $this;` inside a trait is no longer reported against the trait.** A trait method declared to return an interface the trait does not itself implement was flagged on every `return $this`, because `$this` was taken to be the trait. PHP never runs a trait's methods on the trait, so `$this` is now read as an instance of a class that uses it: whatever the `@phpstan-require-extends` / `@phpstan-require-implements` tags state, or, failing a tag, whatever every class in the project that uses the trait has in common. The trait's own members stay reachable alongside. A trait whose users share nothing, or that nothing uses, is still reported, since there is then nothing to prove `$this` is anything in particular. @@ -158,19 +160,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A namespaced class named after a global one keeps the members it inherits.** `Nette\Neon\Exception extends \Exception` lost `getMessage()`, `getCode()`, and the rest whenever the question was asked from a file that wrote `use Nette\Neon\Exception;`: the import was applied to the stored parent name, turning the global `\Exception` back into the subclass, and the cycle that made cut the inheritance chain. PHP has no self-inheritance, so a parent, interface, or trait that resolves to the class itself is now read as the global class it named. - **A `static $var;` local carries the value an earlier call left in it.** The declaration was treated as an ordinary unassigned local until the walk happened to pass an assignment to it, which loses the one thing a `static` local means: the assignment that matters can sit in a branch the current call never reaches. It is now typed from its own initialiser together with every assignment the body makes to it, wherever they are, so hover, completion, and find-references all read a `static` local the way it actually behaves. - **A by-reference `use (&$total)` capture declares the variable it captures.** PHP creates the outer variable as `null` when a by-reference capture names one that does not exist yet, which is how a counter that is only ever written inside the closure is normally set up. PHPantom reported "Undefined variable" at the capture and again at every read of it. A by-value `use ($total)` still reads the outer variable, so a name that was never assigned there is still reported. -- **`isset($var)` in a condition proves the variable exists for the whole branch.** `if (isset($type) && $type !== T_WHITESPACE) { … }` inside a loop reported "Undefined variable" on the reads in the branch body, because the only assignment to `$type` sits *after* the `if` and is reached on the next iteration. The proof now covers the branch, not just the rest of the condition. A name nothing in the scope ever assigns is still reported, and an `isset()` in an `||` chain proves nothing, since the branch can be entered without it holding. +- **`isset($var)` in a condition proves the variable exists for the whole branch.** `if (isset($type) && $type !== T_WHITESPACE) { … }` inside a loop reported "Undefined variable" on the reads in the branch body, because the only assignment to `$type` sits _after_ the `if` and is reached on the next iteration. The proof now covers the branch, not just the rest of the condition. A name nothing in the scope ever assigns is still reported, and an `isset()` in an `||` chain proves nothing, since the branch can be entered without it holding. - **`$a === $b` rules out `null` on the nullable side.** Comparing a `?string` identical to something that cannot be `null` means both sides carried the same value, so the branch holds no `null`, but PHPantom kept the full `?string` and reported passing it to a `string` parameter. The comparison narrows in either operand order, and the fall-through of an `if ($a !== $b) { return; }` guard gets the same proof. A loose `==` still proves nothing, because `null == 0` holds. - **`array_key_exists()` narrows an optional array-shape key.** A key declared `array{a?: string}` reads as `?string` until something proves it is there, which `isset()` did and `array_key_exists()` did not, so returning `$shape['a']` from inside the check was reported as returning a `?string`. Presence is all `array_key_exists()` proves, so a key whose declared value type includes `null` keeps it. Works on a property subject and through an `if (!array_key_exists(…)) { return; }` guard. - **`instanceof` against a value holding the class name narrows the subject.** Only a literal class name counted, so `if (!$statement instanceof $stmtClass) { continue; }` had no effect at all and every member read past the guard was reported as missing. The right-hand side is now resolved: a `class-string` narrows to `T`, a union of them to the union of the classes they name, and an object stands for its own class. An operand that names no particular class (a plain `string`) still proves nothing, and leaves the subject as declared. - **An `elseif`'s condition narrows its own `&&` operands.** `elseif ($expr->var instanceof Variable && $expr->var->getName())` reported the second read as a member the declared type does not have: only the leading `if`'s condition carried its proof rightward, so an `elseif` written the same way narrowed nothing until its body. This was the largest single source of false "not found on class" reports on PHPStan's own codebase. - **An array element addressed by a variable is one subject a guard can narrow.** `if ($types[$i] instanceof ShapeType)` recorded the check but never read it back, so `$types[$i]->shapeMethod()` inside the branch was reported against the array's declared element type. Only a literal offset (`$types[0]`) narrowed before, which is why storing the element in a local first was the workaround. A different index is still a different subject, so a guard on `$types[$i]` proves nothing about `$types[$j]`. -- **An assertion tag resolves its type where the tag was written.** A `@phpstan-assert-if-true TestMethod $this` on an installed package's own interface narrowed nothing, because the unqualified `TestMethod` was looked for in the namespace of the code *calling* the method rather than the one declaring it. The name is now qualified against the declaring file's namespace, which is how PHP reads every other unqualified name there, so a package that documents its own predicates gets them honoured without a hand-written patch. +- **An assertion tag resolves its type where the tag was written.** A `@phpstan-assert-if-true TestMethod $this` on an installed package's own interface narrowed nothing, because the unqualified `TestMethod` was looked for in the namespace of the code _calling_ the method rather than the one declaring it. The name is now qualified against the declaring file's namespace, which is how PHP reads every other unqualified name there, so a package that documents its own predicates gets them honoured without a hand-written patch. - **`is_a($name, Foo::class, true)` leaves a string subject a string.** The check also passes for a `class-string`, so the truthy branch typed the subject as `class-string|Foo`, and passing it on to a `string` parameter was then reported, since an object is not a string. A subject that can only ever hold a string has no object alternative for the check to select, so it now narrows to the `class-string` alone. - **A guarded getter stays narrowed for every use, not just the first.** `if (!$scope->isInClass()) { return; }` proved `$scope->getClassReflection()` non-null, and then the first read of it threw the proof away: any call on a receiver counted as something that could have changed the receiver's state, including the very call the proof was about. So the second and third use of the same getter were reported as passing a nullable value. A call on the receiver still drops what it could genuinely have changed, so the `$stmt->execute()` case the rule exists for keeps working. - **A predicate's guarantee is honoured wherever the guard is written.** `@phpstan-assert`-style tags on a method now apply when the receiver is a property (`$this->scope->isInClass()`) rather than a local, when its type is an intersection (`Scope&Invoker`), when the predicate is one clause of an `&&` chain (`$name === 'static' && $scope->isInClass()`), and when the class implements the interface that declares the tag instead of carrying it itself. Each of these silently narrowed nothing before. A sibling `elseif` testing the same predicate no longer corrupts the fact for the branches after it either. -- **A member typed `self` resolves against the class it was read off.** `Scope::getParentScope(): ?self` read through a `$scope` variable resolved `self` against the class the *reading* code sits in, so the guarded `$scope->getParentScope()->hasExpressionType(…)` reported a method missing from a class the file never mentions. Same for a property declared `?self`. +- **A member typed `self` resolves against the class it was read off.** `Scope::getParentScope(): ?self` read through a `$scope` variable resolved `self` against the class the _reading_ code sits in, so the guarded `$scope->getParentScope()->hasExpressionType(…)` reported a method missing from a class the file never mentions. Same for a property declared `?self`. - **An element of a static array property is a subject in its own right.** `self::$anonymousClasses[$key]->getDisplayName()` looked for a static member literally named `$anonymousClasses[$key]`, found none, and fell back to answering with the enclosing class, reporting a method missing from a class that has nothing to do with the value. -- **A skipped position in a destructuring pattern no longer shifts the ones after it.** `[, $second] = $pair;` and `foreach ($rows as [, $value])` bound the variable to the *first* element's type instead of the second, because a hole was passed over without counting as a position. Every later variable in the pattern picked up a type belonging to something else, which showed up as wrong completions and go-to-definition as much as it did as false errors. +- **A skipped position in a destructuring pattern no longer shifts the ones after it.** `[, $second] = $pair;` and `foreach ($rows as [, $value])` bound the variable to the _first_ element's type instead of the second, because a hole was passed over without counting as a position. Every later variable in the pattern picked up a type belonging to something else, which showed up as wrong completions and go-to-definition as much as it did as false errors. - **A project file that redeclares a PHP builtin no longer replaces what PHPantom knows about it.** Several popular packages ship one PHP file per builtin function as a signature reference (`phpstan/php-8-stubs` does, and phpstorm-stubs is itself a Composer dependency of plenty of projects). Those files declare the function with the plainest signature it can have, and PHPantom took them at their word for the rest of the session, so `array_keys()` on an `array` came back with keys of no particular type rather than `string`. PHP fatals on redeclaring an internal function, so a file like this never runs, and the built-in signature now wins. On PHPStan's own codebase this removes about a fifth of the errors PHPantom reported. - **A `@phpstan-type` alias is expanded when the array it names is iterated.** `foreach ($lines as $file => $line)` on a parameter typed through an alias left the key as `int|string`, while spelling the same array out in the `@param` narrowed it to `string`. Reading the variable's type straight out of scope was the one path that skipped the alias lookup. - **`array_map()` no longer invents keys its input never promised.** Mapping over an `array`, an `Foo[]`, or a bare `array` produced an `array`, claiming the sequential `0, 1, 2, …` that only a `list` has. Assigning the result to a property or parameter declared `array` was then reported as a type error. The keys are carried over untouched, so a result whose input never named its key type does not name one either. @@ -226,7 +228,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Diagnostics -- **A `private` or `protected` member reached from outside is reported where you write it.** PHP resolves a member access to a declaration first and enforces that declaration's visibility second, so reading `$account->pin` on a class that keeps `$pin` private is a fatal error rather than a missing property — but it used to be reported as neither. Properties, methods, class constants, and static properties are all checked, against the class that *declares* the member rather than the one the access happened to go through, so a member inherited from a shared parent stays reachable from every branch below it while one declared on a sibling does not. A parent's private member, which PHP does not inherit at all, is now named as the access violation it is instead of looking like a member that does not exist. The check stands down wherever PHP itself would not fail: a class whose magic methods answer for members the caller cannot reach directly is left alone, a trait's members belong to whichever class uses it, and a `@see` tag documents a member rather than reading one. Contributed by @petrovo-as. +- **A `private` or `protected` member reached from outside is reported where you write it.** PHP resolves a member access to a declaration first and enforces that declaration's visibility second, so reading `$account->pin` on a class that keeps `$pin` private is a fatal error rather than a missing property — but it used to be reported as neither. Properties, methods, class constants, and static properties are all checked, against the class that _declares_ the member rather than the one the access happened to go through, so a member inherited from a shared parent stays reachable from every branch below it while one declared on a sibling does not. A parent's private member, which PHP does not inherit at all, is now named as the access violation it is instead of looking like a member that does not exist. The check stands down wherever PHP itself would not fail: a class whose magic methods answer for members the caller cannot reach directly is left alone, a trait's members belong to whichever class uses it, and a `@see` tag documents a member rather than reading one. Contributed by @petrovo-as. - **Two new diagnostics: illegal `readonly` writes and self-contradicting docblocks.** A write to a `readonly` property from anywhere PHP forbids one, and a `@param` or `@return` tag that contradicts the nullability of the declaration it documents, are now reported where you write them rather than when the code runs. Every form the readonly write can take is checked, including the ones that are easy to overlook (`unset()`, a `foreach` or destructuring target, taking a reference), and the writes the language allows are left alone. - **Four new declaration diagnostics.** An enum whose cases do not agree with its backing, a redeclaration that drops `static` from an inherited return type, an abstract trait method nothing implements (with the "Implement missing methods" code action stubbing it alongside the rest), and a `match` arm whose literal can never equal the subject. Contributed by @calebdw. @@ -286,6 +288,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Faster workspace symbol search.** Matching a symbol against the "Go to Symbol in Workspace" query no longer allocates a lowercased copy of every class, method, property, constant, and function name in the project on each keystroke; matching is now done byte-wise in place for the common case of ASCII identifiers. - **The `analyze` and `fix` CLI subcommands no longer build the cross-file reference index.** That index only serves Find References, Rename, and reference-count inlay hints, none of which the CLI subcommands query, so skipping it removes wasted work from whole-project runs. The editor's LSP session is unaffected. - + ### Removed - **Bundled Zed extension.** PHPantom's plain-PHP wiring has merged into Zed's official PHP extension, so a separate PHPantom extension is no longer needed. See [Editor Setup](editor-setup.md) for the updated Zed configuration. @@ -306,13 +309,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Switching workspace diagnostics off now takes effect immediately too.** Setting `workspace = false` mid-session left a scan already in progress to run to completion and publish results for files you had just said you did not want diagnosed, and every result the scan had already reported stayed in the problems panel, kept up to date on every file you closed, for the rest of the session. Saving the change now stops a running scan and clears everything it had reported. Turning it back on afterwards starts a fresh scan, the same as enabling it for the first time. - **A busy editor no longer parks a background task for the session.** Asking the editor to re-pull diagnostics is a request the server waits on an answer to, and the requests sent after a batch of watched files changed (a branch switch) and after the background index finished waited without a time limit. Both are best-effort, so both now give up after ten seconds like the rest. - **A function call written in a different case is found and renamed.** PHP resolves function names case-insensitively, so `HELPER()` and `helper()` call the same function, but find-references and rename compared the two exactly. A call spelled in another case was missing from the results, and renaming the function left it calling a name that no longer exists, breaking the very file the rename was meant to update. Every spelling now matches, in the cross-file index that decides which files to scan as well as in the scan itself. Constant names stay case-sensitive, which is how PHP treats them. -- **A stored `preg_match` result keeps the groups it matched.** `preg_match` writes its capture groups into an out-parameter, and a literal pattern says which keys that leaves behind, so a group read resolves to a `string`. Storing the call's *result* in a variable first (`$ok = preg_match($pattern, $html, $m);`) lost all of it: the array kept whatever was there before the call, so a group read came out as `null` and every function it was handed to reported an argument-type error. The call is now recognised whether or not its result is stored, and the variable holding the result stands for the match, so `if ($ok)` narrows the array to the pattern's keys and the `else` branch to the empty one, exactly as testing the call itself does. This applies to by-reference output parameters generally, not just `preg_match`: a call that writes through a reference is seen when its result is assigned too. A variable that is both the target of the assignment and the out-parameter of its call (`$file = end($file);`) still holds what was assigned to it, since the assignment happens once the call has returned. +- **A stored `preg_match` result keeps the groups it matched.** `preg_match` writes its capture groups into an out-parameter, and a literal pattern says which keys that leaves behind, so a group read resolves to a `string`. Storing the call's _result_ in a variable first (`$ok = preg_match($pattern, $html, $m);`) lost all of it: the array kept whatever was there before the call, so a group read came out as `null` and every function it was handed to reported an argument-type error. The call is now recognised whether or not its result is stored, and the variable holding the result stands for the match, so `if ($ok)` narrows the array to the pattern's keys and the `else` branch to the empty one, exactly as testing the call itself does. This applies to by-reference output parameters generally, not just `preg_match`: a call that writes through a reference is seen when its result is assigned too. A variable that is both the target of the assignment and the out-parameter of its call (`$file = end($file);`) still holds what was assigned to it, since the assignment happens once the call has returned. - **A class written in a different case is found and renamed.** PHP resolves class names case-insensitively, so `new WIDGET()` instantiates `Widget`, but find-references and rename compared the two exactly. The mis-cased site was missing from the results, and renaming the class left that file instantiating a name that no longer exists, breaking the very file the rename was meant to update. Every spelling now matches, in the cross-file index that decides which files to scan, in the scan itself, and through the `use` imports, aliases, and collision handling the class rename goes through. Starting the rename from a mis-cased site also works: the old name is read off the declaration rather than off the site the cursor was on, so the file is still renamed alongside the class it holds. The same applies to `new` expressions found as references to a constructor. Constant names stay case-sensitive, which is how PHP treats them. - **Rename can be started from a fully-qualified call site.** `\Support\shout()` names its function with a leading backslash, and the check that guards rename against a symbol map older than the buffer compared that text against a name recorded without one. The comparison could never match, so prepare-rename returned nothing and rename refused to run from that spelling, even though a rename started from any other site rewrote the same call correctly. - **A global function called from namespaced code reports how many times it is used.** The reference count above a function declaration read "0 references" for a global helper whenever its callers were namespaced, which is every PSR-4 project with a `helpers.php`. PHP qualifies an unqualified call with the current namespace and falls back to the global function when nothing is declared there, so a `helper()` call inside `namespace App;` was credited to `App\helper` alone and the declaration it actually reaches was credited with nothing. Both names such a call can reach are now counted, while a call reached through a `use function` import still counts only towards what it imports. - **Renaming a `define()`-declared constant rewrites the `define()` call too.** The name a `define('FOO', 1)` call declares is a string literal, and nothing recognised it as naming the constant it creates. Renaming `FOO` from any use site therefore rewrote every use and left the `define()` call declaring the old name, so the code no longer defined what it read. The call is now the constant's declaration site: rename reaches it, find-references lists it, and hover and document-highlight work on the name inside the quotes. Go-to-definition on a use of such a constant now lands on the name itself rather than on the `define` keyword. - **Renaming a constant now rewrites `defined()` and `constant()` calls too.** `defined('FOO')` and `constant('FOO')` name a constant through a string literal the same way `define()` does, and nothing recognised either as a reference to it: renaming `FOO` rewrote the declaration and every ordinary use but left these calls asking about the old name, so a `defined()` guard silently stopped guarding and `constant()` failed at runtime. Both now resolve, hover, navigate, and are reached by rename and find-references like any other use of the constant. `constant('Foo::BAR')`, which names a class constant rather than a global one, is left alone. -- **A property read back through reflection types as the property declares.** `getProperty()` is declared to return a bare `ReflectionProperty` and `getValue()` a bare `mixed`, which is as specific as an annotation can be: what the read produces depends on the *name* passed to `getProperty()`, not on any type. Where that name is a literal and the reflected class is known, the read now resolves to the declared type of that property, so `(new ReflectionObject($config))->getProperty('shell')->getValue($config)` completes, hovers, and navigates as the `?Shell` it is, and a member reached through it counts as a reference to that member. `new ReflectionObject($x)` also keeps the class it reflects, the way `new ReflectionClass($x)` already did, so `newInstance()` on it no longer widens to `object`. A name that is not a literal, a property with no declared type, and a reflected value whose class is unknown all keep `mixed`. +- **A property read back through reflection types as the property declares.** `getProperty()` is declared to return a bare `ReflectionProperty` and `getValue()` a bare `mixed`, which is as specific as an annotation can be: what the read produces depends on the _name_ passed to `getProperty()`, not on any type. Where that name is a literal and the reflected class is known, the read now resolves to the declared type of that property, so `(new ReflectionObject($config))->getProperty('shell')->getValue($config)` completes, hovers, and navigates as the `?Shell` it is, and a member reached through it counts as a reference to that member. `new ReflectionObject($x)` also keeps the class it reflects, the way `new ReflectionClass($x)` already did, so `newInstance()` on it no longer widens to `object`. A name that is not a literal, a property with no declared type, and a reflected value whose class is unknown all keep `mixed`. - **Renaming a namespaced constant or function no longer renames an unrelated symbol of the same short name.** Find References and rename matched a call or a constant reference against the target whenever the two merely shared their last name segment, so renaming `App\A\VERSION` also rewrote an unrelated `App\B\VERSION` declared in a sibling namespace, and the same happened for functions. The short-name check exists to catch PHP's own fallback from an unqualified reference to the global constant or function of that name, and it still does, but only once the namespace-qualified guess is confirmed to name nothing real; a namespace-qualified target, or a sibling namespace that genuinely declares its own symbol under the same short name, is no longer treated as a match. - **Closing a file during a project-wide PHPStan/PHPCS/Mago scan no longer resurrects its diagnostics.** A whole-project run of an external tool feeds an open file's results straight into the live per-file cache so the buffer updates immediately, but it only checked whether the file was still open before starting that hand-off, not right before writing it. Closing the file while the scan was still delivering its other results left the file's problems reappearing in the editor even though it had just been closed and its cache cleared. The open check now happens immediately before each write, matching how the per-file PHPStan/PHPCS/Mago runs already behave, and an open file whose issues the scan no longer reports now has its cache cleared too instead of keeping stale results. - **A project-wide PHPStan/PHPCS/Mago scan no longer undoes a result it was overtaken by.** These project-wide runs take long enough that a file you are editing can be re-checked on its own, and finish, while the scan is still delivering. The scan then wrote its own findings over the newer ones, so a problem you had just fixed came back and stayed until the next single-file run. Each tool now tracks which files it has re-checked since a scan began and the scan leaves those alone, keeping whichever result was produced from the newer content. @@ -328,7 +331,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A project with its own `phpstan.neon` gets PHPStan diagnostics regardless of what `composer.json` declares.** PHPStan auto-detection depended entirely on a `composer.json` dependency, so a project that hand-authors a `phpstan.neon` or `phpstan.neon.dist` config, whether it depends on `phpstan/phpstan` transitively, installs a Larastan fork the dependency check does not otherwise certify, or wires PHPStan up some other way entirely, never had `vendor/bin/phpstan` recognised. A `phpstan.neon`/`phpstan.neon.dist` file at the workspace root is now itself enough to certify PHPStan on a project, including a Laravel one that has not installed Larastan. - **A project with its own `phpcs.xml` gets phpcbf as its formatter, even when `squizlabs/php_codesniffer` is only a transitive dependency.** Formatter auto-detection looked only for a direct `squizlabs/php_codesniffer` entry in `require-dev`, so a project that instead depends on a rules package like `slevomat/coding-standard` or `cakephp/cakephp-codesniffer`, which pull PHP_CodeSniffer in transitively, never had `vendor/bin/phpcbf` recognised even though the binary was right there. A `phpcs.xml`, `.phpcs.xml`, `phpcs.xml.dist`, or `.phpcs.xml.dist` file at the workspace root now certifies phpcbf on its own. Closes #374. - **A project that formats with Mago keeps formatting with Mago.** PHP_CodeSniffer is a linter that happens to ship a fixer, so a project can lint with it and format with something else entirely. Because an external formatter takes precedence over the built-in one, a `phpcs.xml` handed phpcbf the job even on a project whose `mago.toml` says in as many words what it formats with, and every save reformatted the file to the PHPCS ruleset. A `[formatter]` table in the workspace `mago.toml` now settles it: the ruleset records what the project lints with, that table records what it formats with, and both are honoured. -- **An `instanceof` check on a value declared `object|string` narrows it to the class.** A subject whose declared type names no class at all had the checked class *added* to its union rather than replacing it, so a route parameter typed `object|string` came out of `if ($server instanceof Server)` as `object|string|Server` and passing it to something expecting a `Server` was reported as `string does not satisfy Server`. Both spellings of the check were affected, including the guard form `if (! $server instanceof Server || ! canManage($server))` where the right operand of `||` runs only once the negated check has failed. Every alternative in such a union is either subsumed by the checked class (`object`) or ruled out by the check succeeding (`string`), so the check's result is now the whole answer. Closes #359. +- **An `instanceof` check on a value declared `object|string` narrows it to the class.** A subject whose declared type names no class at all had the checked class _added_ to its union rather than replacing it, so a route parameter typed `object|string` came out of `if ($server instanceof Server)` as `object|string|Server` and passing it to something expecting a `Server` was reported as `string does not satisfy Server`. Both spellings of the check were affected, including the guard form `if (! $server instanceof Server || ! canManage($server))` where the right operand of `||` runs only once the negated check has failed. Every alternative in such a union is either subsumed by the checked class (`object`) or ruled out by the check succeeding (`string`), so the check's result is now the whole answer. Closes #359. - **A custom Eloquent builder keeps the model it was built for.** `SiteCertificate::query()->whereKey($id)->firstOrFail()` resolved to the base `Model`, or reported `subject type 'TModel' could not be resolved`, whenever the model routed its queries through a custom builder. PHP has no generics, so almost nobody writes `@template`/`@extends` on a builder subclass: `class SiteCertificateBuilder extends Builder {}` is the whole class, and the model was lost at whichever method on the chain returns it. The model the query was started from now travels through the builder to the end of the chain, so the result of `firstOrFail()`, `first()`, `get()`, and the rest completes, hovers, and is checked as the concrete model, whether the builder is declared with generics or without. A builder specialised this way also keeps everything the ordinary resolution gives it, including the query-builder methods it reaches through `@mixin`. Closes #362. - **A custom Eloquent builder keeps its model however deep the builder hierarchy is.** A project that gives its builders a shared base of their own (`class AdminUserBuilder extends UserBuilder`, where `UserBuilder extends Builder`) lost the model one level in, since neither of its own classes declares generics, and the query fell back to the base `Model` the way it used to before builders kept their model at all. The binding now reaches the generic ancestor wherever it sits in the chain, so a builder subclass at any depth resolves `first()`, `get()`, and the rest as the concrete model. - **`array_filter()` reports the type the filter leaves behind.** A callback that tests the value it is handed proves something about every entry that survives, but the result kept whatever element type went in, so `array_filter($values, fn ($v) => $v !== null)` still looked like it could hold `null` and returning it from a function declared `int[]` was reported as a type error. The surviving values are now narrowed the way the body of an `if` narrows the variable it guards, whether the test is written as an `is_…()` call, a comparison against `null`, an `instanceof` check, or a callable string such as `'is_int'`. The keys were already narrowed this way in the modes that hand the callback a key, and both halves narrow together under `ARRAY_FILTER_USE_BOTH`. Closes #376. @@ -370,7 +373,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A guard on a `?->` chain's result narrows the receiver the chain ran against.** `$period = $agreement?->latestPeriod();` followed by `if (!$period instanceof Period) { return; }` proves `$agreement` was not null: a null receiver short-circuits the chain, and the guard would have returned. The proof was only drawn when the guard's own condition spelled the chain out, so storing the result in a variable first (which is how it is nearly always written) lost it and passing `$agreement` on was reported as passing a `?Agreement`. The link between the value and the receivers it came from is now recorded where the chain is written, so any guard that rules out the value's null rules out theirs, whatever shape it takes. Writing to the receiver in between drops the link, since what the guard proves is about the value the chain actually ran against. - **A ternary that repeats a property narrows it the way it narrows a variable.** `$alt = $article->alt ? $article->alt : $article->title;` is the standard fallback idiom, and the then-arm still read `$article->alt` as the nullable it is declared to be, so the result was reported as nullable everywhere it was used. The same line written with a plain variable narrowed correctly, which is what made it look like a Blade problem: Blade compiles a component attribute into exactly this assignment. Both the proof and the read now key on the whole path rather than on the variable it starts from. - **A `@var` docblock no longer cancels the rest of the statement it annotates.** A `/** @var Foo $x */` written directly above a statement is authoritative over the assignment it names, and the walker took that to mean the statement needed no further analysis at all: every narrowing pass for that line was skipped, so a ternary, an `&&` chain, or an assertion helper on the same line proved nothing. Only the assignment is skipped now. -- **`assertInstanceOf()` on a mock leaves an intersection, not a choice.** A mock really is both the interface it was built as and the class it stands in for, so `assertInstanceOf(MethodNode::class, $mock)` on a `MockObject` leaves a `MethodNode&MockObject`. It was recorded as `MockObject|MethodNode` instead, which satisfies neither half, so returning the value from a method declared to return the intersection was reported as a type error. A subject that is *already* an intersection now narrows within it as well, so `(FunctionNode|MethodNode)&MockObject` proven to be a `MethodNode` becomes `MethodNode&MockObject` rather than staying as it was. +- **`assertInstanceOf()` on a mock leaves an intersection, not a choice.** A mock really is both the interface it was built as and the class it stands in for, so `assertInstanceOf(MethodNode::class, $mock)` on a `MockObject` leaves a `MethodNode&MockObject`. It was recorded as `MockObject|MethodNode` instead, which satisfies neither half, so returning the value from a method declared to return the intersection was reported as a type error. A subject that is _already_ an intersection now narrows within it as well, so `(FunctionNode|MethodNode)&MockObject` proven to be a `MethodNode` becomes `MethodNode&MockObject` rather than staying as it was. - **An argument is not checked against a `@template` only it could have bound.** Comparing an argument to a parameter type that was substituted from that same argument is circular, and PHPantom already stood the check down where the template had a single binding site. Laravel's `travelTo` names `TDate` in both `$date` and its optional `$callback`, and a call passing only the date still binds `TDate` from that one argument, but the second site's existence was enough to re-enable the check, so `$this->travelTo(Carbon::create(2024))` was reported as expecting a `Carbon` and getting a `?Carbon`, contradicting the callee's own `@template TDate of …|null` bound. What counts is now the binding sites the caller actually filled. - **A `@phpstan-assert-if-true` promise about the receiver's own members is kept.** PHPStan's `Scope::isInTrait()` is annotated `@phpstan-assert-if-true !null $this->getTraitReflection()`, and a tag whose subject is a member of the receiver rather than a parameter was ignored outright, so the paired getter still read as nullable inside the guard. Those now narrow the member as read through the variable the call was written on. A `!null` promise about a plain parameter was dropped for a related reason (the tag names no class, so the class-based narrowing had nothing to rule out) and is now applied as the matching `is_*()` guard would apply it. PHPStan leaves the identical `isInClass()` bare, so that pairing is supplied for it: extensions are written against it regardless. - **A `Stringable` object passed to a `string` parameter is checked against the file's `strict_types` setting.** PHP only converts a `Stringable` object to a string automatically outside `declare(strict_types=1)`; under strict types the same call throws a `TypeError`. Every neighbouring type-juggling rule (int/float to string, numeric-string to int/float) already read the file's `strict_types` flag, but the `Stringable` rule was accepting the object either way, so a class relying on `__toString()` under strict types went unreported. @@ -384,7 +387,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A `??` argument decides a builtin's return type.** `str_replace('Error: ', '', $error ?? '')` reports one type for a string subject and another for an array one, and the coalesce came back with no type at all, so the call kept both. It resolved correctly for the same value assigned to a variable first, which made this a gap in reading the argument rather than in the rule. A coalesce now reports the union of its left operand without `null` and its right, wherever a call's arguments are read. - **A union of a class and a scalar narrows on both sides of an `instanceof`.** `Decimal|float` resolves to one class and one scalar, and the type engine kept the pair on a single entry that only named the class. An `instanceof` check ruling that class out therefore had nothing left to point at and dropped the whole union, so the guarded branch kept the type the check had just disproved: the `else` of `if ($value instanceof Decimal)` still read `Decimal|float` and passing `$value` to `number_format()` was reported as a type error, as was the body of `if (!$imgix instanceof Image)`. Ruling a class out now subtracts just that class and leaves the rest of the union standing, so both spellings of the check narrow to the half that survives it. A union of two classes always narrowed correctly and is unchanged. - **A guard that repairs a value is not undone where the branches rejoin.** Catching a bad value and replacing it on the spot is one of the most common shapes in PHP: `if (!$value) { $value = 'fallback'; }` on a `string|false`, or `if (!is_array($status)) { $status = [$status]; }` on a value that may or may not already be a list. After the `if`, every path holds the good value, but the merge put the original union back and the line below was reported for a `false` or a bare item that cannot reach it. Two things caused it. The path where the check did not hold only ruled out `null`, where the same guard written as `if (!$value) { return; }` correctly ruled out everything falsy; and an array literal built inside the branch read its elements from the parameter's declaration rather than from the branch, so `[$status]` wrapped the un-narrowed value. Both now agree with the guard, so the merged type is the repaired one. -- **A guard reaches the reads derived from the value it narrowed.** Narrowing a variable and then reading something *derived* from it went back to the declaration instead of to the guard: an array-dimension fetch (`$violationMessage['args']` inside `if (is_array($violationMessage))`), an argument to one of the array functions whose result follows its input (`array_slice($cached, 0, $limit)` inside `if ($cached !== null)`), and the same read after a `@phpstan-assert` guard such as PHPUnit's `assertNotNull()`. Each of those resolution paths consulted the backward `@param`/`@var` scan first, and that scan describes a variable where it is *annotated*, not where it is *used*, so the arm the guard had just ruled out came back and the read was reported as a type error. All of them now read the guarded scope first and fall back to the annotation only when the scope has nothing to say. +- **A guard reaches the reads derived from the value it narrowed.** Narrowing a variable and then reading something _derived_ from it went back to the declaration instead of to the guard: an array-dimension fetch (`$violationMessage['args']` inside `if (is_array($violationMessage))`), an argument to one of the array functions whose result follows its input (`array_slice($cached, 0, $limit)` inside `if ($cached !== null)`), and the same read after a `@phpstan-assert` guard such as PHPUnit's `assertNotNull()`. Each of those resolution paths consulted the backward `@param`/`@var` scan first, and that scan describes a variable where it is _annotated_, not where it is _used_, so the arm the guard had just ruled out came back and the read was reported as a type error. All of them now read the guarded scope first and fall back to the annotation only when the scope has nothing to say. - **A typed class constant keeps the value it was given.** PHP 8.3 lets a class constant declare a type (`private const int DEFAULT_OPTIONS = JSON_HEX_TAG | JSON_THROW_ON_ERROR;`), and PHPantom took that declaration as the whole answer, so everything the initialiser said was thrown away the moment a type was written in front of it. Only the declared type reached the code that reads a constant's value, which is what decides a flag argument, a match subject, and a comparison against a constant. The everyday symptom was a `json_encode($value, self::DEFAULT_OPTIONS)` reported as `string|false` when the mask it is handed sets `JSON_THROW_ON_ERROR`, a failure that throws rather than returns. The initialiser is now read the same way an untyped constant's is, and the declared type is what stands when the value cannot be worked out. A declaration that says more than its initialiser does keeps its own answer, so a constant typed as an enum still resolves to that enum rather than to the case it happens to hold. - **A member read off a class constant resolves against the value the constant holds, not the class that declares it.** `self::TYPED->value`, `self::UNTYPED->value`, and `Matrix::TYPED->value` were all reported as `Property 'value' not found on class 'Matrix'` for `public const Kind TYPED = Kind::A;` and `public const UNTYPED = Kind::A;`, since the class constant's subject resolved to the class it was declared on rather than to the enum case it held. Reading a member off `Class::CONST` now resolves against what the constant's value or declared type actually is, so an enum case stashed in a constant still exposes `->value` whether the constant is typed, untyped, or read through `self::`, `static::`, or the class name directly. - **An `array` or `T[]` keeps a key type a callback proves.** These name a value type and say nothing about their keys, which the type engine read as "integer keys" because that is the useful default for iterating one. It is only a default, though, and a callback that filters on the key contradicted it: `array_filter($data, fn ($k) => is_string($k), ARRAY_FILTER_USE_KEY)` had nothing left to keep and handed back the type it was given, so passing the result to a parameter wanting `array` was reported as a mismatch. The narrowing now starts from every key PHP permits, so the shorthand gets the same answer the spelled-out `array` already did, and the result carries that key type through an array union (`+`) with other string-keyed arrays. A `list` does promise integer keys and is unchanged. @@ -405,7 +408,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A loop condition narrows its own operands.** The `&&` narrowing an `if` condition performs was not applied in a `do`/`while` or `for` condition, so `do { $node = $this->parseOptional(); } while ($node && $this->addChild($list, $node));` reported the null the condition's first operand rules out. Both now narrow as `if` and `while` already did. - **A namespaced constant is found however it is written.** A `const` declared inside a namespace was indexed under its last segment alone, so the only reference that could find it was one that named it without the namespace. `Config\GRADES`, `\App\Config\GRADES`, and a name reached through a `use const` import all resolved to nothing: hover showed no value, Ctrl+Click went nowhere, and everything the constant's value proves was lost, so a strict `in_array($grade, Config\GRADES, true)` gate stopped narrowing and the code after it was reported against the type the gate had just ruled out. Constants are now indexed by their fully-qualified name and a reference is resolved against the file it is written in, the way a class or function name is, including PHP's fallback from an unqualified name to the global constant of that name. A `define()` still names exactly the string it was given, wherever the call sits, which is what PHP does. - **A nullable value is checked the same way whichever way it is written.** `?string` and `string|null` describe the same value, but only the second was ever checked: passing a `?string` to a `string` parameter, returning one from a `string` function, and assigning one into a `string` property all passed silently, while the same code with the union spelling was reported. Which of the two a value carries is an accident of how it was produced, so the same argument was reported or not depending on whether its type came from a declaration or from a branch merge, a `??` chain or an optional array-shape key. Both spellings are now read as the union they stand for, so a null the code has not ruled out is reported wherever it is passed, and a nullable value satisfies a parameter that lists `null` among its own types. -- **A property keeps what a check proved about it across a method call.** `if (!$payment->id) { throw … }` followed by `$order = $payment->load();` threw away everything the check had established about `$payment`'s properties, so the very next line reported the null the guard exists to rule out. A call can still change what a *recorded call* through the same receiver answers (`$stmt->fetch()` after `$stmt->execute()`), and that is still dropped, but the properties read through it are kept. +- **A property keeps what a check proved about it across a method call.** `if (!$payment->id) { throw … }` followed by `$order = $payment->load();` threw away everything the check had established about `$payment`'s properties, so the very next line reported the null the guard exists to rule out. A call can still change what a _recorded call_ through the same receiver answers (`$stmt->fetch()` after `$stmt->execute()`), and that is still dropped, but the properties read through it are kept. - **A ternary inside `new Foo(…)` narrows in its branches.** `new Wrapper($h->name ? strtoupper($h->name) : '')` was checked with the type the value had before the condition, so the branch that only runs when the value is there was reported against its nullable type. Arguments to `new` are now narrowed like the arguments to any other call. - **`array_filter` keeps what its callback proves about the keys.** In the two modes that hand the callback the key (`ARRAY_FILTER_USE_KEY` and `ARRAY_FILTER_USE_BOTH`) the result was reported with the key type it went in with, so `array_filter($data, fn ($k) => is_string($k), ARRAY_FILTER_USE_KEY)` still claimed integer keys the call exists to remove, and passing it on to a parameter declared `array` was reported as a mismatch. The keys that survive are now read off the callback the same way an `if (is_string($k))` body is read, whether it is written inline or named (`'is_string'`), and a callback that admits every key it could receive leaves the type alone. - **An element write refines what the array already holds.** A write into a variable whose keys were already tracked was thrown away rather than recorded: `$row[] = $pen` on a `array{name: string}` left the shape exactly as it was, and so did `$row[$key] = 1`, so the value that had just been written was not there to read back. Both are now applied. An append takes the next free integer key beside the keys already tracked, an append one level down extends what that key holds instead of leaving it at the value it was initialised with, and a write through a key only known at runtime widens the shape to the keys and values it and the existing entries describe together, since a runtime key may land on any of them. The reverse mistake is gone too: writing a literal key into an array declared by key and value type (`array`) rebuilt it as a shape holding that one key, discarding every other key it was known to hold, and appending to a string-keyed array called the result a `list`. Both now keep the array's key and value types and fold the written pair into them. @@ -422,7 +425,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`list` and `array` are recognised as the same type.** Two array types were only ever lined up when both were written with the same number of type arguments, so a `list` never satisfied an `array`, an `array` never satisfied an `array`, and a list of array shapes was rejected by a parameter declared `array>`. Each spelling now contributes the key and value type it implies before the two are compared: a `list` keys on `int`, a one-argument `array` on `array-key`, and a one-argument `iterable` promises nothing about its keys at all. A `list` on the receiving end still demands sequential keys, so a plain `array` does not pass for one. - **An Eloquent model factory keeps its model type through a shared factory base.** Projects often put common factory helpers on an unannotated `BaseFactory`, leaving each concrete factory to Laravel's naming convention. PHPantom applied that convention to the intermediate base instead of the concrete factory, found no corresponding model, and reduced every inherited generic return to Eloquent's base `Model`; a factory reached correctly from `Draft::factory()` could therefore still make what looked like the wrong class. Convention inference now stays anchored to the concrete factory across the whole inheritance chain, so its inherited build methods return the associated model. Contributed by @shuvroroy (#356). - **A doubly negated guard narrows like the bare one.** `if (!(!$user))` left `$user` as possibly null inside the body, while `if ($user)`, `if (!($user === null))`, and every other spelling of the same test narrowed. The outer `!` was a shape no guard recognised, so nothing was proved at all. A pair of `!` now cancels before the condition is read, and it cancels per conjunct, so wrapping one operand of an `&&` costs the chain nothing. Blade's `@unless` compiles to `if (!…)`, which makes `@unless (!$user)` exactly this shape, so the body of one narrows now too. -- **A remembered check does not outlive the state it was made about.** `if ($stmt->fetch('id') !== false)` proves something about the row the statement is on, and a repeated `$stmt->fetch('id')` inside the branch reads that proof rather than the declared `array|false`. But an intervening `$stmt->execute()` moves to another row, and the proof stood anyway, so the sentinel the guard exists to rule out was reported as impossible on a value that could be exactly that. A call on a receiver now drops what was remembered *through* that receiver, whether that is a repeated call, a property path, or an element. The receiver keeps its own type, a call on some other object leaves it alone, and a callee declared `@pure`, `@phpstan-pure`, or `@psalm-pure` promises it changed nothing, so the proof survives it. +- **A remembered check does not outlive the state it was made about.** `if ($stmt->fetch('id') !== false)` proves something about the row the statement is on, and a repeated `$stmt->fetch('id')` inside the branch reads that proof rather than the declared `array|false`. But an intervening `$stmt->execute()` moves to another row, and the proof stood anyway, so the sentinel the guard exists to rule out was reported as impossible on a value that could be exactly that. A call on a receiver now drops what was remembered _through_ that receiver, whether that is a repeated call, a property path, or an element. The receiver keeps its own type, a call on some other object leaves it alone, and a callee declared `@pure`, `@phpstan-pure`, or `@psalm-pure` promises it changed nothing, so the proof survives it. - **`iterable` is a type guard.** `is_iterable($x)` narrowed nothing, and neither did the `@phpstan-assert iterable` tag PHPUnit's `assertIsIterable()` carries: `iterable` names no class, so the instanceof route could not carry it, and the guard route had no kind for it the way it has one for `array`, `string`, and `callable`. Both now narrow. A union keeps the members `foreach` can actually walk, which is an array in any of its spellings plus an object whose interfaces reach `Traversable`, so a generator and a collection survive the guard while a plain object leaves with the scalars. The failing branch is the inverse proof, and a `mixed` that passes the check reads as `iterable` rather than staying `mixed`. - **An assertion about `$this` narrows it, even where a docblock rebinds the closure.** A Pest test closure is bound to whatever `pest()->extends(…)` names, and no expression in the test file says what that is, so the suites write `assert($this instanceof AppTestCase);` as the closure's first line to spell it out. The `@param-closure-this` tag on Pest's `test()` had the last word on what `$this` was, so the assertion was read and then thrown away and every helper the real base class provides was reported as an unknown member. The tag says what the closure is bound to, which is a starting point like any declared type, so a proof inside the body now refines it and the subclass's members resolve. The tag still wins over the `$this` a closure merely captured from the method around it, which is the case it exists for. - **A constant defined from other constants holds their value.** `const FLAGS = JSON_THROW_ON_ERROR;` and `const COMBO = JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR;` were read only as far as their type, a plain `int`, so everything built on one lost the value behind it: `json_encode($data, self::FLAGS)` was still reported as possibly `false` even though the flag rules that out, and a `match` or a comparison against such a constant could not be decided either. The initialiser is now folded to the value PHP computes for it, through as many constants as it names and across the bitwise operators (`|`, `&`, `^`, `<<`, `>>`, `~`) a constant expression may use, and a constant that is simply an alias of another one (`const NS = Base::NS;`) holds what that one holds. A mask assigned to a variable first (`$flags = JSON_UNESCAPED_SLASHES | self::FLAGS;`) keeps its value too, so the call it is passed to reads the same bits as the call that spells the flags out. A constant defined in terms of itself has no value to hold and is left alone rather than chased in circles. @@ -435,7 +438,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A strict `in_array()` against a list of values narrows the needle to them.** `if (!in_array($user->getEmail(), self::APPROVED, true)) { abort(403); }` proves on the far side of the gate that the email is one of the approved addresses, so it cannot be null. Only a needle whose type named a class was narrowed before, which left every scalar gate (an allow-list of strings, a set of status codes, a list of locales) proving nothing at all. The needle now keeps only the values the haystack could hold, whether the haystack is a `list` parameter, an array written out at the call, or a class or global constant. A constant list of literals also carries its values now rather than flattening to a bare `array`, so `foreach (self::APPROVED as $address)` and `self::APPROVED[0]` describe what the constant actually holds. - **A checked call is remembered by the next occurrence of the same call.** `if (mb_strpos($slug, $marker) !== false) { mb_substr($slug, 0, mb_strpos($slug, $marker)); }` reported the inner `mb_strpos()` as `int|false` all over again, because a check only ever narrowed a variable and a repeated call was resolved from scratch. Writing the call out twice is how the guard idiom reads, so this hit `$this->option('from') ? Carbon::parse($this->option('from')) : null` and every `strpos`/`array_search`/`getenv` guard written without a temporary variable. A call is now remembered under its own written form, arguments included, so the second occurrence reads what the check proved. The proof lasts as long as the values it was made about: writing to anything the call reads drops it, as does leaving the branch or going round the loop again, and a call that hands back something different each time (`fgets()`, `array_shift()`, `time()`, `rand()`) is not remembered at all. - **A Laravel request accessor answers for the call it was written as.** `header()`, `query()`, `cookie()`, `post()`, `input()` and `file()` each declare one type covering every way of calling them, because a PHP signature cannot say that the answer depends on the arguments. So `$request->header('User-Agent', '')` came back `string|array|null` even though a header is never an array and the `''` is what a missing one produces, and `$request->query()` with no key came back the same way even though it can only be the whole query array. Each call is now read the way Laravel reads it: no key at all is the whole bag, a key is the item, and a default that is not null is what rules the missing-key branch out. `file('photo')` is the upload it names, with the request's own validation rules telling a `photos[]` field's list of uploads from a single one. -- **A function called through an imported namespace is no longer reported as undefined.** `use Core\Ip;` followed by `Ip\isIpAllowed($address)` is how PHP reaches a function in an imported namespace, and the call was flagged as an unknown function even though Ctrl+Click on that very name opened the declaration. The two disagreed because only one of them said *where* the name was written: go-to-definition asked with the position in hand and got PHP's own answer, while the diagnostic asked without it and was left guessing from the file's imports and its namespace, neither of which can resolve a name of that shape. The check now asks the same way everything else does, so it agrees with navigation, and it follows a call into a file that declares several `namespace` blocks rather than assuming the whole file lives in the first one. Contributed by @petrovo-as. +- **A function called through an imported namespace is no longer reported as undefined.** `use Core\Ip;` followed by `Ip\isIpAllowed($address)` is how PHP reaches a function in an imported namespace, and the call was flagged as an unknown function even though Ctrl+Click on that very name opened the declaration. The two disagreed because only one of them said _where_ the name was written: go-to-definition asked with the position in hand and got PHP's own answer, while the diagnostic asked without it and was left guessing from the file's imports and its namespace, neither of which can resolve a name of that shape. The check now asks the same way everything else does, so it agrees with navigation, and it follows a call into a file that declares several `namespace` blocks rather than assuming the whole file lives in the first one. Contributed by @petrovo-as. - **A `use function` or `use const` import is part of the symbol it names.** Only class imports were indexed, so an import line was a dead end: Ctrl+Click on it went nowhere, hover said nothing, it was missing from the function's list of references, and a rename rewrote every call while leaving the import pointing at a name that no longer existed. Both kinds are now indexed as the symbol they actually name. A global `const FOO = 1;` also gained a declaration of its own. Previously only the value it was assigned was ever looked at, so find-references and rename could not be started from one at all. Contributed by @petrovo-as. - **Renaming a function or constant keeps its imports and aliases intact.** A name written as `use function Foo\bar;` is the same symbol as the `bar()` that calls it, but not the same text, and the rename treated it as if it were: it replaced the whole line's name and left `use function baz;`, dropping the namespace. An aliased import fared worse. `use function Foo\bar as quux;` became `use function baz as quux;` and every `quux()` in the file was rewritten to `baz()`, turning code that compiled into code that does not. The alias is a local name and stays valid once the function is renamed. Each mention is now rewritten as what it is: a qualified import keeps its namespace and moves only the name at the end, a plain call takes the new name, and an alias and the calls that use it are left alone. Function names are matched case-insensitively and constant names are not, the way PHP matches them. Contributed by @petrovo-as. - **Go-to-implementation answers for the interfaces your dependencies ship.** Asking for the implementations of a method declared in a Composer package (`HttpKernelInterface::handle()` in a Symfony application, say) came back empty once the workspace index was ready, because results were narrowed to the project's own classes and Symfony's `HttpKernel` was dropped along with everything else under `vendor/`. That narrowing now applies only when the symbol you asked about belongs to the project, so an interface shipped by a package is answered from the classes that ship beside it. Two narrower exclusions are gone as well: an abstract class whose method has a body implements that method, though a method re-declared `abstract` still does not, and a concrete class that inherits the method unchanged resolves to the ancestor that declares it instead of being skipped for not overriding it. Requests on PHP's own interfaces, `Countable` and the like, still answer from the project alone, since their implementations span the entire dependency tree, which costs far more to collect than the answer is worth. @@ -450,7 +453,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Laravel's `__()` and `trans()` report the line they resolve to.** Laravel declares the translation helpers as `string|array|null`, because a key may name a whole group and the keyless form hands its own null back, so every `{{ __('checkout.title') }}` echo, `__()` fed to an `HtmlString`, and `assertSee(__('key'))` mismatched against the string it really is. The key at the call site settles it now: a key naming a single line is a `string`, a key naming a group is the array of lines beneath it, and `__()` with no key at all is the `null` it returns. A key built at runtime keeps both remaining branches and passes wherever either would, since no call that names a key returns null. `Lang::get()` reads the same way. - **A `@property` tag's name is coloured as a property.** The member name in a `@property` tag was highlighted as a method, while the same name was coloured as a property everywhere it was used, because a tag-declared member is not among the class's own members and fell through to the method default. Both tags are now classified by the tag that declares them. - **A successful `instanceof` check narrows the value down to the class, not out to a wider union.** `assert($obj instanceof Configuration)` on a value declared `object|null` reported `object|null|Configuration`: the check added the class beside what was already there instead of ruling out everything the class does not cover. The `if (!$obj instanceof Configuration) { throw … }` guard proves exactly the same thing and now goes through the same code, so its fall-through narrows identically rather than drifting on its own. -- **A check on a `?->` chain narrows the receivers it ran through.** `if ($image?->file_id !== null)` said nothing about `$image`, so the body still had to treat it as possibly null even though a null receiver is precisely what makes the check fail. Every receiver along the chain is non-null inside the branch now, whether the proof comes from a comparison against `null`, a truthy test, an identity check against a value that is not null, or the fall-through of a guard that returns. A branch the check does *not* prove, such as the else, keeps the nullable type it had. +- **A check on a `?->` chain narrows the receivers it ran through.** `if ($image?->file_id !== null)` said nothing about `$image`, so the body still had to treat it as possibly null even though a null receiver is precisely what makes the check fail. Every receiver along the chain is non-null inside the branch now, whether the proof comes from a comparison against `null`, a truthy test, an identity check against a value that is not null, or the fall-through of a guard that returns. A branch the check does _not_ prove, such as the else, keeps the nullable type it had. - **A type guard says what a value is even when nothing else does.** `$version = $row->version` on a plain `stdClass` yields no type at all, and a following `assert(is_string($version))` was skipped for want of a type to narrow, throwing away the one statement that described the value. The guard now establishes the type outright, in an `assert()` and past an `if (!is_string($v)) { return; }` guard alike. - **A null check on an array element refines that element.** `isset($m[0])`, `$m[0] !== null` and `assert(isset($m[0]))` left a following `$m[0]` reading `string|null` on an `array`, because only a constant array shape had a slot to record the proof in. The element the check named now carries it, without claiming anything about the array's other keys. - **A conditional return type keyed on an argument's type picks a branch.** `Order::find(7)` reported `Collection|Order|null`, every branch of the conditional at once, because a condition like `($id is array|Arrayable ? … : …)` was only ever settled by an argument written as a literal. The argument's resolved type now decides it, through the class hierarchy where the condition names a class or interface, so one id finds one model and a list of ids finds the collection. The same reading covers `is null`, which followed whether an argument was written at all rather than what it holds, so a nullable value no longer commits to the non-null branch it may not take, and `is not null` takes the branch its negation names. A condition that genuinely cannot be decided still reports both branches. Long chains of these decide as a whole, so spatie's `Data::collect()` reports the collection its arguments select instead of a union of all nineteen, and a value the branch names as a `@template` (`tap($order, …)`) is filled in from the call rather than reported as a bare `TValue`. @@ -458,11 +461,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **More builtins report the shape their arguments actually select.** A standard-library function that returns one of several things depending on how it was called can only be declared as the union of all of them, so every call carried branches it could never take. `pathinfo($path, PATHINFO_FILENAME)` reported the whole component array beside the string it really returns, `print_r($v, true)` kept a boolean, `hrtime(true)` and `microtime(true)` kept the array and string forms of their default, `getenv('HOME')` kept the whole-environment array, `mb_convert_encoding()` on a string kept an array branch, `abs()` on an `int` kept a `float`, and `SimpleXMLElement::asXML()` reported a `bool` that could not be split into the string it serialises to. Each now resolves to the branch its arguments select, including through a named constant and through the parameter's declared default when the argument is left out. A call whose deciding argument cannot be pinned down keeps every branch, which is all it can promise. - **A conditional return type keyed on a value reads named constants.** Only a literal written at the call site settled a condition like `($flags is 15 ? … : …)`; a constant, or a local holding one, decided nothing and the call silently took the fallback branch. The argument's resolved value now settles it either way, and a condition naming `true` or `false` is told apart from a plain `is bool` so each boolean literal picks its own branch. When the value genuinely cannot be determined, both branches are reported rather than committing to one. - **A fully-qualified global constant resolves.** `\PHP_EOL` and friends, written that way inside a namespace to skip the fallback lookup, were searched for under the leading separator and never found, so hover showed nothing and any type that depended on the constant's value fell back. -- **A `@property` tag beats an inherited property nobody can reach.** PHP only calls `__get()` when no *accessible* property of that name exists, so a `protected` declaration up the chain is never what the read yields. PHPantom reported its type anyway: an Eloquent model documenting `@property string $connection` still resolved `$model->connection` through `Model`'s own `\UnitEnum|string|null`, and the same happened for `$table` and `$keyType`, which models shadow routinely. The tag now describes the read it was written for. A property the class declares *itself* is a different matter and keeps its own type, since it is in scope everywhere the tag is, and so does an accessible inherited one. +- **A `@property` tag beats an inherited property nobody can reach.** PHP only calls `__get()` when no _accessible_ property of that name exists, so a `protected` declaration up the chain is never what the read yields. PHPantom reported its type anyway: an Eloquent model documenting `@property string $connection` still resolved `$model->connection` through `Model`'s own `\UnitEnum|string|null`, and the same happened for `$table` and `$keyType`, which models shadow routinely. The tag now describes the read it was written for. A property the class declares _itself_ is a different matter and keeps its own type, since it is in scope everywhere the tag is, and so does an accessible inherited one. - **An accumulator that starts as `[]` counts in whole numbers.** `$totals[$k] = ($totals[$k] ?? 0) + $n` is the standard way to tally by key, and the first pass read the empty array as an unknown value rather than a miss, so the sum came out `int|float` and failed every `array` it was declared as. An offset read on `[]` now yields `null`, which is what PHP produces and what the `??` was written to catch. The empty array also stops trailing along beside the array a later write produced: a variable seeded with `[]` and appended to in a loop, or captured by reference and filled in by a closure, reports the array it ends up holding instead of that alternative plus the empty one it started from, so reading an element out of it no longer carries a `null` from the empty half. - **A ternary's arms see what its condition proved.** `is_string($req) ? $req : 'today'` handed both arms the raw `string|array|null`, so a value the condition had just established was still reported against every `string` the ternary fed. Each arm is now resolved under its own polarity of the condition, using the same narrowing an `if`/`else` body gets, and it happens wherever the ternary is written rather than only in some positions: assignment, argument, and return all behave the same. That covers the whole family of conditions rather than a list of recognised shapes, so a type guard, a null or falsy check, `instanceof`, a member-existence proof, and anything added to narrowing later all reach the arms. A nested ternary's else arm carries the outer conditions' inverse narrowing as well as its own, and `?:` still yields the truthy half of its subject. - **A negated compound guard narrows by every conjunct.** `if (!is_string($payload) || $payload === '') { return; }` is the standard way to reject everything a function cannot handle, and the code after it was left with the un-narrowed union: the guard proved nothing. Falling through an `||` means every operand was false, so each operand's own inverse now applies, whatever kind of check it is. Previously only `instanceof` and member-existence checks were read one operand at a time, and the rest were matched against the whole `||` expression, which never matched. Every exit form works (`return`, `throw`, `continue`, `abort()`), so does the `else` branch, and so do chains of more than two conjuncts. `is_resource()` joins the `is_*` family it was missing from, and `!== ''` / `!== []` now refine to `non-empty-string` / `non-empty-array` rather than only removing a literal that was never in the union. -- **An array written under a `string` key stays keyed by `string`.** Every non-literal string key widened to `int|string`, on the grounds that a numeric string becomes an int key at runtime. Only a *literal* decimal-integer string does, so a function building `array` reported `array` and failed its own declared return type, including after an explicit `(string)` cast, a backed enum's `->value`, and `ReflectionProperty::getName()`. A key expression now keeps its own domain: `string` stays `string`, `int` stays `int`, and the int conversion applies to literal decimal keys alone. `++$i` and `$i++` resolve as well, so a counter used as a write key no longer falls back to `array-key`. +- **An array written under a `string` key stays keyed by `string`.** Every non-literal string key widened to `int|string`, on the grounds that a numeric string becomes an int key at runtime. Only a _literal_ decimal-integer string does, so a function building `array` reported `array` and failed its own declared return type, including after an explicit `(string)` cast, a backed enum's `->value`, and `ReflectionProperty::getName()`. A key expression now keeps its own domain: `string` stays `string`, `int` stays `int`, and the int conversion applies to literal decimal keys alone. `++$i` and `$i++` resolve as well, so a counter used as a write key no longer falls back to `array-key`. - **An assignment in an `elseif` condition narrows what it wrote.** `} elseif ($token = $request->bearerToken()) {` puts `$token` in scope and proves it truthy, and the leading `if` form read it that way, but the `elseif` form applied the narrowing before the assignment had happened, so the body saw the raw nullable type. The two now run in the same order, for both brace and `elseif:` syntax. - **A variable seeded with `false` can be checked for.** `$time = false; … $time = strtotime($raw); … if ($time) { date($f, $time); }` is how PHP code has always written "not parsed yet", and PHPantom widened that first `false` to `bool` the moment it was assigned. `bool` includes `true`, so the truthiness check had nothing to subtract and the guarded body still saw `bool|int`, which was then reported against `date()`'s `?int` parameter. A written `true` or `false` now keeps its own type the way every other literal does, so the join is `int|false` and the check clears the `false` half. A value that genuinely is `bool` narrows to `true` inside a truthy branch as well, so the common `$found = false; foreach … { $found = true; break; } if ($found)` pattern reports what the branch proved. A type written back into source still widens to `bool`: an inferred return type suggests `: bool` rather than the `true` that would need PHP 8.2 and would reject a subclass returning the other half. - **An override's own return type has the last word over the docblock it inherits.** An implementation without a docblock of its own inherits the interface's `@return`, which is usually what you want, but the implementation's native declaration is a promise the wider union does not get to override. An interface declaring `@return array|list|string` with an implementation declaring `: array` reported the `string` half as a possible result, and that half then failed every `array` parameter the value was passed to. The inherited union is now restricted to what the override's own declaration allows. Only alternatives whose kind is unmistakable take part, so a class name, `object`, `callable`, or `iterable` on either side rules nothing out. @@ -482,15 +485,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A guard clause on a property proves the same thing it proves about a local.** `if ($this->handle === false) { return; }` is how a `T|false` property is checked before use, and PHPantom went on reading the property as `string|false` for the rest of the method, reporting every use of it as if the guard were not there. The same check on a local variable narrowed correctly, so the guard's shape was never the problem: a property path was simply not recorded as the subject the check ruled a value out of. It is now, so a property survives the guard narrowed, whether the guard ends its branch with a `return`, a `throw`, or a `continue`. `!$this->handle` and `empty($this->handle)` name a property the same way, which they previously did not name at all. The path may be as deep as it needs to be, and a later write to it replaces what the guard proved rather than outliving it. - **An array literal keeps the values it was written with.** `[1, 1.5, '123']` says exactly what it holds, but the values were widened to `int|float|string` the moment they were stored, so reading an entry back could not be proven to be anything the individual values were. Handing `$values[$key]` to a parameter or return type of `numeric` was reported, even though every entry of the array is numeric on its own, and the same held for a literal key: `$values[2]` read as `string` rather than as the numeric `'123'` written at that position. The values a literal names now survive into its type, so a read off it, a `foreach` over it, and an inferred `@return` all see them, and a key that is only known at runtime resolves to the set of entries the array actually has. Widening now happens where the array is changed instead: a push or a keyed write says the array is being built up rather than written out, so the value arriving there stands in for however many more follow. An alternative already covered by a broader sibling is folded away, so a list mixing a plain `string` with two string literals stays `list`, and a literal naming more distinct values than a set of alternatives is worth reasoning about falls back to the base types. - **A constant table constrains a plain signature too.** `@param key-of` says a parameter takes one of the table's keys, and `@return value-of` says the result is one of its values. PHPantom only looked behind the constant's name while working out a call's `@template` bindings, which a function that declares no `@template` never does, so both tags widened to whatever a key or a value could be in general: `acceptsKey('nope')` went unreported, and a return that can only ever be `int|string` was read as `mixed`. The constant is now read wherever a declared parameter or return type names one, so an untemplated function or method is held to the table's own keys and hands back the table's own values. The declaration reads the same way from inside the body: the parameter holds the keys the table has, so hover names them and passing one on is judged against them, and a `@return` naming the table is held to what the table holds, so returning a key the table does not have is reported where it is written. The `Class::TABLE` and `self::TABLE` spellings read the same way, and a constant that cannot be reached, or whose value is not an array literal, still widens rather than being guessed at. -- **A lookup into a constant table reads as the entry its key names.** A constant holding an array literal is the ordinary way to write a table of settings, and a function that reads one out of it can say so: `@template T of key-of` with `@return ID_TABLE[T]` names the value under whichever key the caller passed. PHPantom read neither tag, because the docblock only ever sees the constant's *name* and nothing looked behind it, so the declaration's own `int|string` stood for every call and `takesInt(lookUp('immutable'))` was reported for passing a `string`. The constant's initializer is now read where a type operator asks for it, so each call resolves to its own entry: hover names it, argument checks judge against it, and a key the table does not hold is still rejected. The `Class::TABLE` spelling reads the same way, and a constant whose value is not an array literal is left alone rather than guessed at. +- **A lookup into a constant table reads as the entry its key names.** A constant holding an array literal is the ordinary way to write a table of settings, and a function that reads one out of it can say so: `@template T of key-of` with `@return ID_TABLE[T]` names the value under whichever key the caller passed. PHPantom read neither tag, because the docblock only ever sees the constant's _name_ and nothing looked behind it, so the declaration's own `int|string` stood for every call and `takesInt(lookUp('immutable'))` was reported for passing a `string`. The constant's initializer is now read where a type operator asks for it, so each call resolves to its own entry: hover names it, argument checks judge against it, and a key the table does not hold is still rejected. The `Class::TABLE` spelling reads the same way, and a constant whose value is not an array literal is left alone rather than guessed at. - **An omitted argument reads a constant table under the key its own default names.** `@template T of key-of` with `@return ID_TABLE[T]` resolves to a single entry at every call site that writes the key out, but a parameter carrying its own default (`function lookUp(string $type = 'immutable')`) bound nothing when the caller left the argument off, so `lookUp()` fell back to the whole table's value union and `takesInt(lookUp())` was reported for passing a string the call can never return. A default value is as known at the declaration site as an argument is at the call site, so it now binds the template the same way: `lookUp()` resolves exactly as `lookUp('immutable')` does, for a method as much as for a function. - **A `for` loop's update clause carries its type into the next iteration.** `for ($node = $head; $node !== null; $node = $node->next)` is how a linked list is walked by hand, and the reassignment in the update clause counted for nothing. The clause was read far enough to hover and navigate the variables in it, but the type it produced was never fed back into the loop, so the body saw whatever the initialiser bound on the first trip through on every trip, and the variable kept that type after the loop as well, even where the update clause was the only thing that could have changed it. The clause now runs where PHP runs it, after the body and before the condition is checked again: the body sees the type the update produces alongside the one the initialiser bound, and a walk that ends because it ran out of nodes leaves the cursor holding `null` rather than the node it started from. The initialisers stay the one-time seed they are, so a variable the update clause retypes is no longer reset to its starting type. - **Arithmetic on a refined `int` no longer widens to `int|float`.** `int + int` is `int`, and that held for the bare spelling, but `strlen()`, `count()`, and most of the standard library's counting functions are declared with a refinement like `int<0,max>` rather than plain `int`, and accumulating one of those (`$length += strlen($text);`) read as an unrecognised operand and fell back to the conservative `int|float`, reported several lines away at the function's `return` rather than at the addition that caused it. Every `int` refinement (`positive-int`, `non-negative-int`, `int`, and the rest) is now classified as `int` for arithmetic, and the same holds for `float`'s own refinements. - **A `foreach` key is typed from what is being iterated.** `foreach ($xs as $i => $x)` over a `list`, an `int[]`, or an array shape left `$i` as `int|string`, the entire domain a PHP array key can occupy, even where the subject can only ever produce one half of it. The key now comes from the subject: a list and a `T[]` bind an `int`, and an array shape binds whichever of `int` and `string` its own keys are. So an argument check on the key says something, and filling a second array through it (`$rows[$i] = …`) yields `array` instead of widening the key to `int|string`. A subject that genuinely says nothing about its keys, a bare `array` or an untyped parameter, still leaves the key `int|string`, since that is all either one licenses. - **A check written beside the assignment it guards narrows the variable.** `while (($line = fgets($handle)) !== false)` is the compact form every stream read loop is written in, and the check ruled nothing out: the body saw the whole `string|false` the assignment produced, so passing the line to anything that takes a `string` was reported on the line the condition exists to protect. The assignment now lands in the loop's scope before the condition narrows it, and a check reads through the parentheses to the variable the assignment wrote, so the sentinel is gone for the body. The bare truthy form (`while ($parent = $parent->getParent())`), the `null` sentinel, and the same shapes written as an `if` all follow, the negated guard `if (!$row = $query->first()) { throw … }` among them. -- **A read loop keeps the narrowing its condition established.** `while ($line !== false) { useString($line); $line = readLine(); }` is how every `fgets()`, `fgetcsv()`, and `readdir()` loop is written, and the read at the top of the body was judged against `string|false`, on the line the condition exists to protect. The reassignment at the bottom was the cause: to find what a variable holds on the second and later trips through a loop, PHPantom walks the body once ignoring the position it was asked about, and when that walk was the only one, the answer it left behind was the type at the *end* of the body rather than at the position asked for. A walk that honours the position now always follows, so the read sees what the loop entry established, and a read written *below* the reassignment still sees the reassigned type. `foreach`, `for`, and `do`/`while` share the walk and are fixed with it. +- **A read loop keeps the narrowing its condition established.** `while ($line !== false) { useString($line); $line = readLine(); }` is how every `fgets()`, `fgetcsv()`, and `readdir()` loop is written, and the read at the top of the body was judged against `string|false`, on the line the condition exists to protect. The reassignment at the bottom was the cause: to find what a variable holds on the second and later trips through a loop, PHPantom walks the body once ignoring the position it was asked about, and when that walk was the only one, the answer it left behind was the type at the _end_ of the body rather than at the position asked for. A walk that honours the position now always follows, so the read sees what the loop entry established, and a read written _below_ the reassignment still sees the reassigned type. `foreach`, `for`, and `do`/`while` share the walk and are fixed with it. - **An array filled in over several branches reads as one array.** Building a lookup up a branch at a time, `$rows = [];` and then a `$rows[$item->id] = …` under each of a handful of `if`s, is how half the report-building code in a procedural codebase is written, and PHPantom described the result by listing every stage it passed through: `array|array|array|array`, one cumulative snapshot per branch, each one overlapping the last. Hovering the variable said nothing legible, and a function honest enough to declare `array` was reported as returning something incompatible with its own signature. The branches now merge into the single array they describe, so the type is the one the code builds. Two arrays that hold genuinely different things, assigned in sibling branches rather than written into one array, are still a union, which is what they are. An empty `[]` is also read as the empty array it is rather than as an array of unknown contents, so it no longer trails along beside whatever gets written into it. -- **A type PHP has no declaration for is reported where it is written.** `function takesResource(resource $value)` reads as a real type hint and is not one: PHP has no `resource` declaration, so it warns that the name "is not a supported builtin type and will be interpreted as a class name" and then looks for a class called `resource`, which does not exist either. PHPantom accepted it silently, because `resource` is part of the vocabulary a *docblock* may draw on and nothing checked where the name was written. A native hint naming something PHP does not support is now reported the same way any unresolvable class name is, which covers the legacy aliases (`integer`, `boolean`, `double`, `real`) and the PHPStan-only spellings (`number`, `scalar`, `list`) as well as `resource`. Every type PHP does support is untouched, in any casing, since those are reserved keywords, and a docblock may keep using the whole vocabulary, which is where it means something. +- **A type PHP has no declaration for is reported where it is written.** `function takesResource(resource $value)` reads as a real type hint and is not one: PHP has no `resource` declaration, so it warns that the name "is not a supported builtin type and will be interpreted as a class name" and then looks for a class called `resource`, which does not exist either. PHPantom accepted it silently, because `resource` is part of the vocabulary a _docblock_ may draw on and nothing checked where the name was written. A native hint naming something PHP does not support is now reported the same way any unresolvable class name is, which covers the legacy aliases (`integer`, `boolean`, `double`, `real`) and the PHPStan-only spellings (`number`, `scalar`, `list`) as well as `resource`. Every type PHP does support is untouched, in any casing, since those are reserved keywords, and a docblock may keep using the whole vocabulary, which is where it means something. - **`assert()` on an array element narrows that element.** `assert($items[0] instanceof Foo)` is how a test or a defensive read states what a slot holds, and reading the same slot afterwards ignored it: the assert only ever narrowed a plain variable, so `$items[0]` kept whatever the array's element type said. The element is now narrowed under the same key the equivalent `if ($items[0] instanceof Foo)` uses, for a numeric index and a string key alike, and only for the element the assert names: a sibling key keeps its declared type. - **`!== false` narrows inside the branch it guards.** `fopen()`, `finfo_open()`, `strpos()`, and every other function that reports failure with `false` are guarded by writing `if ($handle !== false)`, and PHPantom read the body of that `if` as though the check were not there: the value stayed `T|false` and passing it to anything that takes a `T` was reported, on the line the check exists to protect. The check now rules `false` out for the branch, the way `!== null` already ruled out `null`, and it holds for a `while` condition as well as an `if`. Only `false` is ruled out, so a `T|false|null` value keeps its `null` and is still reported: `null !== false` is true. - **A docblock can refine one member of a native union.** `/** @return false|string */` written over a `bool|string` return type says the only boolean it ever hands back is `false`, which is what makes the idiomatic `!== false` check worth writing. The refinement was discarded: a native union was refinable only when one of its members was a class or a broad container, so an all-scalar union kept its declared spelling and callers saw `bool|string` no matter what the docblock said. Each member is now checked against the docblock member that narrows it, the same check a lone native `bool` already passed, so `bool` → `false`, `int` → `positive-int`, and `string` → `non-empty-string` all reach the type inside a union too. A docblock that describes something the native union does not mention is still ignored, since there the native hint is the more trustworthy of the two. @@ -502,9 +505,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **An array literal that leaves out a required shape key is reported.** A parameter documented as `@param array{host: string, port: int} $config` names the keys the callee is going to read, but `takesConfig(['host' => 'localhost'])` passed without a word, and the missing key surfaced later as an undefined-index warning somewhere inside the function. A required key the literal does not hold is now reported at the call site, and the message names the keys that are missing rather than leaving two long shape spellings to be diffed by eye. Key order is irrelevant, an extra key is harmless, a key the shape marks optional is by definition not required, and an entry written without a key counts as the index PHP gives it, so `['a', 'b']` still satisfies `array{0: string, 1: string}`. The check needs the array written out at the call site, because that is the only place all of its keys are visible: a shape built up over several statements, or one holding a key that is not a plain literal, records the keys PHPantom watched go in rather than everything the array holds, so a key it does not mention is unproven rather than absent and nothing is reported. - **A closure that returns the wrong thing for a `callable(...)` parameter is reported.** A parameter documented as `@param callable(int): string $callback` says what the callee will do with the result, but passing `static fn (int $v): int => $v` was accepted without a word: any two callable-ish types were treated as compatible, so the whole signature went unchecked. The return type is now compared, and the message names the two halves that disagree (`return type int does not satisfy string`) rather than leaving it to be read out of two type spellings. A closure's return counts whether it was declared or resolved from the body, and a closure that offers neither, along with a bare `Closure` or a callable named by a string, still says nothing to contradict and is left alone. Parameter types are not compared yet. This also corrected `array_filter`'s callback, which PHPantom typed as returning `bool`: PHP tests the result for truthiness, so the everyday `array_filter($items, fn ($i) => preg_match($re, $i))` is not a mistake. - **A class name written with an escaped backslash is recognized.** `'App\\Model'`, `"App\Model"`, and `'App\Model'` are three source spellings of the same runtime value, but a string literal's content was taken from its raw source text rather than decoded per PHP's quote rules, so an escaped backslash, or any other escape sequence, left the value one character too long to match anything in the project. `class-string`, `interface-string`, and Larastan's `model-property` literal checks now decode a string literal's escapes before resolving it, so a class name copied out of a double-quoted string, or written with a doubled backslash by habit, resolves the same as its plain spelling. -- **A partially-compatible union argument is now reported.** `acceptsLevel(gives())`, where `gives()` returns `1|99` and `acceptsLevel()` declares `@param 1|10 $level`, was accepted silently: the check asked whether *any* member of the argument union satisfied the parameter, so a satisfying member (`1`) hid one that didn't (`99`). It now asks whether *every* member satisfies the parameter, the same rule already applied everywhere else a union is checked against a type, and the message names the specific member that doesn't (`99 does not satisfy 1|10`). The same laxness applied to any union source, so an `int|string` value passed where `int` is declared is reported too, matching the `TypeError` PHP raises for the string case under `strict_types`. Getting there without new false positives meant closing three narrowing gaps the old laxness had been quietly covering for: an `elseif`'s own condition no longer sees a reassignment made in the preceding `if`-branch as though it had already run; `if ($x === false) { throw …; }` now narrows `false` out of `$x` the same way an `=== null` guard already did, so the common resource-handle idiom (`finfo_open()`, `pg_connect()`, …) resolves to its non-`false` type after the guard; and narrowing a declared class by `instanceof` to an unrelated interface it doesn't implement (a mock that is both simultaneously) now produces the intersection the value actually has, rather than a `Foo|Bar` union that neither member alone satisfies. +- **A partially-compatible union argument is now reported.** `acceptsLevel(gives())`, where `gives()` returns `1|99` and `acceptsLevel()` declares `@param 1|10 $level`, was accepted silently: the check asked whether _any_ member of the argument union satisfied the parameter, so a satisfying member (`1`) hid one that didn't (`99`). It now asks whether _every_ member satisfies the parameter, the same rule already applied everywhere else a union is checked against a type, and the message names the specific member that doesn't (`99 does not satisfy 1|10`). The same laxness applied to any union source, so an `int|string` value passed where `int` is declared is reported too, matching the `TypeError` PHP raises for the string case under `strict_types`. Getting there without new false positives meant closing three narrowing gaps the old laxness had been quietly covering for: an `elseif`'s own condition no longer sees a reassignment made in the preceding `if`-branch as though it had already run; `if ($x === false) { throw …; }` now narrows `false` out of `$x` the same way an `=== null` guard already did, so the common resource-handle idiom (`finfo_open()`, `pg_connect()`, …) resolves to its non-`false` type after the guard; and narrowing a declared class by `instanceof` to an unrelated interface it doesn't implement (a mock that is both simultaneously) now produces the intersection the value actually has, rather than a `Foo|Bar` union that neither member alone satisfies. - **A value read out of an array literal keeps the value it was written with.** A function whose `@return value-of` projects out of a template bound from the argument, as in `firstValue(['low' => 1, 'high' => 10])`, produced the right shape of type but not the right precision: each element was widened to its scalar type before the union was formed, so the call came back as plain `int` instead of `1|10`. Passing that on to something declared as `1|10` was reported as a type error, and hover described the call more loosely than the caller had written it. Array literal elements now keep their own literal value, matching how the literal's keys are already carried through `key-of`. A value that is not a literal, and every other way a template picks up a type, are unaffected. -- **Passing on what a `void` call gives back is reported.** A function or method declared `void` hands back no value, so `takesString(logRequest($request))` is a misreading of the API that PHP 8 covers up by substituting `null` at the call site. Nothing said so: a call to something declared `void` was resolved to that substituted `null` rather than to the `void` it declares, and `void` was skipped outright as an argument type on the grounds that it should never turn up as one. Between them the mistake only surfaced where the parameter happened to reject `null`, and stayed silent wherever it accepted one. A call now carries the `void` its signature declares, so passing it anywhere a value is expected is reported, with a message that says the expression returns no value rather than naming a type and leaving the reader to work out what it means. The same type reaches everything else that reads a call's result, so a variable assigned from a `void` call reads as `void` in hover and reports the member access it cannot answer against `void` instead of against a `null` the code never wrote. `never` is untouched: a call that does not return hands the parameter nothing because the program does not get that far, which is sound for any parameter type. A parameter *annotated* `void` is left alone too, since nothing produces a value of that type and the annotation is what is wrong there, not the argument. +- **Passing on what a `void` call gives back is reported.** A function or method declared `void` hands back no value, so `takesString(logRequest($request))` is a misreading of the API that PHP 8 covers up by substituting `null` at the call site. Nothing said so: a call to something declared `void` was resolved to that substituted `null` rather than to the `void` it declares, and `void` was skipped outright as an argument type on the grounds that it should never turn up as one. Between them the mistake only surfaced where the parameter happened to reject `null`, and stayed silent wherever it accepted one. A call now carries the `void` its signature declares, so passing it anywhere a value is expected is reported, with a message that says the expression returns no value rather than naming a type and leaving the reader to work out what it means. The same type reaches everything else that reads a call's result, so a variable assigned from a `void` call reads as `void` in hover and reports the member access it cannot answer against `void` instead of against a `null` the code never wrote. `never` is untouched: a call that does not return hands the parameter nothing because the program does not get that far, which is sound for any parameter type. A parameter _annotated_ `void` is left alone too, since nothing produces a value of that type and the annotation is what is wrong there, not the argument. - **`interface-string` is held to naming an interface.** The refinement was parsed and displayed, but nothing enforced what it says: `interface-string` was compared as though it were `class-string`, so passing the name of an ordinary class satisfied it, and, in the other direction, passing the name of an interface where an `interface-string` was declared was reported as a mismatch because a `class-string` had no relationship to it at all. Both sides are now decided by what the name refers to, since that is the whole point of the spelling: `SomeInterface::class` is accepted, `SomeClass::class` is reported even when the class implements the interface, and the same goes for a name written as a plain string. A name we cannot load may well belong to an interface in a file nobody indexed, and a bare `class-string` says nothing either way, so neither is reported. - **A class declared in two files survives the file that won it dropping the name.** A package that ships a class twice, typically a variant behind a `class_exists` guard, settles on one of the two declarations, and only that one was kept. So when the file holding it stopped declaring the name, because the class was renamed, the file emptied, or the file deleted, the name became unresolvable even though the other file still declared it, and every use of the class was reported as not found until something happened to re-parse the survivor. The runners-up are now remembered, the way they already were for duplicate functions, so the name is handed to the next file that declares it, along with the members completion and hover read from it, its place under its parent in go-to-implementation, and the file go-to-definition opens. The two paths that index classes, the one for files open in the editor and the one that loads vendor code, stubs, and files re-opened after closing, also disagreed about duplicates: the second kept the first file it saw for a name but the last set of members, so go-to-definition could open one declaration while everything else described the other. Both record declarations the same way now, so a duplicated name resolves to the same file whichever path indexed it. - **A function declared in two files resolves to the same one on every run.** A package that ships a helper twice, typically a native implementation alongside a `function_exists`-guarded polyfill with a looser signature, left the winner up to whichever indexing worker finished last. So the signature a call was checked against was decided anew each time the project was indexed, and running `analyze` twice on unchanged code reported an argument type mismatch on one run and nothing on the next, which reads as a flaky analyzer. Duplicate declarations now settle on the same file every time, and go-to-definition lands on that declaration rather than on a different one each session. The runners-up are still remembered, so editing or deleting the file that won hands the name to the next file that declares it instead of the function going missing until something happens to re-parse it. Duplicate classes were already settled deterministically, but the tie-break only held until one of the files was parsed again on its own: re-parsing the losing copy dropped the winning declaration out of the index entirely, taking the class's members with it, and left go-to-implementation no longer listing the class under its parent. Those indexes now agree on one declaration and keep agreeing across re-parses. @@ -540,7 +543,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A container call through the `App` facade resolves when it is chained directly.** `$repo = App::make(EventRepository::class);` followed by `$repo->getActiveEvents()` resolved, but the one-line `App::make(EventRepository::class)->getActiveEvents()` did not, and neither did `App::makeWith(...)->run()`. A facade forwards its static calls to a container class, and only the assignment path knew to look past the facade's own `@method` tag (which flattens the container's argument-dependent return to `object|mixed`) to the concrete class that actually types the call. The chain resolver now makes the same jump, so both spellings resolve, matching the `app()` helper. - **`analyze` reports the same diagnostics on every run.** Two runs over an unchanged directory could differ by dozens of messages, which made it impossible to tell a real regression from noise when comparing two builds over a corpus. Three things let a file's result depend on what the parallel workers happened to reach first. Bundled stubs were the only files with no protection against two workers parsing them at once, and the worker that finished second took the re-parse path, discarding every already-resolved class that depended on the stub. An interface was merged into an implementing class in full when it was already cached and as a weaker approximation when it was not. And a class declared in more than one file, as Carbon's `DatePeriodBase` and Symfony's polyfilled `RoundingMode` are, resolved to whichever copy was parsed last, so a name could pick up the legacy variant of a class that also ships a modern one. Results no longer depend on the worker count either, so machines with different core counts agree. - **A static factory's method-level template survives into a directly chained call.** A factory such as `Collection::make($items)`, declared `@template TValue` with `@return static`, bound its element type when the result went through a variable but lost it when the call was chained straight on: the static path read the factory's declared return type without applying the bindings it had just computed, and then flattened `static<…>` to a bare class name, dropping the arguments with it. Both now happen the way they already did for an instance method, so `Wrapper::make(names())->push([1])` reports the same argument mismatch that the two-line form does. -- **A standalone `@var` docblock narrows a call inside the same `echo`, `if`, or other non-expression statement.** A `/** @var Collection $byName */` written on its own line, immediately followed by a statement other than a bare expression (an `echo`, an `if`, a `return`, ...), correctly typed the variable everywhere *after* that statement but not within an expression inside the statement itself: a diagnostic scope snapshot taken right before the docblock was applied never got refreshed, so a call reached through the annotated variable saw its bare class instead of the generic arguments the annotation gave it and fell back to the class's declared template bound. Every Blade `{{ $byName->get(...) }}` compiles to exactly this shape (`echo e( $byName->get(...) );`), which is how this most commonly surfaced. +- **A standalone `@var` docblock narrows a call inside the same `echo`, `if`, or other non-expression statement.** A `/** @var Collection $byName */` written on its own line, immediately followed by a statement other than a bare expression (an `echo`, an `if`, a `return`, ...), correctly typed the variable everywhere _after_ that statement but not within an expression inside the statement itself: a diagnostic scope snapshot taken right before the docblock was applied never got refreshed, so a call reached through the annotated variable saw its bare class instead of the generic arguments the annotation gave it and fell back to the class's declared template bound. Every Blade `{{ $byName->get(...) }}` compiles to exactly this shape (`echo e( $byName->get(...) );`), which is how this most commonly surfaced. - **A callback body that is a call binds the template it returns.** An unannotated callback takes its return type from its body, and a body that was a call had none to give: only classes came back from that step, so `->keyBy(fn (Review $r) => $r->getRating())` left the key template unbound and a later `$byRating->get(1)` was reported as expecting `array-key|\UnitEnum|null` instead of `int|null`. A call body now resolves to whatever the callee returns, scalars included, the same way a property read or a `new` expression already did. - **A union parameter hint binds through the alternative the argument matches.** A parameter that accepts either an element or a container of elements, as Laravel's `Collection::wrap()` does with `@param iterable|TWrapValue $value`, always bound the template to the whole argument, so wrapping a `string[]` gave a collection of `string[]` rather than of `string`. The alternatives are now tried against the argument's actual shape, and the bare one, which matches anything, is only used when none of the others fit. Key and value positions line up across container shapes too, so a `list` argument binds a `TKey`/`TValue` pair correctly instead of leaving both at their declared bounds. - **A re-keying callback rebinds the key type of the Laravel collection it returns.** `Collection::keyBy()` and every other method whose return type is bound to a callback's return type (`@param callable(): TNewKey`) missed that binding in two common cases, and fell back to the template's declared bound, so a later `$keyed->get('slug')` was reported as expecting `int|null` or `array-key|null`. A callback written `static fn (…) => …` or `static function (…) { … }` is now read as the closure literal it is, rather than being skipped over the modifier. And a callback with no return-type annotation now keeps its body through call-chain resolution, so `->keyBy(fn ($row) => $row->slug)->get('slug')` binds the key type from the body the same way an annotated callback binds it from the annotation. @@ -605,8 +608,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Framework internals no longer appear as properties on Eloquent models.** A model that declared no relationships of its own still offered `hasMany`, `belongsTo`, `morphEagerTo` and the rest of the framework's own methods as properties, plus `has_many_count` and friends as count properties. Only relationships the model itself declares produce properties now.- **`parent::SOME_CONSTANT` resolves to a type.** A class constant reached through the `parent` keyword produced no type at all, so hover on it was blank and anything derived from it lost the value, while the same constant reached through `self`, `static`, or the class name resolved normally. Constants inherited further up the chain resolve through `parent::` too. - **A template parameter bound only by the argument it type-checks no longer flags a false positive.** PHPUnit's `assertSame(url('/login'), $x)` (and any other call where a `@template` is bound solely by the parameter being checked, such as `assertSame`'s `$expected`) could report a type mismatch: the substituted parameter type is derived from resolving that exact argument, so comparing the argument to it again is circular and, when the two resolution passes disagree on an ambiguous expression, produced a spurious diagnostic. Such a parameter is no longer checked against its own argument. - **`self`, `static`, and `parent` in a parameter type resolve to a real class.** A method declared `canChangeTo(self $next)` used to be checked against the literal keyword, so passing an instance of the declaring class was reported as "expects self, got State". The keywords now resolve wherever the call is made from, including through a property (`$this->state->canChangeTo(State::B)`), where the enclosing class is not the one declaring the method. `self` on an inherited method binds to the class that declares it, so a parent instance is still accepted when the method is called on a subclass, and a `parent` parameter is now checked instead of skipped. Mismatches name the class the keyword resolves to rather than the keyword. -- **Returning a base type where a subclass is declared is now reported.** Passing or returning a value whose type is a *supertype* of what the signature declares (an `Animal` where a `Cat` is expected) used to be waved through on the grounds that the value might be the narrower type at runtime. That silence hid a whole class of genuine mistakes, most visibly returning a base type from a method declared to return a specific subclass. Such a downcast is now a type mismatch. Code that proves the narrower type first, with `instanceof` or a `match` on the value's class, keeps resolving to it and stays quiet. -- **An Eloquent query resolves to the model's own collection class.** `Post::where(...)->get()`, a relation property, and `$post->comments()->get()` all resolve to the collection the model actually builds (from `#[CollectedBy]`, `@use HasCollection<...>`, or a `newCollection()` override) rather than the base `Illuminate\Database\Eloquent\Collection`. The collection's own methods complete on the result, hover names the real class, and a method declared to return it type-checks. A self-referential relation (`@return HasMany`) and a collection class declared in the model's own namespace are both recognized, and a query for a different model resolves to *that* model's collection. +- **Returning a base type where a subclass is declared is now reported.** Passing or returning a value whose type is a _supertype_ of what the signature declares (an `Animal` where a `Cat` is expected) used to be waved through on the grounds that the value might be the narrower type at runtime. That silence hid a whole class of genuine mistakes, most visibly returning a base type from a method declared to return a specific subclass. Such a downcast is now a type mismatch. Code that proves the narrower type first, with `instanceof` or a `match` on the value's class, keeps resolving to it and stays quiet. +- **An Eloquent query resolves to the model's own collection class.** `Post::where(...)->get()`, a relation property, and `$post->comments()->get()` all resolve to the collection the model actually builds (from `#[CollectedBy]`, `@use HasCollection<...>`, or a `newCollection()` override) rather than the base `Illuminate\Database\Eloquent\Collection`. The collection's own methods complete on the result, hover names the real class, and a method declared to return it type-checks. A self-referential relation (`@return HasMany`) and a collection class declared in the model's own namespace are both recognized, and a query for a different model resolves to _that_ model's collection. - **`view('name')` resolves to the concrete view object.** The helper's declared return type is the `Illuminate\Contracts\View\View` contract, but Laravel's view factory always builds an `Illuminate\View\View`. Every Blade component's `render(): View` signature names the concrete class, so the contract-typed result reported a mismatch on correct code. `view()` with a template name now resolves to the concrete class; with no arguments it is still the view factory. - **`match ($value::class)` narrows its subject in each arm.** A dispatch table written as `match ($node::class) { Foo::class, Bar::class => $this->handle($node), ... }` left `$node` at its declared type, so passing it to a handler typed for the arm's class was reported as a mismatch and completion on it offered the wrong members. Each arm now narrows the subject to the classes it names. - **An override inherits the `@param` types of the method it implements.** PHP requires an override to restate every native type hint, so an implementation of a generic interface method (`processNode(Node $node)` under `@implements Rule`) still receives the narrower type the interface's `@param` describes. That type is now used inside the method body. An override that deliberately names a different type keeps its own. @@ -684,9 +687,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A replace on a string comes back a string, and one on an array comes back an array.** `preg_replace()`, `preg_replace_callback()`, `preg_filter()`, `str_replace()`, `str_ireplace()` and `substr_replace()` return whatever shape their subject was, but their signatures can only name the flat union of both overloads, so every call was read as `array|string` no matter what it was handed. Passing the result of a replace on a plain string straight into a `string` parameter or returning it from a `string` function was reported for an array branch the call could never take, which was the single largest source of argument and return mismatches in real code. Each of them now resolves against the subject at the call site: a string subject rules the array branch out, an array subject rules the string branch out and keeps the keys it was given, and a subject whose shape is genuinely unknown still reports both, since that is all the call can promise. `preg_replace()`'s `null` error result survives for a string subject, where PHP really can return it, and is dropped for an array subject, where it cannot. - **`json_encode()` with `JSON_THROW_ON_ERROR` can no longer be `false`.** The flag is how modern code asks for a `JsonException` instead of a silent `false`, and the declared `string|false` return type has no way to say so, so every such call was still read as possibly `false`: handing the result to a `string` parameter, returning it, or concatenating it were all reported for a branch the flag had already ruled out. The flag is now read at the call site, whether it is passed on its own, OR-ed together with other JSON flags such as `JSON_PRETTY_PRINT`, written as a plain number, or held in a constant. A call that leaves the flag out, or whose flags cannot be read, keeps the failure branch, because there it is real. - **A cast argument is read as the type it casts to.** `(string) $customer->mobile` is a `string` whatever the property holds, but an argument written that way resolved to nothing at all, so anything that reads a call's arguments to work out its return type was left guessing: a `@template` bound from that argument stayed unbound, and a return type that depends on the argument fell back to naming every branch at once. Every cast now answers with what it produces, so `str_replace('a', 'b', (string) $value)` is a `string` and not the string-or-array union its signature also allows. -- **A parameter default written `self::SOME_CONST` resolves against the class that declares it, not the caller's.** An omitted argument is decided by its parameter's declared default, and when that default names a class constant through `self::`, `static::`, or `parent::`, the keyword was resolved against the class the *call* sits in rather than the class that wrote the default. `ContainerInterface::get(string $id, int $invalidBehavior = self::EXCEPTION_ON_INVALID_REFERENCE)` looked the constant up on whatever class happened to call `get()`, found nothing there, and left the conditional return type undecided, so `$container->get(Service::class)` reported `Service|object|null` instead of the branch the default actually selects. The keyword now resolves against the method's own declaring class, which is where it was written. +- **A parameter default written `self::SOME_CONST` resolves against the class that declares it, not the caller's.** An omitted argument is decided by its parameter's declared default, and when that default names a class constant through `self::`, `static::`, or `parent::`, the keyword was resolved against the class the _call_ sits in rather than the class that wrote the default. `ContainerInterface::get(string $id, int $invalidBehavior = self::EXCEPTION_ON_INVALID_REFERENCE)` looked the constant up on whatever class happened to call `get()`, found nothing there, and left the conditional return type undecided, so `$container->get(Service::class)` reported `Service|object|null` instead of the branch the default actually selects. The keyword now resolves against the method's own declaring class, which is where it was written. - **A class named through a whole-namespace import is the same class as its fully-qualified name.** Importing a namespace once (`use App\Support;`) and then writing `Support\Pen` wherever the class is wanted is a common alternative to one `use` per class, and PHPantom kept that spelling as a type of its own rather than resolving it to `App\Support\Pen`. A generic bound from two arguments that named the class both ways bound the union of the two spellings instead of the one class, so a closure whose parameter and return type were written that way was reported as not satisfying the `callable(...)` it was passed to. A `::class` written that way lost its namespace entirely once it travelled to a factory in another file, which then either failed to resolve the class or, worse, silently found an unrelated class of the same short name in the factory's own namespace and reported every member read off the result. All of these now resolve to the class the name actually refers to. -- **`@param-closure-this` resolves its class from the file that declares it.** The tag names the class a callback's `$this` is bound to, and PHPantom looked that name up in the *calling* file's imports instead of the declaring method's. A caller never spells the class itself, since only the tag mentions it, so unless the caller happened to import it too the tag resolved to nothing, `$this` silently fell back to the enclosing class, and every member read inside the callback was reported as unknown. The tag is now resolved where it is written, the same as every other docblock type on the method. +- **`@param-closure-this` resolves its class from the file that declares it.** The tag names the class a callback's `$this` is bound to, and PHPantom looked that name up in the _calling_ file's imports instead of the declaring method's. A caller never spells the class itself, since only the tag mentions it, so unless the caller happened to import it too the tag resolved to nothing, `$this` silently fell back to the enclosing class, and every member read inside the callback was reported as unknown. The tag is now resolved where it is written, the same as every other docblock type on the method. - **A builder method chained on an Eloquent relation stays on the relation.** `$this->belongsTo(Author::class)->withTrashed()` was inferred as returning `Builder`, so a method declared `: BelongsTo` (the standard Laravel signature) got a false `type_mismatch_return`, and on a large Laravel codebase this one pattern accounted for most of them. At runtime a relation forwards the call to its query builder and hands back the relation whenever the builder came back, so the chain never leaves the relation. Scope methods, `@method` virtual methods (such as `withTrashed` from `SoftDeletes`), and `where{Column}()` methods reached through a relation now carry the relation as their return type, including when the model uses a custom builder subclass, and the rest of the chain resolves off it as before. Closes #354. - **A `float` reaching an `int` position outside `declare(strict_types=1)` is no longer reported.** `int / int` resolves to `int|float`, which is correct, but a file with no `strict_types` declaration coerces the float half of that union on the way in rather than raising a `TypeError`, so `$this->timeout = $max / 300` against a typed `int $timeout` property, and `return $length / 86400;` from a function declared `: int`, were both reported for a branch PHP itself accepts. A `float` argument, return value, or property assignment is now accepted the same way `numeric` and `string` already are outside `strict_types`. Under `declare(strict_types=1)` PHP really does refuse a float, but it also keeps `int / int` an int unless the division does not come out even, which is a property of the two values rather than of their types, so no annotation on the operands could rule the float out and the only way to quiet the report was a cast that changes what the code does. The union the operator produces is now treated the way the standard library's unchecked failure branches already are, so one branch fitting the target is enough. The type is unchanged everywhere else: hover still reads `int|float`, and `is_float()` still narrows it. This is the operator's own union and not unions at large, so a declared `int|float` still has to fit an `int` whole. - **`int ** int` carries the same benevolent `int|float` union as division.** PHP promotes exponentiation to a `float` on overflow (`2 ** 64`) or a negative exponent (`2 ** -1`), a property of the operand values rather than their types, so `takes_int($base ** $exp)` and `$base **= $exp` under `declare(strict_types=1)` were reported the same way plain division was before the fix above. The result is now treated with the same benevolence: one branch fitting the target is enough, and a declared `int|float` or an exponentiation of one still has to fit whole. @@ -742,7 +745,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`compact()` strings are linked to local variables.** A string argument to `compact('user')` is now treated as a reference to the matching local variable. Renaming the variable updates the string (and renaming from the string updates the variable and its other uses), find-references includes the string, and go-to-definition on the string jumps to the variable's assignment. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/159. - **Imported and same-namespace symbols rank first in completion.** Classes, functions, and constants that are already imported via a `use` statement or live in the same namespace now always appear above non-imported symbols in the completion list, regardless of dependency provenance. Previously a non-imported project class could outrank an already-imported vendor class, forcing users to scroll past irrelevant results. Contributed by @calebdw. - **Laravel route controller method navigation and completion.** Method-name strings inside `Route::controller(X::class)->group(fn(){…})` closures now resolve as references to the controller's methods. Go-to-definition, find-references, rename, hover, and diagnostics all work on the action string (e.g. `Route::patch('cancel', 'cancel')` resolves `'cancel'` to `WorkItemController::cancel()`). Autocompletion inside the action string offers the controller's methods. Handles `->controller()` anywhere in the fluent chain, chained route calls (`->name()`, etc.), and nested groups where an inner `->controller()` shadows the outer one. Contributed by @calebdw. -- **Package provenance displayed in hover.** Hovering over a class, method, property, constant, or function now shows a colored badge indicating where the symbol comes from: 🟢 for direct Composer dependencies (e.g. `laravel/framework`), 🟠 for transitive dependencies with an italic *(transitive)* marker, and 🟣 for PHP core/extension symbols. Project-local symbols show no badge. The package name is resolved from `vendor/composer/installed.json`. Closes #228. Contributed by @calebdw. +- **Package provenance displayed in hover.** Hovering over a class, method, property, constant, or function now shows a colored badge indicating where the symbol comes from: 🟢 for direct Composer dependencies (e.g. `laravel/framework`), 🟠 for transitive dependencies with an italic _(transitive)_ marker, and 🟣 for PHP core/extension symbols. Project-local symbols show no badge. The package name is resolved from `vendor/composer/installed.json`. Closes #228. Contributed by @calebdw. - **Diagnostic ignore rules in `.phpantom.toml`.** A new `[[diagnostics.ignore]]` config section suppresses matching diagnostics project-wide, similar to PHPStan's `ignoreErrors`. Each rule can constrain by file path (glob), message (regex), and/or diagnostic code, so a project can silence known-noisy paths (test fixtures, vendored code with unavailable stubs) without editor-only `@phpantom-ignore` comments scattered through the codebase. - **Built-in formatter respects `mago.toml`.** When formatting falls back to the embedded formatter, a `mago.toml` at the workspace root is now honoured, applying its `[formatter]` preset and settings instead of the PER-CS 2.0 defaults. Contributed by @enwi in https://github.com/PHPantom-dev/phpantom_lsp/pull/233. - **Rename updates `$param` in conditional return types.** Renaming a function parameter now also renames references to that parameter inside PHPDoc conditional return type annotations (`@return ($param is true ? T : U)`), including nested conditionals. Previously the `@param` tag and function body were updated but the `@return` conditional was left stale. Contributed by @calebdw. diff --git a/examples/php/completion.php b/examples/php/completion.php index 22105a7ec..b30e3a96e 100644 --- a/examples/php/completion.php +++ b/examples/php/completion.php @@ -4925,6 +4925,27 @@ public function demo(): void $this->getDefaultDriver(); // resolves Router::getDefaultDriver() }); + // A union binding offers members from both possible contexts. + $router->eachContext(function () { + // Try: $this-> offers Route::prefix() and Resource::only(). + if ($this instanceof Scaffolding\ScaffoldingClosureThisRoute) { + $this->prefix('/union'); // narrowed to Route + } else { + $this->only('index'); // narrowed to Resource + } + }); + + $router->eachContext(function () { + // Try: self::next()-> offers members from both contexts. + self::next()->withContext(function () { + // The nested callback keeps both possible bindings too. + if ($this instanceof Scaffolding\ScaffoldingClosureThisRoute) { + return; + } + $this->only('index'); // early return leaves only Resource + }); + }); + // The tag names the base class, so an assertion is how a closure // body says which subclass it was actually bound to. Narrowing // refines the tag rather than being overruled by it. diff --git a/examples/php/scaffolding/assertions.php b/examples/php/scaffolding/assertions.php index 8ef110df3..b7925ea9f 100644 --- a/examples/php/scaffolding/assertions.php +++ b/examples/php/scaffolding/assertions.php @@ -1619,6 +1619,35 @@ function runDemoAssertions(): void $ctExt = $ctRouter->extend('redis', function () {}); assert($ctExt instanceof Scaffolding\ScaffoldingClosureThisRouter, 'Router::extend() must return self'); + $ctContexts = []; + $ctRouter->eachContext(function () use (&$ctContexts) { + if ($this instanceof Scaffolding\ScaffoldingClosureThisRoute) { + $ctContexts[] = $this->prefix('/union'); + } else { + $ctContexts[] = $this->only('index'); + } + }); + assert(count($ctContexts) === 2, 'eachContext() must invoke both union alternatives'); + assert($ctContexts[0] instanceof Scaffolding\ScaffoldingClosureThisRoute); + assert($ctContexts[1] instanceof Scaffolding\ScaffoldingClosureThisResource); + + $ctChainedContexts = []; + $ctRemainingContexts = []; + $ctRouter->eachContext(function () use (&$ctChainedContexts, &$ctRemainingContexts) { + $ctChainedContexts[] = self::next(); + self::next()->withContext(function () use (&$ctRemainingContexts) { + if ($this instanceof Scaffolding\ScaffoldingClosureThisRoute) { + return; + } + $ctRemainingContexts[] = $this->only('index'); + }); + }); + assert(count($ctChainedContexts) === 2); + assert($ctChainedContexts[0] instanceof Scaffolding\ScaffoldingClosureThisRoute); + assert($ctChainedContexts[1] instanceof Scaffolding\ScaffoldingClosureThisResource); + assert(count($ctRemainingContexts) === 1, 'early return must exclude the Route binding'); + assert($ctRemainingContexts[0] instanceof Scaffolding\ScaffoldingClosureThisResource); + // Nested @param-closure-this: the innermost binding is the one in // effect, and the inner call's receiver is the outer binding. $ctInner = null; diff --git a/examples/php/scaffolding/scaffolding.php b/examples/php/scaffolding/scaffolding.php index ac41302c8..3e8955be9 100644 --- a/examples/php/scaffolding/scaffolding.php +++ b/examples/php/scaffolding/scaffolding.php @@ -948,6 +948,14 @@ public function through(array $pipes): static { return $this; } // binds the callback with Closure::call() so the runtime matches the tag. class ScaffoldingClosureThisRoute { + public static function next(): self { return new self(); } + + /** @param-closure-this self $callback */ + public function withContext(\Closure $callback): void + { + $callback->call($this); + } + public function middleware(string $m): self { return $this; } public function prefix(string $p): self { return $this; } @@ -962,6 +970,14 @@ public function resource(string $name, \Closure $callback): void class ScaffoldingClosureThisResource { + public static function next(): self { return new self(); } + + /** @param-closure-this self $callback */ + public function withContext(\Closure $callback): void + { + $callback->call($this); + } + public function only(string $action): self { return $this; } } @@ -984,6 +1000,15 @@ public function group(\Closure $callback): void $callback->call(new ScaffoldingClosureThisRoute()); } + /** + * @param-closure-this ScaffoldingClosureThisRoute|ScaffoldingClosureThisResource $callback + */ + public function eachContext(\Closure $callback): void + { + $callback->call(new ScaffoldingClosureThisRoute()); + $callback->call(new ScaffoldingClosureThisResource()); + } + /** * Declares the base class but binds a subclass, the way Pest's `test()` * declares `PHPUnit\Framework\TestCase` and binds whatever diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs index 2ade50c39..b7b3cc70d 100644 --- a/src/definition/resolve.rs +++ b/src/definition/resolve.rs @@ -308,9 +308,29 @@ impl Backend { .map(|loc| vec![loc]) } - SymbolKind::SelfStaticParent(ssp_kind) => self - .resolve_self_static_parent(uri, content, position, *ssp_kind) - .map(|loc| vec![loc]), + SymbolKind::SelfStaticParent(ssp_kind) => { + if *ssp_kind == SelfStaticParentKind::This + && let Some(classes) = + self.resolve_closure_this_override(uri, content, cursor_offset) + { + return Some( + classes + .iter() + .filter_map(|class| { + self.resolve_class_reference( + uri, + content, + &class.fqn(), + true, + cursor_offset, + ) + }) + .collect(), + ); + } + self.resolve_self_static_parent(uri, content, position, *ssp_kind) + .map(|loc| vec![loc]) + } SymbolKind::ClassReference { name, is_fqn, .. } => self .resolve_class_reference(uri, content, name, *is_fqn, cursor_offset) @@ -1169,22 +1189,6 @@ impl Backend { ssp_kind, SelfStaticParentKind::Self_ | SelfStaticParentKind::Static | SelfStaticParentKind::This ) { - // For `$this`, check `@param-closure-this` override first: - // when the cursor is inside a closure whose enclosing call - // site declares `@param-closure-this`, jump to the - // overridden class definition instead of the lexical class. - if ssp_kind == SelfStaticParentKind::This - && let Some(override_cls) = - self.resolve_closure_this_override(uri, content, cursor_offset) - { - let fqn = override_cls.fqn(); - if let Some(loc) = - self.resolve_class_reference(uri, content, &fqn, true, cursor_offset) - { - return Some(loc); - } - } - // Jump to the enclosing class definition in the current file. if current_class.keyword_offset == 0 { return None; diff --git a/src/definition/type_definition.rs b/src/definition/type_definition.rs index 0010df790..32630965a 100644 --- a/src/definition/type_definition.rs +++ b/src/definition/type_definition.rs @@ -123,10 +123,13 @@ impl Backend { .map(|cc| vec![PhpType::named(atom(cc.name.as_ref()))]) .unwrap_or_default(), SelfStaticParentKind::This => { - if let Some(override_cls) = + if let Some(override_classes) = self.resolve_closure_this_override(uri, content, offset) { - vec![PhpType::named(override_cls.fqn())] + override_classes + .into_iter() + .map(|class| PhpType::named(class.fqn())) + .collect() } else { current_class .map(|cc| vec![PhpType::named(atom(cc.name.as_ref()))]) diff --git a/src/hover/mod.rs b/src/hover/mod.rs index 27ef35da1..47f21ba7a 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -450,9 +450,26 @@ impl Backend { SelfStaticParentKind::Self_ | SelfStaticParentKind::Static => { current_class.cloned() } - SelfStaticParentKind::This => self - .resolve_closure_this_override(uri, content, cursor_offset) - .or_else(|| current_class.cloned()), + SelfStaticParentKind::This => { + if let Some(classes) = + self.resolve_closure_this_override(uri, content, cursor_offset) + { + if classes.len() > 1 { + let ty = PhpType::union( + classes + .iter() + .map(|class| PhpType::named(class.fqn())) + .collect(), + ); + return Some(make_hover(format!( + "```php\n current_class .and_then(|cc| cc.parent_class.as_ref()) .and_then(|parent_name| { diff --git a/src/resolution.rs b/src/resolution.rs index 510733536..2d05420d5 100644 --- a/src/resolution.rs +++ b/src/resolution.rs @@ -1488,7 +1488,7 @@ impl Backend { /// Check whether `cursor_offset` is inside a closure whose /// enclosing call site declares `@param-closure-this`, and if so - /// return the overridden class. + /// return the overridden class alternatives. /// /// This is a convenience wrapper that builds the [`ResolutionCtx`] /// and calls [`find_closure_this_override`] so that callers (hover, @@ -1499,7 +1499,7 @@ impl Backend { uri: &str, content: &str, cursor_offset: u32, - ) -> Option { + ) -> Option>> { use crate::class_lookup::find_class_at_offset; use crate::type_engine::resolver::ResolutionCtx; @@ -1510,13 +1510,13 @@ impl Backend { let target = self.facade_macro_concrete(&target).unwrap_or(target); if let Some(class) = self.find_or_load_class(&target) { let class_loader = self.class_loader(&ctx); - return Some(Arc::unwrap_or_clone( + return Some(vec![ crate::virtual_members::resolve_class_fully_maybe_cached( &class, &class_loader, Some(&self.resolved_class_cache), ), - )); + ]); } } let current_class = find_class_at_offset(&ctx.classes, cursor_offset); diff --git a/src/type_engine/call_resolution/callable_target.rs b/src/type_engine/call_resolution/callable_target.rs index 3ece86852..2f57bc09a 100644 --- a/src/type_engine/call_resolution/callable_target.rs +++ b/src/type_engine/call_resolution/callable_target.rs @@ -259,7 +259,7 @@ impl Backend { /// Resolve a static class reference + method name to a /// [`ResolvedCallableTarget`]. /// - /// Resolves the class via [`crate::type_engine::resolver::resolve_static_owner_class`], merges + /// Resolves the class via [`crate::type_engine::resolver::resolve_static_owner_classes`], merges /// via `resolve_class_fully`, and looks up `method_name`. fn resolve_static_method_callable( class: &str, @@ -267,8 +267,46 @@ impl Backend { rctx: &ResolutionCtx<'_>, args_text: Option<&str>, ) -> Option { - let owner = crate::type_engine::resolver::resolve_static_owner_class(class, rctx)?; + let owners = crate::type_engine::resolver::resolve_static_owner_classes(class, rctx); + let mut result: Option = None; + let mut returns = Vec::new(); + for owner in &owners { + if let Some(target) = Self::resolve_static_method_callable_for_owner( + class, + method_name, + owner, + rctx, + args_text, + ) { + if owners.len() > 1 + && let Some(ret) = &target.return_type + { + returns.push(ret.replace_self(&owner.fqn())); + } + if result.is_none() { + result = Some(target); + } + } + } + if let Some(target) = &mut result + && !returns.is_empty() + { + target.return_type = if returns.len() == 1 { + returns.pop() + } else { + Some(PhpType::union(returns)) + }; + } + result + } + fn resolve_static_method_callable_for_owner( + class: &str, + method_name: &str, + owner: &Arc, + rctx: &ResolutionCtx<'_>, + args_text: Option<&str>, + ) -> Option { // When the class has template params, try to substitute them with // concrete types. For `parent::` calls, use the child's @extends // generics to get the concrete type arguments. Otherwise fall back @@ -287,16 +325,16 @@ impl Backend { } else { None }; - let args = type_args.unwrap_or_else(|| crate::inheritance::default_type_args(&owner)); + let args = type_args.unwrap_or_else(|| crate::inheritance::default_type_args(owner)); crate::virtual_members::resolve_class_fully_with_type_args( - &owner, + owner, rctx.class_loader, rctx.resolved_class_cache, &args, ) } else { crate::virtual_members::resolve_class_fully_maybe_cached( - &owner, + owner, rctx.class_loader, rctx.resolved_class_cache, ) diff --git a/src/type_engine/call_resolution/return_types.rs b/src/type_engine/call_resolution/return_types.rs index d041aea30..e474d3c98 100644 --- a/src/type_engine/call_resolution/return_types.rs +++ b/src/type_engine/call_resolution/return_types.rs @@ -805,61 +805,13 @@ impl Backend { SubjectExpr::StaticMethodCall { class, method } => { let method_name = method.as_str(); - let owner_class = if class.starts_with('$') { - // Variable holding a class-string (e.g. `$cls::make()`). - // May resolve to multiple classes for union class-strings. - let all_owners: Vec> = ResolvedType::into_arced_classes( - crate::type_engine::resolver::resolve_target_classes( - class, - AccessKind::DoubleColon, - ctx, - ), - ); - // When there are multiple possible classes, resolve the - // method return type through each and union the results. - if all_owners.len() > 1 { - let mut union_results: Vec> = Vec::new(); - for owner in &all_owners { - let split_args = split_text_args(text_args); - let arg_refs = split_args.to_vec(); - let template_subs = Self::build_method_template_subs( - owner, - method_name, - &arg_refs, - ctx, - ); - let var_resolver = build_var_resolver(ctx); - let mr_ctx = MethodReturnCtx { - all_classes: ctx.all_classes, - class_loader: ctx.class_loader, - backend: ctx.backend, - template_subs: &template_subs, - var_resolver: Some(&var_resolver), - cache: ctx.resolved_class_cache, - calling_class_name: ctx.current_class.map(|c| c.name.as_str()), - is_static: true, - call_args: None, - }; - ClassInfo::extend_unique_arc( - &mut union_results, - Self::resolve_method_return_types_with_args( - owner, - method_name, - text_args, - &mr_ctx, - ), - ); - } - if !union_results.is_empty() { - return union_results; - } - } - all_owners.into_iter().next() - } else { - crate::type_engine::resolver::resolve_static_owner_class(class, ctx) - }; - - if let Some(ref owner) = owner_class { + let owners = crate::type_engine::resolver::resolve_static_owner_classes(class, ctx); + let mut results = Vec::new(); + let split_args = split_text_args(text_args); + let arg_refs = split_args.to_vec(); + let var_resolver = build_var_resolver(ctx); + let mut return_hints = Vec::new(); + for owner in &owners { // A static call through a Laravel facade is typed by the // container class the facade forwards to, so that // `App::make(Foo::class)->…` sees the same @@ -886,12 +838,11 @@ impl Backend { ctx.resolved_class_cache, ); - let split_args = split_text_args(text_args); - let arg_refs = split_args.to_vec(); let template_subs = Self::build_method_template_subs(&merged, method_name, &arg_refs, ctx); - if let Some(ref mut hint_out) = return_type_hint_out + let mut owner_hint = None; + if return_type_hint_out.is_some() && let Some(m) = merged.get_method_ci(method_name) && let Some(ref ret) = m.return_type { @@ -922,7 +873,7 @@ impl Backend { } else { substituted }; - **hint_out = Some( + owner_hint = Some( crate::virtual_members::laravel::replace_eloquent_collections_in_type( &resolved_hint, ctx.class_loader, @@ -931,7 +882,6 @@ impl Backend { ); } - let var_resolver = build_var_resolver(ctx); let mr_ctx = MethodReturnCtx { all_classes: ctx.all_classes, class_loader: ctx.class_loader, @@ -946,19 +896,35 @@ impl Backend { if let Some((date_class, date_return_type)) = Self::configured_laravel_date_return(&merged, method_name, ctx.class_loader) { - if let Some(ref mut hint_out) = return_type_hint_out { - **hint_out = Some(date_return_type); + if return_type_hint_out.is_some() { + owner_hint = Some(date_return_type); } - return vec![date_class]; + ClassInfo::push_unique_arc(&mut results, date_class); + } else { + ClassInfo::extend_unique_arc( + &mut results, + Self::resolve_method_return_types_with_args( + &merged, + method_name, + text_args, + &mr_ctx, + ), + ); + } + if let Some(hint) = owner_hint { + return_hints.push(hint); } - return Self::resolve_method_return_types_with_args( - &merged, - method_name, - text_args, - &mr_ctx, - ); } - vec![] + if let Some(hint_out) = return_type_hint_out + && !return_hints.is_empty() + { + *hint_out = if return_hints.len() == 1 { + return_hints.pop() + } else { + Some(PhpType::union(return_hints)) + }; + } + results } // ── Standalone function call: app(…) / myHelper(…) ────── diff --git a/src/type_engine/resolver/mod.rs b/src/type_engine/resolver/mod.rs index 58af7faf9..78f3357a2 100644 --- a/src/type_engine/resolver/mod.rs +++ b/src/type_engine/resolver/mod.rs @@ -306,8 +306,6 @@ fn resolve_target_classes_expr_inner( match expr { // ── Keywords that always mean "current class" ──────────── SubjectExpr::This => { - use crate::type_engine::variable::forward_walk; - // `$this` is not available inside static methods. if current_class.is_some() && ctx.is_in_static_method { return vec![]; @@ -325,32 +323,14 @@ fn resolve_target_classes_expr_inner( // `current_class` below. let from_scope = resolve_this_from_scope(ctx); - // `@param-closure-this` override: when the cursor is inside a - // closure passed as an argument to a function whose parameter - // carries the tag, `$this` is the declared type rather than the - // lexical class. The tag states what the closure is *bound* to, - // so a narrowing proof inside the body still refines it — a - // `assert($this instanceof AppTestCase)` in a Pest closure whose - // `test()` parameter declares `@param-closure-this TestCase` - // means `$this` is the subclass. The scope only wins when it is - // strictly narrower; otherwise it holds the lexically captured - // `$this` the tag is there to replace. - if let Some(override_cls) = - super::variable::closure_resolution::find_closure_this_override(ctx) + // The walker seeds closure bindings before applying guards, so its + // result already includes both rebinding and narrowing. Consult the + // annotation only when no scope was available. + if from_scope.is_none() + && let Some(classes) = + super::variable::closure_resolution::find_closure_this_override(ctx) { - let narrowed = from_scope.filter(|types| { - !types.is_empty() - && types.iter().all(|rt| { - rt.class_info.as_ref().is_some_and(|ci| { - forward_walk::is_subclass_of( - &ci.fqn(), - &override_cls.fqn(), - class_loader, - ) - }) - }) - }); - return narrowed.unwrap_or_else(|| vec![ResolvedType::from_class(override_cls)]); + return ResolvedType::from_classes(classes); } let mut this_types = if let Some(scope_types) = from_scope { @@ -379,10 +359,9 @@ fn resolve_target_classes_expr_inner( this_types } - SubjectExpr::SelfKw | SubjectExpr::StaticKw => resolve_self_static_class(ctx) - .map(ResolvedType::from_class) - .into_iter() - .collect(), + SubjectExpr::SelfKw | SubjectExpr::StaticKw => { + ResolvedType::from_classes(resolve_self_static_classes(ctx)) + } // ── `parent::` — resolve to the current class's parent ── SubjectExpr::Parent => { @@ -423,10 +402,7 @@ fn resolve_target_classes_expr_inner( // class names, so find_class_by_name / class_loader won't // find them. let owner_classes: Vec> = if is_self_or_static(class) { - resolve_self_static_class(ctx) - .map(Arc::new) - .into_iter() - .collect() + resolve_self_static_classes(ctx) } else if let Some(parent_name) = resolve_class_keyword(class, current_class) { // parent — resolve via all_classes first, then class_loader if let Some(cls) = find_class_by_name(all_classes, &parent_name) { @@ -1046,8 +1022,9 @@ fn resolve_call_raw_return_type( None } SubjectExpr::StaticMethodCall { class, method } => { - let owner = resolve_static_owner_class(class, ctx); - if let Some(ref cls) = owner { + let owners = resolve_static_owner_classes(class, ctx); + let mut returns = Vec::new(); + for cls in &owners { let merged = crate::virtual_members::resolve_class_fully_maybe_cached( cls, ctx.class_loader, @@ -1056,12 +1033,17 @@ fn resolve_call_raw_return_type( let found = merged.get_method_ci(method); if let Some(m) = found { if let Some(ref ret) = m.return_type { - return Some(ret.clone()); + returns.push(if owners.len() > 1 { + ret.replace_self(&cls.fqn()) + } else { + ret.clone() + }); + continue; } // Method exists but has no return type. // Only fall through to __callStatic for virtual methods. if !m.is_virtual { - return None; + continue; } } // __callStatic fallback: method not found, or virtual @@ -1069,10 +1051,19 @@ fn resolve_call_raw_return_type( if let Some(m) = merged.get_method_ci("__callStatic") && let Some(ref ret) = m.return_type { - return Some(ret.clone()); + returns.push(if owners.len() > 1 { + ret.replace_self(&cls.fqn()) + } else { + ret.clone() + }); + continue; } } - None + match returns.len() { + 0 => None, + 1 => returns.pop(), + _ => Some(PhpType::union(returns)), + } } SubjectExpr::FunctionCall(fn_name) => { if let Some(fl) = ctx.function_loader @@ -1709,9 +1700,13 @@ fn resolve_variable_fallback( /// runtime binds the closure with the target class as its scope /// (`Closure::bind`), so `self::` and `static::` refer to the bound /// target rather than the class that lexically encloses the closure. -fn resolve_self_static_class(ctx: &ResolutionCtx<'_>) -> Option { - super::variable::closure_resolution::find_closure_this_override(ctx) - .or_else(|| ctx.current_class.cloned()) +fn resolve_self_static_classes(ctx: &ResolutionCtx<'_>) -> Vec> { + super::variable::closure_resolution::find_closure_this_override(ctx).unwrap_or_else(|| { + ctx.current_class + .map(|class| Arc::new(class.clone())) + .into_iter() + .collect() + }) } /// Resolve a static class reference (`self`, `static`, `parent`, or a @@ -1719,28 +1714,21 @@ fn resolve_self_static_class(ctx: &ResolutionCtx<'_>) -> Option { /// /// Handles the `self`/`static`/`parent` keywords and falls back to /// `class_loader` then `resolve_target_classes` for named classes. -pub(in crate::type_engine) fn resolve_static_owner_class( +pub(in crate::type_engine) fn resolve_static_owner_classes( class: &str, rctx: &ResolutionCtx<'_>, -) -> Option> { +) -> Vec> { if is_self_or_static(class) { - resolve_self_static_class(rctx).map(Arc::new) + resolve_self_static_classes(rctx) } else if let Some(resolved_name) = resolve_class_keyword(class, rctx.current_class) { - // parent — load via class_loader so we get the full parent ClassInfo - (rctx.class_loader)(&resolved_name) + (rctx.class_loader)(&resolved_name).into_iter().collect() + } else if let Some(owner) = find_class_by_name(rctx.all_classes, class) + .map(Arc::clone) + .or_else(|| (rctx.class_loader)(class)) + { + vec![owner] } else { - find_class_by_name(rctx.all_classes, class) - .map(Arc::clone) - .or_else(|| (rctx.class_loader)(class)) - .or_else(|| { - resolved_to_arcs(resolve_target_classes( - class, - crate::AccessKind::DoubleColon, - rctx, - )) - .into_iter() - .next() - }) + resolved_to_arcs(resolve_target_classes(class, AccessKind::DoubleColon, rctx)) } } diff --git a/src/type_engine/variable/closure_resolution.rs b/src/type_engine/variable/closure_resolution.rs index ac2b606c6..86aca8fd1 100644 --- a/src/type_engine/variable/closure_resolution.rs +++ b/src/type_engine/variable/closure_resolution.rs @@ -91,14 +91,34 @@ use crate::types::{AccessKind, ClassInfo, FunctionInfo, MethodInfo, ResolvedType /// Check whether the cursor is inside a closure that is passed as an /// argument to a function/method whose parameter carries a /// `@param-closure-this` annotation. If so, resolve the declared type -/// and return it as a `ClassInfo`. +/// and return its class alternatives. /// /// This is the static-analysis equivalent of `Closure::bindTo()`: /// frameworks like Laravel rebind closures so that `$this` inside the /// closure body refers to a different object. The /// `@param-closure-this` PHPDoc tag declares what `$this` should /// resolve to. -pub(crate) fn find_closure_this_override(ctx: &ResolutionCtx<'_>) -> Option { +pub(crate) fn find_closure_this_override(ctx: &ResolutionCtx<'_>) -> Option>> { + find_closure_this_binding(ctx).map(|binding| binding.classes) +} + +/// Resolve only the binding declared for this closure, leaving an inherited +/// (possibly narrowed) capture intact when a nested closure has no own tag. +pub(in crate::type_engine) fn closure_this_binding_at( + ctx: &ResolutionCtx<'_>, + closure_start: u32, +) -> Option>> { + find_closure_this_binding(ctx) + .filter(|binding| binding.closure_start == closure_start) + .map(|binding| binding.classes) +} + +struct ClosureThisBinding { + closure_start: u32, + classes: Vec>, +} + +fn find_closure_this_binding(ctx: &ResolutionCtx<'_>) -> Option { let _guard = ClosureThisGuard::enter(ctx)?; with_parsed_program(ctx.content, "find_closure_this_override", |program, _| { @@ -114,7 +134,10 @@ pub(crate) fn find_closure_this_override(ctx: &ResolutionCtx<'_>) -> Option, ctx: &ResolutionCtx<'_>) -> Option { +fn walk_stmt_for_closure_this( + stmt: &Statement<'_>, + ctx: &ResolutionCtx<'_>, +) -> Option { let sp = stmt.span(); if ctx.cursor_offset < sp.start.offset || ctx.cursor_offset > sp.end.offset { return None; @@ -249,7 +272,10 @@ fn walk_stmt_for_closure_this(stmt: &Statement<'_>, ctx: &ResolutionCtx<'_>) -> /// Walk an expression looking for a call whose closure argument /// contains the cursor and whose parameter has `closure_this_type`. -fn walk_expr_for_closure_this(expr: &Expression<'_>, ctx: &ResolutionCtx<'_>) -> Option { +fn walk_expr_for_closure_this( + expr: &Expression<'_>, + ctx: &ResolutionCtx<'_>, +) -> Option { let sp = expr.span(); if ctx.cursor_offset < sp.start.offset || ctx.cursor_offset > sp.end.offset { return None; @@ -350,7 +376,10 @@ fn walk_expr_for_closure_this(expr: &Expression<'_>, ctx: &ResolutionCtx<'_>) -> /// Walk a call expression, checking each closure/arrow-function argument /// to see if the cursor is inside it and the target parameter has /// `closure_this_type`. -fn walk_call_for_closure_this(call: &Call<'_>, ctx: &ResolutionCtx<'_>) -> Option { +fn walk_call_for_closure_this( + call: &Call<'_>, + ctx: &ResolutionCtx<'_>, +) -> Option { match call { Call::Function(fc) => { let func_name = match fc.function { @@ -482,9 +511,9 @@ fn walk_args_for_closure_this( arguments: &TokenSeparatedSequence<'_, Argument<'_>>, ctx: &ResolutionCtx<'_>, lookup_fn: &F, -) -> Option +) -> Option where - F: Fn(usize) -> Option, + F: Fn(usize) -> Option>>, { for (arg_idx, arg) in arguments.iter().enumerate() { let arg_expr = arg.value(); @@ -511,7 +540,10 @@ where if let Some(nested) = walk_closure_body_for_closure_this(arg_expr, ctx) { return Some(nested); } - return lookup_fn(arg_idx); + return lookup_fn(arg_idx).map(|classes| ClosureThisBinding { + closure_start: arg_span.start.offset, + classes, + }); } } None @@ -527,7 +559,7 @@ where fn walk_closure_body_for_closure_this( arg_expr: &Expression<'_>, ctx: &ResolutionCtx<'_>, -) -> Option { +) -> Option { match arg_expr { Expression::Closure(closure) => { for stmt in closure.body.statements.iter() { @@ -548,7 +580,7 @@ fn closure_this_from_function_params( fi: &FunctionInfo, arg_idx: usize, ctx: &ResolutionCtx<'_>, -) -> Option { +) -> Option>> { let param = fi.parameters.get(arg_idx)?; let php_type = param.closure_this_type.as_ref()?; resolve_closure_this_type(php_type, None, ctx) @@ -627,10 +659,20 @@ fn closure_this_from_receiver( method_name: &str, arg_idx: usize, ctx: &ResolutionCtx<'_>, -) -> Option { +) -> Option>> { let receiver_classes = ResolvedType::into_arced_classes(resolve_receiver_types(obj_start, obj_end, ctx)); - for cls in &receiver_classes { + closure_this_from_owners(&receiver_classes, method_name, arg_idx, ctx) +} + +fn closure_this_from_owners( + owners: &[Arc], + method_name: &str, + arg_idx: usize, + ctx: &ResolutionCtx<'_>, +) -> Option>> { + let mut bindings = Vec::new(); + for cls in owners { let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( cls, ctx.class_loader, @@ -640,10 +682,10 @@ fn closure_this_from_receiver( && let Some(result) = closure_this_from_method_params(method, arg_idx, Some(&resolved), ctx) { - return Some(result); + ClassInfo::extend_unique_arc(&mut bindings, result); } } - None + (!bindings.is_empty()).then_some(bindings) } /// Look up `closure_this_type` on a static method's parameter at @@ -653,77 +695,81 @@ fn closure_this_from_static_receiver( method_name: &str, arg_idx: usize, ctx: &ResolutionCtx<'_>, -) -> Option { - let class_name = static_receiver_class_name(class_expr, ctx.current_class)?; +) -> Option>> { + let class_name = match class_expr { + Expression::Self_(_) | Expression::Static(_) => "self".to_string(), + _ => static_receiver_class_name(class_expr, ctx.current_class)?, + }; if method_name.eq_ignore_ascii_case("macro") && arg_idx == 1 && let Some(resolve_macro_this) = ctx.laravel_macro_this_resolver && let Some(owner) = resolve_macro_this(&class_name) { - return Some(Arc::unwrap_or_clone( + return Some(vec![ crate::virtual_members::resolve_class_fully_maybe_cached( &owner, ctx.class_loader, ctx.resolved_class_cache, ), - )); + ]); } - let owner = find_owner_by_name(&class_name, ctx.all_classes, ctx.class_loader)?; - - let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( - &owner, - ctx.class_loader, - ctx.resolved_class_cache, - ); - let method = resolved.get_method(method_name)?; - closure_this_from_method_params(method, arg_idx, Some(&resolved), ctx) + let receiver_ctx = ResolutionCtx { + cursor_offset: class_expr.span().start.offset, + ..*ctx + }; + let owners = + crate::type_engine::resolver::resolve_static_owner_classes(&class_name, &receiver_ctx); + closure_this_from_owners(&owners, method_name, arg_idx, ctx) } /// Extract `closure_this_type` from a method's parameter at `arg_idx` -/// and resolve it to a `ClassInfo`. +/// and resolve its class alternatives. fn closure_this_from_method_params( method: &MethodInfo, arg_idx: usize, owner: Option<&ClassInfo>, ctx: &ResolutionCtx<'_>, -) -> Option { +) -> Option>> { let param = method.parameters.get(arg_idx)?; let php_type = param.closure_this_type.as_ref()?; resolve_closure_this_type(php_type, owner, ctx) } -/// Resolve a raw `@param-closure-this` type string to a `ClassInfo`. -/// -/// Handles `$this`, `static`, and `self` by mapping them to the -/// declaring class (owner), and resolves fully-qualified class names -/// through the class loader. +/// Resolve the declared binding through the shared type resolver, preserving +/// union alternatives and resolving relative types against the receiver. fn resolve_closure_this_type( php_type: &PhpType, owner: Option<&ClassInfo>, ctx: &ResolutionCtx<'_>, -) -> Option { +) -> Option>> { + let owner = owner.or(ctx.current_class); if php_type.is_self_like() { - return owner.cloned().or_else(|| ctx.current_class.cloned()); + return owner.map(|class| vec![Arc::new(class.clone())]); } - - // Extract the base class name without stringifying. - let type_str = php_type.base_name()?; - - // Try local classes first, then the cross-file loader. - if let Some(cls) = ctx.all_classes.iter().find(|c| c.name == type_str) { - return Some(ClassInfo::clone(cls)); + let owner_name = owner.map(ClassInfo::fqn).unwrap_or_default(); + let classes = crate::type_engine::type_resolution::type_hint_to_classes_typed( + php_type, + &owner_name, + ctx.all_classes, + ctx.class_loader, + ); + if classes.is_empty() { + return None; } - - let resolved = (ctx.class_loader)(type_str)?; - Some(Arc::unwrap_or_clone( - crate::virtual_members::resolve_class_fully_maybe_cached( - &resolved, - ctx.class_loader, - ctx.resolved_class_cache, - ), - )) + Some( + classes + .into_iter() + .map(|class| { + crate::virtual_members::resolve_class_fully_maybe_cached( + &class, + ctx.class_loader, + ctx.resolved_class_cache, + ) + }) + .collect(), + ) } /// Check whether the inferred callable-signature type is a more specific diff --git a/src/type_engine/variable/forward_walk/assignment.rs b/src/type_engine/variable/forward_walk/assignment.rs index f5d4058c5..82d2d5ca9 100644 --- a/src/type_engine/variable/forward_walk/assignment.rs +++ b/src/type_engine/variable/forward_walk/assignment.rs @@ -904,6 +904,13 @@ pub(crate) fn process_by_ref_closure_capture<'b>( let mut closure_scope = ScopeState::new(); seed_closure_captures(&mut closure_scope, scope, closure.use_clause.as_ref()); + seed_closure_this( + &mut closure_scope, + Some(scope), + closure.span().start.offset, + closure.body.span().start.offset, + ctx, + ); seed_closure_params( &mut closure_scope, diff --git a/src/type_engine/variable/forward_walk/closures.rs b/src/type_engine/variable/forward_walk/closures.rs index 72f7d7dd6..83d391d5e 100644 --- a/src/type_engine/variable/forward_walk/closures.rs +++ b/src/type_engine/variable/forward_walk/closures.rs @@ -156,6 +156,36 @@ pub(crate) fn seed_closure_captures( } } +/// Install a closure's declared `$this` before walking its guards. The +/// receiving call is resolved against the outer scope, where its variables +/// still refer to their values before entering the callback. +pub(crate) fn seed_closure_this( + scope: &mut ScopeState, + outer: Option<&ScopeState>, + closure_start: u32, + body_start: u32, + ctx: &ForwardWalkCtx<'_>, +) { + let outer = outer.unwrap_or(scope); + let scope_resolver = |name: &str| outer.get(name).to_vec(); + let macro_resolver = ctx + .backend + .map(|backend| backend.laravel_macro_this_resolver(ctx.class_loader)); + let rctx = crate::type_engine::resolver::ResolutionCtx { + cursor_offset: body_start, + scope_var_resolver: Some(&scope_resolver), + laravel_macro_this_resolver: macro_resolver.as_ref().map(|resolver| resolver as &_), + ..ctx.as_resolution_ctx() + }; + if let Some(classes) = + super::super::closure_resolution::closure_this_binding_at(&rctx, closure_start) + { + scope.invalidate_dependent_keys("$this"); + scope.invalidate_proofs("$this"); + scope.set("$this", ResolvedType::from_classes(classes)); + } +} + /// Try to enter a closure or arrow function if the cursor is inside one. /// /// Returns `true` if the cursor was inside a closure and the scope was @@ -245,6 +275,13 @@ pub(crate) fn try_enter_closure_expr<'b>( let mut closure_scope = ScopeState::new(); seed_closure_captures(&mut closure_scope, scope, closure.use_clause.as_ref()); + seed_closure_this( + &mut closure_scope, + Some(scope), + closure.span().start.offset, + body_span.start.offset, + ctx, + ); // Seed with parameter types, using callable inference // when available. @@ -273,6 +310,13 @@ pub(crate) fn try_enter_closure_expr<'b>( && ctx.cursor_offset <= body_span.end.offset { // Arrow functions inherit the enclosing scope. + seed_closure_this( + scope, + None, + arrow.span().start.offset, + arrow.arrow.start.offset, + ctx, + ); // Seed with parameter types, using callable inference // when available. let inferred = inferred_params.unwrap_or(&[]); diff --git a/src/type_engine/variable/forward_walk/diagnostic_walk.rs b/src/type_engine/variable/forward_walk/diagnostic_walk.rs index c3804fe41..4fd4e59f7 100644 --- a/src/type_engine/variable/forward_walk/diagnostic_walk.rs +++ b/src/type_engine/variable/forward_walk/diagnostic_walk.rs @@ -159,6 +159,13 @@ pub(crate) fn walk_closures_in_expr<'b>( // `resolve_variable_types` and a captured path keeps whatever // the code above the closure proved about it. seed_closure_captures(&mut closure_scope, outer_scope, closure.use_clause.as_ref()); + seed_closure_this( + &mut closure_scope, + Some(outer_scope), + closure.span().start.offset, + closure.body.span().start.offset, + ctx, + ); // Seed with parameter types, using callable inference when // available. Filter out any inferred params whose base @@ -199,6 +206,13 @@ pub(crate) fn walk_closures_in_expr<'b>( Expression::ArrowFunction(arrow) => { // Arrow functions inherit the enclosing scope. let mut arrow_scope = outer_scope.clone(); + seed_closure_this( + &mut arrow_scope, + Some(outer_scope), + arrow.span().start.offset, + arrow.arrow.start.offset, + ctx, + ); // Seed with parameter types, using callable inference when // available. diff --git a/src/type_engine/variable/rhs_resolution/calls.rs b/src/type_engine/variable/rhs_resolution/calls.rs index 5e33130fe..dece991ca 100644 --- a/src/type_engine/variable/rhs_resolution/calls.rs +++ b/src/type_engine/variable/rhs_resolution/calls.rs @@ -2496,8 +2496,32 @@ pub(super) fn resolve_rhs_static_call( ctx: &VarResolutionCtx<'_>, ) -> Vec { // `Cls::{$expr}()` / `Cls::$name()` — see `runtime_named_member_type`. - if !matches!(static_call.method, ClassLikeMemberSelector::Identifier(_)) { + let ClassLikeMemberSelector::Identifier(ident) = &static_call.method else { return super::runtime_named_member_type(); + }; + + if matches!( + static_call.class, + Expression::Self_(_) | Expression::Static(_) + ) { + let rctx = ctx.as_resolution_ctx(); + let owners = crate::type_engine::resolver::resolve_static_owner_classes("self", &rctx); + let method_name = bytes_to_str(ident.value); + let mut results = Vec::new(); + for owner in owners { + ResolvedType::extend_unique( + &mut results, + resolve_rhs_static_call_on_owner( + &owner, + method_name, + static_call, + true, + &owner, + ctx, + ), + ); + } + return results; } let current_class_name: &str = &ctx.current_class.name; @@ -2689,85 +2713,95 @@ pub(super) fn resolve_rhs_static_call( .map(|c| ClassInfo::clone(c)) }); if let Some(ref owner) = owner { - let concrete_owner = crate::type_engine::call_resolution::facade_concrete_owner( - owner, - &method_name, - ctx.class_loader, - ctx.resolved_class_cache, - ctx.backend, - ); - let owner = concrete_owner.as_ref().unwrap_or(owner); - - if let Some(result) = try_resolve_config_method_type( - &owner.fqn(), - &method_name, - &static_call.argument_list, - ctx, - ) { - return result; - } - - if let Some(result) = try_resolve_trans_method_type( - &owner.fqn(), - &method_name, - &static_call.argument_list, - ctx, - ) { - return result; - } - - let arg_texts = - crate::type_engine::variable::raw_type_inference::extract_arg_texts_from_ast( - &static_call.argument_list, - ctx.content, - ); - let arg_refs: Vec<&str> = arg_texts.iter().map(|s| s.as_str()).collect(); - let rctx = ctx.as_resolution_ctx(); - let template_subs = - Backend::build_method_template_subs(owner, &method_name, &arg_refs, &rctx); - let owner_key = owner.fqn(); - // An explicit `A::` on a non-static method is PHP's pre-8 - // instance-forwarding form, which keeps `$this` (and with it late - // static binding) bound, so only a `static` method written out - // fixes the class. - let target_is_static = method_is_static(owner, &method_name, ctx); - let lsb_class = (forwards_lsb || !target_is_static).then(|| ctx.current_class.fqn()); - let self_replace = - |ty: &PhpType| ty.replace_self_bound(&owner_key, lsb_class.as_deref()); - - let mut results = resolve_owner_method_call( + return resolve_rhs_static_call_on_owner( owner, &method_name, - &static_call.argument_list, + static_call, + forwards_lsb, + ctx.current_class, ctx, - true, - &template_subs, - &self_replace, ); - // `Model::factory(…)`, `UserFactory::times(3)` and - // `UserFactory::new()` open a factory chain, and what they - // were opened with is what the `create()` at the far end of - // it builds. `factory($count)` only settles that once its - // argument is resolved, which is why the type is fetched - // lazily rather than for every static call in the file. - let first_arg_type = || { - let arg = static_call.argument_list.arguments.first()?; - let resolved = resolve_rhs_expression(arg.value(), ctx); - (!resolved.is_empty()).then(|| ResolvedType::types_joined(&resolved)) - }; - crate::virtual_members::laravel::tag_static_factory_call( - &mut results, - &method_name, - arg_refs.first().copied(), - &first_arg_type, - &rctx, - ); - return results; } } vec![] } +/// Apply static-call return inference to one resolved owner. +fn resolve_rhs_static_call_on_owner( + owner: &ClassInfo, + method_name: &str, + static_call: &StaticMethodCall<'_>, + forwards_lsb: bool, + calling_class: &ClassInfo, + ctx: &VarResolutionCtx<'_>, +) -> Vec { + let concrete_owner = crate::type_engine::call_resolution::facade_concrete_owner( + owner, + method_name, + ctx.class_loader, + ctx.resolved_class_cache, + ctx.backend, + ); + let owner = concrete_owner.as_ref().unwrap_or(owner); + + if let Some(result) = + try_resolve_config_method_type(&owner.fqn(), method_name, &static_call.argument_list, ctx) + { + return result; + } + + if let Some(result) = + try_resolve_trans_method_type(&owner.fqn(), method_name, &static_call.argument_list, ctx) + { + return result; + } + + let arg_texts = crate::type_engine::variable::raw_type_inference::extract_arg_texts_from_ast( + &static_call.argument_list, + ctx.content, + ); + let arg_refs: Vec<&str> = arg_texts.iter().map(|s| s.as_str()).collect(); + let rctx = ctx.as_resolution_ctx(); + let template_subs = Backend::build_method_template_subs(owner, method_name, &arg_refs, &rctx); + let owner_key = owner.fqn(); + // An explicit `A::` on a non-static method is PHP's pre-8 + // instance-forwarding form, which keeps `$this` (and with it late + // static binding) bound, so only a `static` method written out + // fixes the class. + let target_is_static = method_is_static(owner, method_name, ctx); + let lsb_class = (forwards_lsb || !target_is_static).then(|| calling_class.fqn()); + let self_replace = |ty: &PhpType| ty.replace_self_bound(&owner_key, lsb_class.as_deref()); + + let mut results = resolve_owner_method_call( + owner, + method_name, + &static_call.argument_list, + ctx, + true, + &template_subs, + &self_replace, + ); + // `Model::factory(…)`, `UserFactory::times(3)` and + // `UserFactory::new()` open a factory chain, and what they + // were opened with is what the `create()` at the far end of + // it builds. `factory($count)` only settles that once its + // argument is resolved, which is why the type is fetched + // lazily rather than for every static call in the file. + let first_arg_type = || { + let arg = static_call.argument_list.arguments.first()?; + let resolved = resolve_rhs_expression(arg.value(), ctx); + (!resolved.is_empty()).then(|| ResolvedType::types_joined(&resolved)) + }; + crate::virtual_members::laravel::tag_static_factory_call( + &mut results, + method_name, + arg_refs.first().copied(), + &first_arg_type, + &rctx, + ); + results +} + /// The array shape a Laravel `validated()` / `validate()` / /// `safe()->only()` call assigns, given the validation rules in scope. /// diff --git a/tests/integration/completion_param_closure_this.rs b/tests/integration/completion_param_closure_this.rs index f5629c0a9..d2a2ceba5 100644 --- a/tests/integration/completion_param_closure_this.rs +++ b/tests/integration/completion_param_closure_this.rs @@ -1028,3 +1028,343 @@ fn test_extract_param_closure_this_coexists_with_param() { (PhpType::parse("$this"), "$callback".to_string()) ); } + +#[tokio::test] +async fn param_closure_this_union_members_and_chains() { + for call in ["bindContext", "(new Binder())->bind", "Binder::bind"] { + for body in [ + "function () { $this->MARK; }", + "fn() => $this->MARK", + "function () { $this->next()->MARK; }", + ] { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union.php").unwrap(); + let src = format!( + r#"MARK;", + "firstOnly", + "secondOnly", + ), + ( + "bindInner(function () { $this->MARK; });", + "innerOnly", + "firstOnly", + ), + ] { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union_scope.php").unwrap(); + let src = format!( + r#"bind(function () { $this->\n});\n"; + let items = complete_at( + &backend, + &uri, + src, + 2, + src.lines().nth(2).unwrap().len() as u32, + ) + .await; + let names = method_names(&items); + assert!(names.contains(&"firstOnly"), "{names:?}"); + assert!(names.contains(&"secondOnly"), "{names:?}"); +} + +#[tokio::test] +async fn param_closure_this_union_relative_and_unresolved_members() { + for annotation in [ + "self|OtherContext", + "static|OtherContext", + "$this|OtherContext", + "Binder|OtherContext|MissingContext", + ] { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union_relative.php").unwrap(); + let src = format!( + r#"bind(function () {{ $this->MARK; }}); +"# + ); + let position = marker_position(&src); + let src = src.replace("MARK", ""); + let items = complete_at(&backend, &uri, &src, position.line, position.character).await; + let names = method_names(&items); + assert!(names.contains(&"ownerOnly"), "{annotation}: {names:?}"); + assert!(names.contains(&"otherOnly"), "{annotation}: {names:?}"); + } +} + +fn marker_position(src: &str) -> Position { + let (line, text) = src + .lines() + .enumerate() + .find(|(_, line)| line.contains("MARK")) + .unwrap(); + Position::new(line as u32, text.find("MARK").unwrap() as u32) +} + +#[tokio::test] +async fn param_closure_this_union_does_not_narrow_to_lexical_member() { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union_lexical_member.php").unwrap(); + let src = r#"MARK; }); + } +} +class SecondContext { public function secondOnly(): void {} } +/** @param-closure-this FirstContext|SecondContext $callback */ +function bindContext(\Closure $callback): void {} +"#; + let position = marker_position(src); + let src = src.replace("MARK", ""); + let items = complete_at(&backend, &uri, &src, position.line, position.character).await; + let names = method_names(&items); + assert!(names.contains(&"firstOnly"), "{names:?}"); + assert!(names.contains(&"secondOnly"), "{names:?}"); +} + +#[tokio::test] +async fn param_closure_this_union_guards() { + for lexical in ["Host", "FirstContext"] { + for (body, expected, absent) in [ + ( + "assert($this instanceof FirstContext); $this->MARK;", + "firstOnly", + "secondOnly", + ), + ( + "if ($this instanceof FirstContext) {} else { $this->MARK; }", + "secondOnly", + "firstOnly", + ), + ( + "if ($this instanceof FirstContext) { return; } $this->MARK;", + "secondOnly", + "firstOnly", + ), + ( + "if ($this instanceof FirstContext) { $nested = function () { $this->MARK; }; }", + "firstOnly", + "secondOnly", + ), + ] { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union_guards.php").unwrap(); + let run = + format!("public function run(): void {{ bindContext(function () {{ {body} }}); }}"); + let src = format!( + r#"MARK;"), + format!("$next = {keyword}::next(); $next->MARK;"), + ] { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union_static.php").unwrap(); + let src = format!( + r#"", "$this?->", "self::", "static::"] { + for annotation in ["FirstContext|SecondContext", "SecondContext|FirstContext"] { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union_nested_receivers.php").unwrap(); + let src = format!( + r#"MARK; }}); }}); +"# + ); + let position = marker_position(&src); + let src = src.replace("MARK", ""); + let items = complete_at(&backend, &uri, &src, position.line, position.character).await; + let names = method_names(&items); + assert!(names.contains(&"firstOnly"), "{annotation}: {names:?}"); + assert!(names.contains(&"secondOnly"), "{annotation}: {names:?}"); + } + } +} + +#[tokio::test] +async fn param_closure_this_union_arrow_guard() { + let backend = create_test_backend(); + let uri = Url::parse("file:///test/closure_union_arrow.php").unwrap(); + let src = r#" $this instanceof FirstContext ? null : $this->MARK); +"#; + let position = marker_position(src); + let src = src.replace("MARK", ""); + let items = complete_at(&backend, &uri, &src, position.line, position.character).await; + let names = method_names(&items); + assert!(names.contains(&"secondOnly"), "{names:?}"); + assert!(!names.contains(&"firstOnly"), "{names:?}"); +} diff --git a/tests/integration/diagnostics_unknown_members.rs b/tests/integration/diagnostics_unknown_members.rs index 1f3a49916..90413e01c 100644 --- a/tests/integration/diagnostics_unknown_members.rs +++ b/tests/integration/diagnostics_unknown_members.rs @@ -14300,3 +14300,69 @@ class Registry { "an assignment used as a call receiver must resolve to what it assigned, got: {diags:?}", ); } + +#[test] +fn param_closure_this_union_diagnostics() { + let backend = create_test_backend(); + let src = r#"firstOnly(); + $this->secondOnly(); + $this->missing(); +}); +"#; + let diagnostics = unknown_member_diagnostics_with_scope_cache( + &backend, + "file:///closure_union_diagnostics.php", + src, + ); + assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); + assert!( + diagnostics[0].message.contains("missing"), + "{diagnostics:?}" + ); +} + +#[test] +fn param_closure_this_union_narrowed_diagnostics() { + let backend = create_test_backend(); + let src = r#"firstOnly(); + $this->secondOnly(); + }); + } +} +class SecondContext { public function secondOnly(): void {} } +/** @param-closure-this FirstContext|SecondContext $callback */ +function bindContext(\Closure $callback): void {} +bindContext(function () { + if ($this instanceof FirstContext) { return; } + $this->secondOnly(); + $this->firstOnly(); +}); +bindContext(fn() => $this instanceof FirstContext ? $this->firstOnly() : $this->secondOnly()); +"#; + let diagnostics = unknown_member_diagnostics_with_scope_cache( + &backend, + "file:///closure_union_narrowed_diagnostics.php", + src, + ); + assert_eq!(diagnostics.len(), 2, "{diagnostics:?}"); + assert!( + diagnostics.iter().any(|d| d.message.contains("secondOnly")), + "{diagnostics:?}" + ); + assert!( + diagnostics.iter().any(|d| d.message.contains("firstOnly")), + "{diagnostics:?}" + ); +} diff --git a/tests/integration/hover.rs b/tests/integration/hover.rs index c82bd2d14..60bad5177 100644 --- a/tests/integration/hover.rs +++ b/tests/integration/hover.rs @@ -15092,3 +15092,13 @@ function tick(): void "a static local's initialiser should type it, got: {text}" ); } + +#[test] +fn hover_param_closure_this_union() { + let backend = create_test_backend(); + let content = "