Skip to content

Commit 2bf56f0

Browse files
committed
Fix 3 bugs caught by PHPactor's tests
1 parent 6b20091 commit 2bf56f0

14 files changed

Lines changed: 230 additions & 116 deletions

docs/CHANGELOG.md

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

1010
### Added
1111

12+
- **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.
13+
14+
### Fixed
15+
16+
- **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.
17+
- **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.
18+
- **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.
19+
20+
1221
- **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.
1322
- **Unresolved member access diagnostic (opt-in).** A new hint-level diagnostic flags `->`, `?->`, and `::` accesses where PHPantom cannot resolve the subject type at all. Off by default because most codebases lack full type coverage. Enable it by adding `unresolved-member-access = true` under `[diagnostics]` in `.phpantom.toml`. Useful for discovering gaps in type annotations or places where PHPantom's inference falls short.
1423
- **Rename.** `textDocument/rename` with `prepareRename` support. Rename variables, classes, methods, properties, functions, and constants across the entire workspace. Variable renames are scoped to the enclosing function or closure. Property renames handle the `$` prefix correctly at declaration vs. access sites. Symbols defined in vendor files are rejected. Non-renameable tokens (`$this`, `self`, `static`, `parent`) are rejected at the prepare step so the editor never opens the rename prompt.

docs/todo/bugs.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -138,20 +138,6 @@ error message.
138138

139139
---
140140

141-
## 9. Enum case instance properties not shown in `->` completion
142-
**Impact: Medium · Effort: Low**
143-
144-
After resolving an enum case, `->` completion does not show the `name`
145-
property (available on all enums) or the `value` property (available on
146-
backed enums). These are implicit instance properties defined by the
147-
`UnitEnum` and `BackedEnum` interfaces. The enum's own methods and
148-
trait methods appear, but these built-in properties are missing.
149-
150-
**Discovered via:** fixture conversion (enum/backed_enum_case_members,
151-
enum/enum_case_members).
152-
153-
---
154-
155141
## 10. Mixed arrow then static accessor chaining not resolved
156142
**Impact: Low · Effort: Low**
157143

docs/todo/testing.md

Lines changed: 3 additions & 70 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, **178
4-
pass** and **50 are ignored** because they exercise features or bug
3+
There are **228 fixture tests** in `tests/fixtures/`. Of these, **183
4+
pass** and **45 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 50 ignored fixtures by the underlying work
8+
This document groups the 45 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
@@ -172,31 +172,6 @@ work without further changes.
172172

173173
---
174174

175-
## 6. `@method` with `static`/`$this` return type on parent (2 fixtures)
176-
177-
**Ref:** [type-inference.md §26](type-inference.md#26-method-with-static-or-this-return-type-on-parent-class)
178-
**Impact: Medium · Effort: Low-Medium**
179-
180-
When a parent class declares `@method static foo()` or
181-
`@method $this bar()`, calling the method on a child class should
182-
return the child class type. Virtual method return types are not
183-
rewritten through the inheritance chain today.
184-
185-
**Fixtures:**
186-
187-
- [ ] `virtual_member/method_returns_static.fixture``@method static foo()` on parent, called on child
188-
- [ ] `virtual_member/method_returns_this.fixture``@method $this bar()` on parent, called on child
189-
190-
**Implementation notes:**
191-
192-
During inheritance merging, when copying virtual methods from a parent
193-
class, apply the same `static`/`$this`/`self` return type rewriting
194-
that already works for regular methods. Check `rewrite_self_static` in
195-
`inheritance.rs` and ensure it also processes `MethodInfo` entries that
196-
originated from `@method` tags.
197-
198-
---
199-
200175
## 7. Invoked closure/arrow function return type (2 fixtures)
201176

202177
**Ref:** [type-inference.md §30](type-inference.md#30-invoked-closurearrow-function-return-type)
@@ -243,32 +218,6 @@ name is a variable, resolve the variable's type. If it is
243218

244219
---
245220

246-
## 9. Enum case instance properties (2 fixtures)
247-
248-
**Ref:** [bugs.md §9](bugs.md#9-enum-case-instance-properties-not-shown-in---completion)
249-
**Impact: Medium · Effort: Low**
250-
251-
`->` completion on an enum case does not show the `name` property
252-
(available on all enums via `UnitEnum`) or the `value` property
253-
(available on backed enums via `BackedEnum`). The enum's own methods
254-
and trait methods appear, but these built-in properties are missing.
255-
256-
**Fixtures:**
257-
258-
- [ ] `enum/enum_case_members.fixture``$case->name` available on unit enum
259-
- [ ] `enum/backed_enum_case_members.fixture``$case->value` and `$case->name` on backed enum
260-
261-
**Implementation notes:**
262-
263-
During enum class resolution (or in the completion builder), inject
264-
synthetic `PropertyInfo` entries for `name` (type `string`, on all
265-
enums) and `value` (type matching the backing type, on backed enums).
266-
These are defined by the `UnitEnum` and `BackedEnum` interfaces in the
267-
stubs, so alternatively ensure that enum resolution inherits from those
268-
interfaces and their properties are included.
269-
270-
---
271-
272221
## 10. Mixed `->` then `::` accessor chaining (2 fixtures)
273222

274223
**Ref:** [bugs.md §10](bugs.md#10-mixed-arrow-then-static-accessor-chaining-not-resolved)
@@ -307,20 +256,7 @@ merging.
307256

308257
---
309258

310-
## 12. Sequential `assert()` calls do not accumulate (1 fixture)
311-
312-
**Ref:** [type-inference.md §22](type-inference.md#22-sequential-assert-calls-do-not-accumulate)
313-
**Impact: Low-Medium · Effort: Low**
314259

315-
Multiple `assert($x instanceof Foo); assert($x instanceof Bar);`
316-
statements should accumulate. Only the last assertion's narrowing
317-
applies today.
318-
319-
**Fixture:**
320-
321-
- [ ] `combination/intersect_interface_assert.fixture` — sequential assert narrows to both types
322-
323-
---
324260

325261
## 13. Compound negated guard clause narrowing (1 fixture)
326262

@@ -504,14 +440,11 @@ Quick wins (Low effort, 1 fixture each):
504440
| Task | Fixture |
505441
|---|---|
506442
| §23 `@phpstan-type` in foreach | `type/phpstan_type_alias` |
507-
| §12 Sequential assert accumulation | `combination/intersect_interface_assert` |
508443

509444
Moderate wins (Low-Medium effort, multiple fixtures):
510445

511446
| Task | Fixtures |
512447
|---|---|
513-
| §6 `@method` static/`$this` rewriting | 2 |
514-
| §9 Enum case `name`/`value` properties | 2 |
515448
| §10 Mixed `->` then `::` chaining | 2 |
516449

517450
Biggest unlocks (Medium effort, many fixtures):

docs/todo/type-inference.md

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -681,16 +681,7 @@ property_narrowing_negated, combination/property_instanceof).
681681

682682
---
683683

684-
## 22. Sequential `assert()` calls do not accumulate
685-
**Impact: Low-Medium · Effort: Low**
686-
687-
Multiple `assert($x instanceof Foo); assert($x instanceof Bar);`
688-
statements on the same variable should accumulate. Today only the
689-
last assertion's narrowing applies.
690684

691-
**Discovered via:** fixture conversion (sequential_narrowing).
692-
693-
---
694685

695686
## 24. Literal string conditional return type
696687
**Impact: Low · Effort: Low-Medium**
@@ -716,18 +707,6 @@ inheritance merging.
716707

717708
---
718709

719-
## 26. `@method` with `static` or `$this` return type on parent class
720-
**Impact: Medium · Effort: Low-Medium**
721-
722-
When a parent class declares `@method static foo()` or
723-
`@method $this bar()`, calling the method on a child class should
724-
return the child class type. Today the virtual method's return type
725-
is not rewritten through the inheritance chain.
726-
727-
**Discovered via:** fixture conversion (virtual_member/method_returns_static,
728-
virtual_member/method_returns_this).
729-
730-
---
731710

732711
## 27. `new $classStringVar` and `$classStringVar::staticMethod()`
733712
**Impact: Low-Medium · Effort: Medium**

example.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,8 +1329,18 @@ public function demo(): void
13291329

13301330
Status::Active; // backed enum case
13311331
Status::Active->label(); // enum method
1332+
Status::Active->name; // "Active" (from UnitEnum)
1333+
Status::Active->value; // "active" (from BackedEnum)
13321334
Priority::High; // int-backed enum
1335+
Priority::High->name; // "High" (from UnitEnum)
1336+
Priority::High->value; // 3 (from BackedEnum, int)
13331337
Mode::Manual; // unit enum
1338+
Mode::Manual->name; // "Manual" (from UnitEnum)
1339+
1340+
// Enum case assigned to variable
1341+
$status = Status::Active;
1342+
$status->name; // resolves through variable
1343+
$status->value;
13341344
}
13351345
}
13361346

src/completion/variable/resolution.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,18 @@ pub(in crate::completion) fn walk_statements_for_assignments<'b>(
460460
results: &mut Vec<ClassInfo>,
461461
conditional: bool,
462462
) {
463+
/// Return the sorted set of class names in `results`.
464+
fn result_names(results: &[ClassInfo]) -> Vec<String> {
465+
let mut names: Vec<String> = results.iter().map(|c| c.name.clone()).collect();
466+
names.sort();
467+
names
468+
}
469+
470+
// Accumulator for sequential `assert($x instanceof ...)` calls.
471+
// Each assert narrows to a single type; this vec collects them
472+
// so that two asserts in a row produce a union (intersection type).
473+
let mut assert_narrowed_types: Vec<ClassInfo> = Vec::new();
474+
463475
for stmt in statements {
464476
// ── Closure / arrow-function scope ──
465477
// If the cursor falls *inside* this statement, check whether
@@ -485,6 +497,7 @@ pub(in crate::completion) fn walk_statements_for_assignments<'b>(
485497
// If the docblock resolves successfully (and passes
486498
// the same override check we apply to @return), use
487499
// it and skip normal resolution for this statement.
500+
let pre_assign_names = result_names(results);
488501
if !try_inline_var_override(
489502
expr_stmt.expression,
490503
stmt.span().start.offset as usize,
@@ -499,16 +512,47 @@ pub(in crate::completion) fn walk_statements_for_assignments<'b>(
499512
conditional,
500513
);
501514
}
515+
// If an assignment (or @var override) changed the
516+
// results, the variable was reassigned — any prior
517+
// assert-narrowed types are no longer valid.
518+
if result_names(results) != pre_assign_names {
519+
assert_narrowed_types.clear();
520+
}
502521

503522
// ── assert($var instanceof ClassName) narrowing ──
504523
// When `assert($var instanceof Foo)` appears before
505524
// the cursor, narrow the variable to `Foo` for the
506525
// remainder of the current scope.
526+
//
527+
// Sequential asserts accumulate an intersection type:
528+
// assert($x instanceof A);
529+
// assert($x instanceof B);
530+
// → results contains members from both A and B.
531+
//
532+
// Save the pre-assert state so we can detect when
533+
// the assert actually narrowed, and merge with any
534+
// prior assert-narrowed types.
535+
let pre_assert_names = result_names(results);
507536
narrowing::try_apply_assert_instanceof_narrowing(
508537
expr_stmt.expression,
509538
ctx,
510539
results,
511540
);
541+
let changed = result_names(results) != pre_assert_names;
542+
// If the assert changed results AND we had prior
543+
// assert-narrowed types, merge the old narrowed
544+
// types back in (accumulate the intersection).
545+
if changed && !assert_narrowed_types.is_empty() {
546+
for cls in &assert_narrowed_types {
547+
if !results.iter().any(|c| c.name == cls.name) {
548+
results.push(cls.clone());
549+
}
550+
}
551+
}
552+
// Track types that came from assert narrowing.
553+
if changed {
554+
assert_narrowed_types.clone_from(results);
555+
}
512556

513557
// ── @phpstan-assert / @psalm-assert narrowing ──
514558
// When a function with `@phpstan-assert Type $param`

src/completion/variable/rhs_resolution.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,58 @@ fn resolve_rhs_property_access(access: &Access<'_>, ctx: &VarResolutionCtx<'_>)
536536
let all_classes = ctx.all_classes;
537537
let class_loader = ctx.class_loader;
538538

539+
// ── Class constant / enum case access: `Foo::BAR` ──
540+
// When the RHS is a class constant access, resolve the class and
541+
// check whether the constant is an enum case (→ type is the enum
542+
// itself) or a typed constant (→ use its type_hint).
543+
if let Access::ClassConstant(cca) = access {
544+
let class_name = match cca.class {
545+
Expression::Identifier(ident) => Some(ident.value().to_string()),
546+
Expression::Self_(_) => Some(current_class_name.to_string()),
547+
Expression::Static(_) => Some(current_class_name.to_string()),
548+
_ => None,
549+
};
550+
if let Some(class_name) = class_name {
551+
let resolved_name = crate::docblock::types::clean_type(&class_name);
552+
let target_classes = crate::completion::type_resolution::type_hint_to_classes(
553+
&resolved_name,
554+
current_class_name,
555+
all_classes,
556+
class_loader,
557+
);
558+
559+
let const_name = match &cca.constant {
560+
ClassLikeConstantSelector::Identifier(ident) => Some(ident.value.to_string()),
561+
_ => None,
562+
};
563+
564+
if let Some(const_name) = const_name {
565+
for cls in &target_classes {
566+
// Check if the constant is an enum case — the
567+
// result type is the enum class itself.
568+
if let Some(c) = cls.constants.iter().find(|c| c.name == const_name) {
569+
if c.is_enum_case {
570+
return target_classes;
571+
}
572+
// Typed class constant — resolve via type_hint.
573+
if let Some(ref th) = c.type_hint {
574+
let resolved = crate::completion::type_resolution::type_hint_to_classes(
575+
th,
576+
current_class_name,
577+
all_classes,
578+
class_loader,
579+
);
580+
if !resolved.is_empty() {
581+
return resolved;
582+
}
583+
}
584+
}
585+
}
586+
}
587+
}
588+
return vec![];
589+
}
590+
539591
let (object_expr, prop_selector) = match access {
540592
Access::Property(pa) => (Some(pa.object), Some(&pa.property)),
541593
Access::NullSafeProperty(pa) => (Some(pa.object), Some(&pa.property)),

0 commit comments

Comments
 (0)