Skip to content

Commit 14f02e5

Browse files
committed
Fix 5 type narrowing cases thanks to PHPactor's tests
1 parent 1108dbe commit 14f02e5

16 files changed

Lines changed: 347 additions & 150 deletions

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Invoked closure and arrow function return types.** `(fn(): Foo => new Foo())()` and `(function(): Bar { return new Bar(); })()` now resolve to the return type of the closure or arrow function. Previously these patterns produced no completions.
13+
- **`new $classStringVar` and `$classStringVar::method()`.** When a variable holds a class-string value (e.g. `$f = Foo::class`), `new $f` resolves to `Foo` and `$f::staticMethod()` resolves through `Foo`'s static members.
14+
- **`iterator_to_array()` element type.** `iterator_to_array($iter)` now resolves the element type from the iterator's generic annotation, so `$items[0]->` offers the correct completions.
15+
- **Compound negated guard clause narrowing.** After `if (!$x instanceof A && !$x instanceof B) { return; }`, the surviving code narrows `$x` to `A|B`. Previously only single negated instanceof guard clauses were recognized.
1216
- **Enum case properties.** Completing on an enum case variable (`$case->`) now shows `name` (on all enums) and `value` (on backed enums) inherited from the `UnitEnum` and `BackedEnum` interfaces.
1317
- **`@implements` generic resolution.** When a class declares `@implements SomeInterface<ConcreteType>`, template parameters on the interface's methods and properties are now substituted with the concrete types. Works with `@template-implements` and `@phpstan-implements` aliases, multiple `@implements` annotations on the same class, parameter type substitution (not just return types), and chained resolution through parent classes (e.g. `Test2 extends Test1<int>` where `Test1` has `@implements Iterator<TKey, string>`). Foreach iteration over classes implementing generic iterable interfaces (including through intermediate interface chains like `TypedCollection extends IteratorAggregate`) now resolves value and key types correctly.
1418
- **`@phpstan-assert` on static methods.** Type guard annotations (`@phpstan-assert`, `@phpstan-assert-if-true`, `@phpstan-assert-if-false`) now work on static method calls like `Assert::instanceOf($value, Foo::class)`. Previously only standalone function calls triggered assertion-based narrowing.
@@ -23,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2327
- **Inherited `@method` and `@property` tags.** Virtual members declared via `@method` or `@property` on a parent class now appear on child classes. Previously only the declaring class itself surfaced these members.
2428
- **Class constant and enum case assignment resolution.** Assigning from a class constant (`$x = Foo::SOME_CONST`) or enum case (`$x = Status::Active`) now resolves the variable's type correctly for subsequent completion.
2529
- **Sequential assert narrowing.** Multiple `assert($x instanceof A); assert($x instanceof B);` statements now accumulate, showing members from both types. Previously only the last assertion's narrowing applied.
30+
- **Arrow function parameter completion with incomplete expressions.** Typing `$foo->` inside an arrow function body (e.g. `fn(Foo $foo) => $foo->`) now resolves the parameter type even when the expression is incomplete. The parser recovery inserts a dummy token so the surrounding arrow function structure is recognized.
2631

2732

2833
- **Project configuration.** PHPantom now reads a `.phpantom.toml` file from the project root (next to `composer.json`) for per-project settings. Currently supports `[php] version` to override the detected PHP version, and `[diagnostics] unresolved-member-access` to enable the new unresolved member access diagnostic. Run `phpantom --init` to generate a default config file with all options commented out. When the file is missing, all settings use their defaults. Parse errors are reported as a warning in the editor.

docs/todo/completion.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ These functions have return type semantics that don't fit into either
4242
| `array_count_values` | Returns `array<TValue, int>` | `ArrayCountValuesDynamicReturnTypeExtension` |
4343
| `array_key_first` / `array_key_last` | Returns key type (usually scalar, low completion value) | `ArrayFirstLastDynamicReturnTypeExtension` |
4444
| `array_find_key` | Returns key type (PHP 8.4) | `ArrayFindKeyFunctionReturnTypeExtension` |
45-
| `iterator_to_array` | Preserves iterable key/value types into array | `IteratorToArrayFunctionReturnTypeExtension` |
4645
| `compact` | Builds typed array from variable names | `CompactFunctionReturnTypeExtension` |
4746
| `count` / `sizeof` | Returns precise int range based on array size | `CountFunctionReturnTypeExtension` |
4847
| `min` / `max` | Returns union of argument types | `MinMaxFunctionReturnTypeExtension` |

docs/todo/testing.md

Lines changed: 11 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# PHPantom — Ignored Fixture Tasks
22

3-
There are **228 fixture tests** in `tests/fixtures/`. Of these, **207
4-
pass** and **21 are ignored** because they exercise features or bug
3+
There are **228 fixture tests** in `tests/fixtures/`. Of these, **214
4+
pass** and **14 are ignored** because they exercise features or bug
55
fixes that are not yet implemented. Each ignored fixture has a
66
`// ignore:` comment explaining what is missing.
77

8-
This document groups the 21 ignored fixtures by the underlying work
8+
This document groups the 14 ignored fixtures by the underlying work
99
needed to un-ignore them. Tasks are ordered by the number of fixtures
1010
they unblock (descending), then by estimated effort. Once a task is
1111
complete, remove the `// ignore:` line from each fixture, verify the
@@ -78,52 +78,6 @@ work without further changes.
7878

7979
---
8080

81-
## 7. Invoked closure/arrow function return type (2 fixtures)
82-
83-
**Ref:** [type-inference.md §30](type-inference.md#30-invoked-closurearrow-function-return-type)
84-
**Impact: Low · Effort: Low-Medium**
85-
86-
Immediately invoked closures and arrow functions do not resolve their
87-
return type. `(fn(): Foo => new Foo())()` and similar patterns produce
88-
`mixed`.
89-
90-
**Fixtures:**
91-
92-
- [ ] `call_expression/arrow_fn_invocation.fixture``(fn() => new Foo())()->` resolves
93-
- [ ] `arrow_function/parameter_type.fixture` — arrow function parameter type for completion inside body
94-
95-
**Implementation notes:**
96-
97-
In the call expression resolution path, detect when the callee is a
98-
parenthesized closure or arrow function expression. Extract the return
99-
type from its signature or body. For `arrow_function/parameter_type`,
100-
the arrow function parameter's type hint should be resolved the same
101-
way closure parameters are (likely in `variable/closure_resolution.rs`).
102-
103-
---
104-
105-
## 8. `new $classStringVar` / `$classStringVar::staticMethod()` (2 fixtures)
106-
107-
**Ref:** [type-inference.md §27](type-inference.md#27-new-classstringvar-and-classstringvarstaticmethod)
108-
**Impact: Low-Medium · Effort: Medium**
109-
110-
When a variable holds a `class-string<Foo>`, `new $var` should resolve
111-
to `Foo` and `$var::staticMethod()` should resolve through `Foo`'s
112-
static methods.
113-
114-
**Fixtures:**
115-
116-
- [ ] `type/class_string_new.fixture``new $classStringVar` resolves to the class type
117-
- [ ] `type/class_string_static_call.fixture``$classStringVar::staticMethod()` resolves return type
118-
119-
**Implementation notes:**
120-
121-
In the object creation and static call resolution paths, when the class
122-
name is a variable, resolve the variable's type. If it is
123-
`class-string<T>`, extract `T` and use it as the class name.
124-
125-
---
126-
12781
## 11. `class-string<T>` on interface method not inherited (1 fixture)
12882

12983
**Ref:** [type-inference.md §25](type-inference.md#25-class-stringt-on-interface-method-not-inherited)
@@ -139,38 +93,6 @@ merging.
13993

14094
---
14195

142-
143-
144-
## 13. Compound negated guard clause narrowing (1 fixture)
145-
146-
**Ref:** [type-inference.md §23](type-inference.md#23-double-negated-instanceof-narrowing) (related)
147-
**Impact: Low · Effort: Low-Medium**
148-
149-
After `if (!$x instanceof A && !$x instanceof B) { return; }`, the
150-
surviving code should know that `$x` is `A|B`. This requires the
151-
narrowing engine to invert compound negated conditions across guard
152-
clauses.
153-
154-
**Fixture:**
155-
156-
- [ ] `completion/parenthesized_narrowing.fixture` — compound negated instanceof with guard clause narrows to union
157-
158-
---
159-
160-
## 15. Negated `@phpstan-assert !Type` (1 fixture)
161-
162-
**Ref:** [type-inference.md §19](type-inference.md#19-negated-phpstan-assert-type)
163-
**Impact: Medium · Effort: Low-Medium**
164-
165-
`@phpstan-assert !Foo $param` should remove `Foo` from the variable's
166-
union type. The `!` prefix is not parsed today.
167-
168-
**Fixture:**
169-
170-
- [ ] `narrowing/phpstan_assert_negated.fixture` — negated assert removes type from union
171-
172-
---
173-
17496
## 16. Generic `@phpstan-assert` with `class-string<T>` (1 fixture)
17597

17698
**Ref:** [type-inference.md §20](type-inference.md#20-generic-phpstan-assert-with-class-stringt-parameter-inference)
@@ -203,21 +125,6 @@ narrowing propagation issue rather than `is_*()` parsing.
203125

204126
---
205127

206-
## 21. `iterator_to_array()` return type (1 fixture)
207-
208-
**Ref:** [completion.md §1](completion.md#1-array-functions-needing-new-code-paths)
209-
**Impact: Medium · Effort: Medium**
210-
211-
`iterator_to_array()` should return the iterator's value type as an
212-
array element type. This needs a special code path similar to the
213-
existing `array_pop`/`array_shift` handling.
214-
215-
**Fixture:**
216-
217-
- [ ] `function/iterator_to_array.fixture``iterator_to_array($gen)` resolves element type
218-
219-
---
220-
221128
## 24. Variable scope isolation in closures (1 fixture)
222129

223130
**Impact: Low · Effort: Low-Medium**
@@ -281,6 +188,14 @@ Moderate wins (Low-Medium effort, few fixtures):
281188
| §25 Pass-by-reference parameter type inference | 1 |
282189
| §26 Pipe operator (PHP 8.5) | 1 |
283190

191+
Medium effort, single fixture:
192+
193+
| Task | Fixtures |
194+
|---|---|
195+
| §11 `class-string<T>` on interface method | 1 |
196+
| §16 Generic `@phpstan-assert` with `class-string<T>` | 1 |
197+
| §20 Elseif chain narrowing with `is_*()` | 1 |
198+
284199
Biggest unlocks (Medium effort, many fixtures):
285200

286201
| Task | Fixtures |

docs/todo/type-inference.md

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -483,19 +483,6 @@ type:
483483

484484
---
485485

486-
487-
## 19. Negated `@phpstan-assert !Type`
488-
**Impact: Medium · Effort: Low-Medium**
489-
490-
When a function declares `@phpstan-assert !Foo $param`, calling it
491-
should remove `Foo` from the variable's union type. Today the negation
492-
prefix is not parsed, so the assertion is either ignored or
493-
misinterpreted as a positive assertion.
494-
495-
**Discovered via:** fixture conversion (phpstan_assert_negated).
496-
497-
---
498-
499486
## 20. Generic `@phpstan-assert` with `class-string<T>` parameter inference
500487
**Impact: Medium · Effort: Medium-High**
501488

@@ -545,19 +532,6 @@ inheritance merging.
545532

546533
---
547534

548-
549-
## 27. `new $classStringVar` and `$classStringVar::staticMethod()`
550-
**Impact: Low-Medium · Effort: Medium**
551-
552-
When a variable holds a `class-string<T>`, `new $var` should resolve
553-
to `T` and `$var::staticMethod()` should resolve through `T`'s static
554-
methods. Neither path is supported today.
555-
556-
**Discovered via:** fixture conversion (type/class_string_new,
557-
type/class_string_static_call).
558-
559-
---
560-
561535
## 28. `__invoke()` return type resolution
562536
**Impact: Low-Medium · Effort: Low**
563537

@@ -570,24 +544,6 @@ an object type.
570544

571545
---
572546

573-
## 30. Invoked closure/arrow function return type
574-
**Impact: Low · Effort: Low-Medium**
575-
576-
Immediately invoked closures and arrow functions do not resolve their
577-
return type:
578-
579-
```php
580-
$result = (fn(): Foo => new Foo())();
581-
$result->method(); // unresolved
582-
```
583-
584-
The call expression resolution does not detect that the callee is a
585-
parenthesized closure/arrow function expression.
586-
587-
**Discovered via:** fixture conversion (call_expression/arrow_fn_invocation).
588-
589-
---
590-
591547
## 31. Resolved-class cache: key by FQN + generic args (done)
592548
**Impact: High · Effort: Medium**
593549

example.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,65 @@ public function demo(): void
728728
// Variable assigned from callable invocation
729729
$fromClosure = $makePen();
730730
$fromClosure->write(); // $result = $fn() resolves return type
731+
732+
// Immediately invoked arrow function with return type
733+
$result = (fn(): Pen => new Pen())();
734+
$result->write(); // resolves Pen from arrow fn return type
735+
736+
// Immediately invoked closure with return type
737+
$obj = (function(): Pencil { return new Pencil(); })();
738+
$obj->sketch(); // resolves Pencil from closure return type
739+
}
740+
}
741+
742+
743+
// ── class-string Variable Resolution ────────────────────────────────────────
744+
745+
class ClassStringVarDemo
746+
{
747+
public function demo(): void
748+
{
749+
// new $var where $var holds a class-string
750+
$cls = Pen::class;
751+
$pen = new $cls;
752+
$pen->write(); // resolves Pen from class-string
753+
754+
// $var::staticMethod() where $var holds a class-string
755+
$userClass = User::class;
756+
$found = $userClass::findByEmail('test@example.com');
757+
$found->getEmail(); // resolves User from class-string static call
758+
}
759+
}
760+
761+
762+
// ── iterator_to_array Resolution ────────────────────────────────────────────
763+
764+
class IteratorToArrayDemo
765+
{
766+
public function demo(): void
767+
{
768+
/** @var \Iterator<int, Pen> $iter */
769+
$iter = getUnknownValue();
770+
771+
$items = iterator_to_array($iter);
772+
$items[0]->write(); // resolves Pen from iterator value type
773+
}
774+
}
775+
776+
777+
// ── Compound Negated Guard Clause Narrowing ─────────────────────────────────
778+
779+
class CompoundNegatedNarrowingDemo
780+
{
781+
/** @param Rock|Banana|Lamp $thing */
782+
public function demo($thing): void
783+
{
784+
// After both negated instanceof checks exit, $thing is Rock|Banana
785+
if (!$thing instanceof Rock && !$thing instanceof Banana) {
786+
return;
787+
}
788+
789+
$thing->weigh(); // both Rock and Banana have weigh()
731790
}
732791
}
733792

src/completion/handler.rs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -731,11 +731,45 @@ impl Backend {
731731
resolved_class_cache: Some(&self.resolved_class_cache),
732732
function_loader: Some(&function_loader),
733733
};
734-
super::resolver::resolve_target_classes(
734+
let mut resolved = super::resolver::resolve_target_classes(
735735
&target.subject,
736736
target.access_kind,
737737
&rctx,
738-
)
738+
);
739+
740+
// ── Incomplete-expression retry ─────────────────
741+
// When the cursor sits right after `->` (or `?->`)
742+
// at the end of an expression with no trailing
743+
// semicolon (e.g. inside an arrow function body the
744+
// user is still typing), the PHP parser may fail to
745+
// produce the enclosing statement. Patch the
746+
// content by appending a dummy identifier +
747+
// semicolon so the parser can recover.
748+
if resolved.is_empty() && target.subject.starts_with('$') {
749+
let patched = Self::patch_incomplete_member_access(content, position);
750+
if patched != content {
751+
let patched_classes = self.parse_php(&patched);
752+
let patched_offset = position_to_offset(&patched, position);
753+
let patched_current =
754+
find_class_at_offset(&patched_classes, patched_offset);
755+
let patched_rctx = ResolutionCtx {
756+
current_class: patched_current,
757+
all_classes: &patched_classes,
758+
content: &patched,
759+
cursor_offset: patched_offset,
760+
class_loader: &class_loader,
761+
resolved_class_cache: Some(&self.resolved_class_cache),
762+
function_loader: Some(&function_loader),
763+
};
764+
resolved = super::resolver::resolve_target_classes(
765+
&target.subject,
766+
target.access_kind,
767+
&patched_rctx,
768+
);
769+
}
770+
}
771+
772+
resolved
739773
};
740774
if candidates.is_empty() {
741775
return vec![];
@@ -1056,6 +1090,49 @@ impl Backend {
10561090
/// (e.g. `$this->greet(|` where the closing `)` hasn't been typed
10571091
/// yet). Closing the call expression lets the parser recover the
10581092
/// surrounding class/function structure.
1093+
/// Patch incomplete member-access expressions for parser recovery.
1094+
///
1095+
/// When the cursor is right after `->` or `?->` and the line has no
1096+
/// semicolon, the PHP parser may fail to recognise the enclosing
1097+
/// statement (e.g. an arrow function body). This inserts a dummy
1098+
/// identifier and semicolon (`_x;`) at the cursor so the parser can
1099+
/// recover the surrounding structure.
1100+
fn patch_incomplete_member_access(content: &str, position: Position) -> String {
1101+
let line_idx = position.line as usize;
1102+
let col = position.character as usize;
1103+
let mut result = String::with_capacity(content.len() + 4);
1104+
1105+
for (i, line) in content.lines().enumerate() {
1106+
if i == line_idx {
1107+
let byte_col = line
1108+
.char_indices()
1109+
.nth(col)
1110+
.map(|(idx, _)| idx)
1111+
.unwrap_or(line.len());
1112+
// Only patch when the cursor is right after `->` or
1113+
// `?->` with nothing meaningful following it.
1114+
let before = &line[..byte_col];
1115+
let after = line[byte_col..].trim();
1116+
if (before.ends_with("->") || before.ends_with("?->")) && after.is_empty() {
1117+
result.push_str(before);
1118+
result.push_str("_x;");
1119+
result.push_str(&line[byte_col..]);
1120+
} else {
1121+
result.push_str(line);
1122+
}
1123+
} else {
1124+
result.push_str(line);
1125+
}
1126+
result.push('\n');
1127+
}
1128+
1129+
if !content.ends_with('\n') && result.ends_with('\n') {
1130+
result.pop();
1131+
}
1132+
1133+
result
1134+
}
1135+
10591136
fn patch_content_at_cursor(content: &str, position: Position) -> String {
10601137
let line_idx = position.line as usize;
10611138
let col = position.character as usize;

0 commit comments

Comments
 (0)