Skip to content

Commit 137226f

Browse files
committed
Add type-guard narrowing for is_array, is_object, and scalars
1 parent 717652b commit 137226f

9 files changed

Lines changed: 987 additions & 33 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- **Fix unsafe `new static()` code action.** When PHPStan reports `new.static`, three quickfixes are offered: add `@phpstan-consistent-constructor` to the class docblock (preferred), add `final` to the class, or add `final` to the constructor. The diagnostic is eagerly cleared after applying any of the fixes.
2323
- **Keyword completions.** Context-aware PHP keyword suggestions in statement positions. Keywords are filtered by scope: `return` and `yield` only inside functions, `break` only inside loops and `switch`, `continue` only inside loops, `case` and `default` only inside `switch`, `namespace` only at the top level, and `extends`/`implements` only in declaration headers. Class-like bodies restrict completions to member keywords (`public`, `function`, `const`, etc.) appropriate for the specific kind (class, interface, trait, or enum). Enum backing types (`int`, `string`) are suggested after `enum Name:`. Modifier chains (`public static `) trigger member-keyword completions. Contributed by @ryangjchandler in https://github.com/AJenbo/phpantom_lsp/pull/43.
2424
- **Attribute completion.** Typing inside `#[…]` now only offers classes decorated with `#[\Attribute]`, filtered by the target of the declaration the attribute applies to. An attribute targeting only methods will not appear when writing `#[…]` above a class, and vice versa. Multi-attribute lists (`#[A, B]`) and namespace-qualified prefixes are supported.
25+
- **Type-guard narrowing.** `is_array()`, `is_string()`, `is_int()`, `is_float()`, `is_bool()`, `is_object()`, `is_numeric()`, and `is_callable()` now narrow union types inside `if` bodies, `else` bodies, `elseif` bodies, and after guard clauses. When a parameter has a PHPDoc union like `null|list<Request>|Request` with a native hint `null|array|Request`, `is_array()` narrows to `list<Request>`, preserving the generic element type so that `foreach` iteration resolves correctly.
2526
- **Deferred code action computation.** Code actions now use the two-phase `codeAction/resolve` model. The lightbulb menu appears instantly because expensive edit computation (extract function, inline variable, etc.) is deferred until the user actually picks an action. PHPStan quickfixes clear their diagnostic immediately when applied instead of relying on heuristic content scanning on every keystroke.
2627
- **Extract function/method.** Select one or more complete statements inside a function or method body and extract them into a new function or private method (`refactor.extract`). Multiple return values use array destructuring. Type hints are inferred automatically. Early returns within the selection are supported, including guard clauses.
2728
- **Extract variable.** Select an expression and extract it into a new local variable assigned just before the enclosing statement (`refactor.extract`). Smart name generation from method calls (`getName()``$name`), property access (`->email``$email`), and function calls.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ------ |
26-
| B7 | [PHPDoc `@param` generic array type not merged with native `array` hint](todo/bugs.md#b7-phpdoc-param-generic-array-type-not-merged-with-native-array-hint) | Low | Medium |
2726
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
2827
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
2928
| | **Release 0.7.0** | | |

docs/todo/bugs.md

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,3 @@
11
# PHPantom — Bug Fixes
22

3-
## B7: PHPDoc `@param` generic array type not merged with native `array` hint
4-
5-
When a method has a native type hint `array` and a PHPDoc `@param` with
6-
a generic type like `list<Request>`, PHPantom doesn't merge the PHPDoc
7-
element type with the native `array` for narrowing purposes. After an
8-
`is_array()` guard, the variable narrows to `array` but loses the `Request`
9-
element type from the docblock.
10-
11-
Real-world example — `MobilePayConnection.php`:
12-
13-
```php
14-
/**
15-
* @param null|list<Request>|Request $request
16-
*/
17-
protected function connect(string $uri, null|array|Request $request, ...): MobilePayResponse
18-
{
19-
if (is_array($request)) {
20-
foreach ($request as $item) {
21-
$serializedObjects[] = $item->jsonSerialize();
22-
// ^^^^^ unresolved
23-
}
24-
}
25-
}
26-
```
27-
28-
After `is_array($request)`, `$request` narrows from `null|array|Request`
29-
to `array`. The `@param` says the array case is `list<Request>`, so
30-
`$item` should be `Request`. But the LSP doesn't unify the narrowed
31-
native `array` with the docblock's `list<Request>`.
32-
33-
**Impact:** 1 diagnostic in the shared project
34-
(`MobilePayConnection:76`).
3+
No outstanding items.

example.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,45 @@ public function demo(): void
218218
}
219219

220220

221+
// ── Type Guard Narrowing (is_array, is_object, …) ──────────────────────────
222+
223+
class TypeGuardNarrowingDemo
224+
{
225+
/**
226+
* @param null|list<Pen>|Pen $input
227+
*/
228+
public function demo(null|array|Pen $input): void
229+
{
230+
// is_array() narrows the union to the array-like PHPDoc member,
231+
// preserving the generic element type for foreach iteration.
232+
if (is_array($input)) {
233+
foreach ($input as $pen) {
234+
$pen->write(); // list<Pen> → Pen
235+
}
236+
}
237+
238+
// Else branch: non-array members survive
239+
if (is_array($input)) {
240+
// array branch
241+
} else {
242+
// $input is null|Pen here
243+
}
244+
245+
// Guard clause: is_array() + early return
246+
if (is_array($input)) {
247+
return;
248+
}
249+
// $input is null|Pen after the guard
250+
251+
// is_object() narrows to class members only
252+
$mixed = pickRockOrBanana(); // Rock|Banana
253+
if (is_object($mixed)) {
254+
$mixed->weigh(); // both Rock and Banana have weigh()
255+
}
256+
}
257+
}
258+
259+
221260
// ── instanceof self/static/parent Narrowing ────────────────────────────────
222261

223262
class InstanceofSelfDemo extends ScaffoldingSedan
@@ -5715,6 +5754,23 @@ function runDemoAssertions(): void
57155754
);
57165755
}
57175756

5757+
// ── Type guard narrowing ────────────────────────────────────────────
5758+
/** @var list<Pen> $tgPens */
5759+
$tgPens = [new Pen('a'), new Pen('b')];
5760+
/** @var null|list<Pen>|Pen $tgInput */
5761+
$tgInput = $tgPens;
5762+
if (is_array($tgInput)) {
5763+
foreach ($tgInput as $tgPen) {
5764+
assert($tgPen instanceof Pen, 'is_array() narrowed foreach element must be Pen');
5765+
}
5766+
}
5767+
$tgSingle = new Pen('solo');
5768+
/** @var list<Pen>|Pen $tgMixed */
5769+
$tgMixed = $tgSingle;
5770+
if (!is_array($tgMixed)) {
5771+
assert($tgMixed instanceof Pen, 'Else branch of is_array() must be Pen');
5772+
}
5773+
57185774
echo "All assertions passed.\n";
57195775
}
57205776

0 commit comments

Comments
 (0)