Skip to content

Commit 54204c9

Browse files
committed
Fix a couple of bugs with type resolving
1 parent 41885a3 commit 54204c9

16 files changed

Lines changed: 929 additions & 59 deletions

docs/CHANGELOG.md

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

5656
### Fixed
5757

58+
- **Generic shape substitution.** Template parameters inside array shapes (`array{data: T}`) and object shapes (`object{name: T}`) are now correctly substituted when inherited through `@extends`. Previously only template parameters inside angle brackets were resolved, leaving bare references like `T` unsubstituted in shape bodies.
59+
- **Array shape bracket access.** Variables assigned from string-key bracket access on array shapes (`$name = $data['name']`) now resolve to the correct value type. Chained access (`$first = $result['items'][0]`) walks through shape keys and generic element types in sequence. This fixes completion, hover, and go-to-definition for variables derived from array shape fields. Previously only direct `$data['key']->` subjects resolved; intermediate variable assignments lost the type.
60+
- **Hover on array shape types.** Hovering over a variable whose type is an array shape (e.g. `array{data: User}`) no longer produces a corrupted `namespace array{...` line in the popup.
61+
- **Eloquent `morphedByMany` relationships.** The inverse side of polymorphic many-to-many relationships (`$this->morphedByMany(...)`) is now recognised. Virtual properties and `_count` properties are synthesized for models using this relationship type.
5862
- **Hover.** Hovering over unresolved function calls, unknown constants, or unresolvable `self`/`static`/`parent`/`$this` keywords no longer shows a bare placeholder. If the symbol cannot be found, no hover is shown.
5963
- **Add @throws.** The code action no longer double-indents the closing `*/` when inserting a `@throws` tag into an existing multi-line docblock.
6064
- **PHPStan stale-diagnostic clearing.** The `@throws`-based staleness check now scopes to the enclosing function's docblock instead of searching the entire file. A `@throws` tag on a different function no longer causes an unrelated diagnostic to be incorrectly cleared.

docs/todo.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,6 @@ within the same impact tier.
1919

2020
# Scheduled Sprints
2121

22-
## Sprint 3 — Bug fixes
23-
24-
| # | Item | Impact | Effort |
25-
| --- | ----------------------------------------------------------------------------------------------------------------- | ------ | ------ |
26-
| | **Release 0.6.0** | | |
27-
2822
## Sprint 4 — Refactoring toolkit
2923

3024
| # | Item | Impact | Effort |
@@ -144,7 +138,6 @@ unlikely to move the needle for most users.
144138
| S5 | Language construct signature help and hover | Low | Low |
145139
| | **[Laravel](todo/laravel.md)** | | |
146140
| L4 | Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`) | Medium | Medium |
147-
| L2 | `morphedByMany` missing from relationship method map | Low-Medium | Low |
148141
| L3 | `$dates` array (deprecated) | Low-Medium | Low |
149142
| L6 | Factory `has*`/`for*` relationship methods | Low-Medium | Medium |
150143
| L7 | `$pivot` property on BelongsToMany | Medium | Medium-High |

docs/todo/bugs.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,4 @@ within the same impact tier.
1313
| **Impact** | **Critical**, **High**, **Medium-High**, **Medium**, **Low-Medium**, **Low** |
1414
| **Effort** | **Low** (≤ 1 day), **Medium** (2-5 days), **Medium-High** (1-2 weeks), **High** (2-4 weeks), **Very High** (> 1 month) |
1515

16-
---
17-
18-
No outstanding items.
16+
---

docs/todo/laravel.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,21 @@ synthesizing `create()`/`make()`, load the associated model class.
312312
For each relationship method on the model, push a `has{Relationship}`
313313
and `for{Relationship}` virtual method (PascalCase of the method
314314
name) that returns `static` (i.e. the factory class itself).
315-
The `has*` variant should accept optional `int $count` and
316-
`array|callable $state` parameters; `for*` should accept
317-
`array|callable $state`.
315+
316+
Larastan's `ModelFactoryMethodsClassReflectionExtension` reveals the
317+
exact parameter signatures to synthesize:
318+
319+
- **`has{Rel}()`** — four overloads: no args, `int $count`,
320+
`array|callable $state`, or `int $count, array|callable $state`.
321+
- **`for{Rel}()`** — two overloads: no args, or
322+
`array|callable $state`.
323+
- **`trashed()`** — only synthesized when the model uses
324+
`SoftDeletes`. No parameters, returns `static`.
325+
326+
The strip-and-match algorithm: strip the `has`/`for` prefix, convert
327+
the remainder to camelCase, and check whether the model has a
328+
relationship method with that name. If not, the dynamic method is
329+
not offered.
318330

319331
#### L7. `$pivot` property on BelongsToMany related models
320332

docs/todo/mago.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,12 @@ No existing code changes behaviour.
440440
- `Callable { params: Vec<CallableParam>, return_type: Option<Box<PhpType>> }`
441441
- `Conditional { param: String, condition: ParamCondition, then: Box<PhpType>, otherwise: Box<PhpType> }`
442442
- `Slice(Box<PhpType>)``T[]`
443-
- `Variable(String)``$this`, template variables
443+
- `Variable { name: String, scope: TemplateScope }``$this`,
444+
template variables. The scope distinguishes class-level `T` from
445+
method-level `T` on the same class. PHPStan identifies template
446+
types by name + scope (`TemplateTypeScope`); without this,
447+
a method declaring its own `@template T` that shadows the
448+
class's `T` would produce incorrect substitutions.
444449
- `ClassString(Option<Box<PhpType>>)``class-string<T>`
445450
- `KeyOf(Box<PhpType>)`, `ValueOf(Box<PhpType>)`
446451
- `Static`, `Self_`, `Parent`

docs/todo/type-inference.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ check compares the richness of the return type.
6767
`native_type_hint`. Also inherit `description` and
6868
`return_description` when the child lacks them.
6969

70+
**Parameter name remapping.** When propagating `@param` types,
71+
match by position, not by name. A child may rename `$userId` to
72+
`$id`; the parent's `@param int $userId` at position 0 should
73+
still flow to the child's position 0. PHPStan's
74+
`PhpDocInheritanceResolver` builds an explicit positional mapping
75+
for this.
76+
7077
2. **During interface merging** (`resolve_class_fully_inner`): same
7178
logic — when an interface method is skipped because the class
7279
already defines it, enrich the existing method with the

example.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,25 @@ public function demo(): void
10031003
}
10041004

10051005

1006+
// ── Generic Shape Substitution ──────────────────────────────────────────────
1007+
1008+
class GenericShapeDemo
1009+
{
1010+
public function demo(): void
1011+
{
1012+
$src = new ScaffoldingGenericShape();
1013+
1014+
// Template params inside array shape bodies are substituted through inheritance
1015+
$result = $src->getResult();
1016+
$result['data']->open(); // array{data: T} with T=Gift → Gift
1017+
1018+
// Chained bracket access walks shape key then list element
1019+
$first = $result['items'][0];
1020+
$first->open(); // list<T> with T=Gift → Gift
1021+
}
1022+
}
1023+
1024+
10061025
// ── Foreach, Key Types, and Destructuring ───────────────────────────────────
10071026

10081027
class IterationDemo
@@ -2837,6 +2856,21 @@ public function __construct()
28372856
}
28382857
}
28392858

2859+
// ScaffoldingGenericShape — used by GenericShapeDemo
2860+
/**
2861+
* @template T
2862+
*/
2863+
class ScaffoldingGenericShapeBase
2864+
{
2865+
/** @return array{data: T, items: list<T>} */
2866+
public function getResult(): array { return []; }
2867+
}
2868+
2869+
/**
2870+
* @extends ScaffoldingGenericShapeBase<Gift>
2871+
*/
2872+
class ScaffoldingGenericShape extends ScaffoldingGenericShapeBase {}
2873+
28402874
class ScaffoldingCollectionForeach
28412875
{
28422876
public UserEloquentCollection $members;

src/completion/variable/raw_type_inference.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,12 @@ fn resolve_rhs_raw_type<'b>(rhs: &'b Expression<'b>, ctx: &VarResolutionCtx<'_>)
806806
Expression::Static(_) => Some(ctx.current_class.name.clone()),
807807
_ => None,
808808
},
809+
// ── Array access: `$arr['key']` or `$arr[0]` ──
810+
// Walk through nested ArrayAccess nodes to collect bracket
811+
// segments, resolve the base variable's type, then narrow
812+
// through each segment (shape key → value type, element
813+
// access → generic value type).
814+
Expression::ArrayAccess(_) => resolve_rhs_array_access_raw_type(rhs, ctx),
809815
// ── Parenthesized: unwrap ──
810816
Expression::Parenthesized(p) => resolve_rhs_raw_type(p.expression, ctx),
811817
// ── Ternary / elvis: `$a ? $b : $c` or `$a ?: $c` ──
@@ -855,6 +861,99 @@ fn resolve_rhs_raw_type<'b>(rhs: &'b Expression<'b>, ctx: &VarResolutionCtx<'_>)
855861
}
856862
}
857863

864+
/// Resolve an array access expression to a raw type string.
865+
///
866+
/// Walks through nested `ArrayAccess` nodes to collect bracket segments
867+
/// (string key vs element access), resolves the innermost base
868+
/// variable's type, then narrows through each segment:
869+
/// - String key → `extract_array_shape_value_type`
870+
/// - Element access → `extract_generic_value_type` (including scalar results)
871+
fn resolve_rhs_array_access_raw_type<'b>(
872+
expr: &'b Expression<'b>,
873+
ctx: &VarResolutionCtx<'_>,
874+
) -> Option<String> {
875+
// Collect bracket segments innermost-last.
876+
let mut segments: Vec<bool> = Vec::new(); // true = string key
877+
let mut keys: Vec<String> = Vec::new();
878+
let mut current: &Expression<'_> = expr;
879+
880+
while let Expression::ArrayAccess(access) = current {
881+
if let Expression::Literal(Literal::String(s)) = access.index {
882+
let key = s.value.map(|v| v.to_string()).unwrap_or_else(|| {
883+
let raw = s.raw;
884+
raw.strip_prefix('\'')
885+
.and_then(|r| r.strip_suffix('\''))
886+
.or_else(|| raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')))
887+
.unwrap_or(raw)
888+
.to_string()
889+
});
890+
segments.push(true);
891+
keys.push(key);
892+
} else {
893+
segments.push(false);
894+
keys.push(String::new());
895+
}
896+
current = access.array;
897+
}
898+
899+
// Reverse to left-to-right order.
900+
segments.reverse();
901+
keys.reverse();
902+
903+
// The base must be a variable.
904+
let Expression::Variable(Variable::Direct(dv)) = current else {
905+
return None;
906+
};
907+
let base_var = dv.name.to_string();
908+
let access_offset = expr.span().start.offset;
909+
910+
// Resolve the base variable's raw type.
911+
let mut current_type =
912+
docblock::find_iterable_raw_type_in_source(ctx.content, access_offset as usize, &base_var)
913+
.or_else(|| {
914+
resolve_variable_assignment_raw_type(
915+
&base_var,
916+
ctx.content,
917+
access_offset,
918+
Some(ctx.current_class),
919+
ctx.all_classes,
920+
ctx.class_loader,
921+
ctx.function_loader,
922+
)
923+
})?;
924+
925+
// Expand type aliases.
926+
if let Some(expanded) = crate::completion::type_resolution::resolve_type_alias(
927+
&current_type,
928+
&ctx.current_class.name,
929+
ctx.all_classes,
930+
ctx.class_loader,
931+
) {
932+
current_type = expanded;
933+
}
934+
935+
// Walk each segment.
936+
for (i, is_string_key) in segments.iter().enumerate() {
937+
if *is_string_key {
938+
if let Some(vt) =
939+
docblock::types::extract_array_shape_value_type(&current_type, &keys[i])
940+
{
941+
current_type = vt;
942+
} else if let Some(et) = docblock::types::extract_iterable_element_type(&current_type) {
943+
current_type = et;
944+
} else {
945+
return None;
946+
}
947+
} else if let Some(et) = docblock::types::extract_iterable_element_type(&current_type) {
948+
current_type = et;
949+
} else {
950+
return None;
951+
}
952+
}
953+
954+
Some(current_type)
955+
}
956+
858957
/// Combine two optional raw type strings into a union.
859958
///
860959
/// When both sides resolve to the same type string, returns a single

0 commit comments

Comments
 (0)