Skip to content

Commit ed4e4d4

Browse files
committed
Implement the todo's
1 parent 2152abb commit ed4e4d4

15 files changed

Lines changed: 1653 additions & 115 deletions

docs/CHANGELOG.md

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

1010
### Added
1111

12+
- **Multi-line method chain completion.** Fluent chains spanning multiple lines now produce completions and support go-to-definition. Continuation lines starting with `->` or `?->` are joined with preceding lines before subject extraction, so builder patterns, query chains, and collection pipelines work seamlessly.
13+
- **Template parameter bound resolution.** When a property or variable type is a `@template` parameter (e.g. `TNode`), the resolver falls back to the upper bound declared via `of` (e.g. `@template TNode of SomeClass`) for completion and go-to-definition.
14+
- **Transitive interface inheritance in go-to-implementation.** If `InterfaceB extends InterfaceA` and `ClassC implements InterfaceB`, go-to-implementation on `InterfaceA` now finds `ClassC`. Works through arbitrary depth and with interfaces that extend multiple parents.
1215
- **Switch statement variable type tracking.** Variables assigned inside `switch` case bodies now resolve their types. Both brace-delimited and colon-delimited (`switch(): … endswitch;`) forms are supported, and all cases contribute to a union type.
1316

17+
### Fixed
18+
19+
- **`?->` chaining fallback now recurses correctly.** The `?->` fallback branch in subject extraction called `extract_simple_variable` instead of `extract_arrow_subject`. The primary `->` branch already handled `?->` chains correctly via a `?` skip, so this was not user-visible, but the fallback is now consistent.
20+
- **Multi-extends interfaces now fully stored.** Interfaces extending multiple parents (e.g. `interface C extends A, B`) now store all parent names, not just the first one.
21+
1422
### Changed
1523

1624
## [0.3.0] - 2026-02-21

docs/todo.md

Lines changed: 37 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -37,33 +37,15 @@ final class ReindexSelectedCommand extends Command
3737

3838
## Completion Gaps
3939

40-
### 16. Multi-line method chain completion
41-
**Priority: High**
42-
43-
Subject extraction in `completion/target.rs` and `subject_extraction.rs`
44-
operates on the current line only. Chains that span multiple lines produce
45-
no completions:
46-
47-
```php
48-
$this->getRepository()
49-
->findAll()
50-
->filter(fn($u) => $u->active)
51-
-> // ← cursor here: no completion
52-
```
53-
54-
`extract_completion_target` sees only whitespace and `->` on the cursor
55-
line and `extract_arrow_subject` finds nothing meaningful to the left.
56-
This is extremely common in Laravel/Symfony code with fluent builders,
57-
query chains, and collection pipelines.
58-
59-
The same limitation affects go-to-definition: `extract_member_access_context`
60-
in `definition/member.rs` also works on the current line, so Ctrl-clicking
61-
a method in a multi-line chain cannot resolve the owning class.
40+
### ~~16. Multi-line method chain completion~~
6241

63-
**Fix:** before extracting the subject, collapse continuation lines around
64-
the cursor. Lines that start with `->` or `?->` (after optional
65-
whitespace) should be joined with the preceding line, then the extraction
66-
runs on the flattened text.
42+
Resolved. Added `collapse_continuation_lines` to `subject_extraction.rs`
43+
that detects when the cursor is on a continuation line (starting with
44+
`->` or `?->` after optional whitespace) and joins it with preceding
45+
lines to form a single flattened expression. Both `extract_completion_target`
46+
in `completion/target.rs` and `extract_member_access_context` in
47+
`definition/member.rs` now use this helper, so completion and
48+
go-to-definition work correctly on multi-line fluent chains.
6749

6850
### ~~17. Switch statement variable type tracking~~
6951

@@ -72,21 +54,14 @@ that iterates each case's statement list with `conditional = true`.
7254
Both brace-delimited and colon-delimited (`switch(): … endswitch;`) forms
7355
are handled via `switch.body.cases()`.
7456

75-
### 18. `?->` chaining loses intermediate segments
76-
**Priority: Medium**
77-
78-
In `extract_arrow_subject` (`subject_extraction.rs`), when a `?->` is
79-
encountered mid-chain, the code calls `extract_simple_variable` instead
80-
of recursing with `extract_arrow_subject`. The `->` path recurses
81-
correctly, but `?->` does not:
57+
### ~~18. `?->` chaining loses intermediate segments~~
8258

83-
```php
84-
$user->getAddress()?->getCity()-> // extracts "$user?->getCity", loses "->getAddress()"
85-
```
86-
87-
**Fix:** change the `?->` branch to call `extract_arrow_subject(chars, inner_arrow)`
88-
instead of `extract_simple_variable(chars, inner_arrow)`, mirroring what
89-
the `->` branch does.
59+
Resolved. The `->` branch already handled `?->` correctly via the `?`
60+
skip at the top of `extract_arrow_subject`, so the described bug did not
61+
manifest in practice. The `?->` fallback branch was still changed from
62+
`extract_simple_variable` to `extract_arrow_subject` for correctness,
63+
and unit tests were added to `subject_extraction.rs` covering nullsafe
64+
chains with calls, properties, and simple variables.
9065

9166
### 19. `static` return type not resolved to concrete class at call sites
9267
**Priority: Medium**
@@ -156,53 +131,18 @@ chained assignments and some edge cases.
156131
`extract_raw_type_from_assignment_text` (recursively), then looking up
157132
the property on the resulting class.
158133

159-
### 22. Template parameter bounds not used for property type resolution
160-
**Priority: Medium**
161-
162-
When a property's docblock type is a template parameter (e.g. `TNode`),
163-
the resolver treats it as a raw class name and fails to find it. It
164-
should recognise that `TNode` is a `@template` parameter of the
165-
enclosing class and fall back to its upper bound for completion and
166-
definition:
134+
### ~~22. Template parameter bounds not used for property type resolution~~
167135

168-
```php
169-
/**
170-
* @template-covariant TNode of PDependNode
171-
*/
172-
abstract class AbstractNode
173-
{
174-
/**
175-
* @param TNode $node
176-
*/
177-
public function __construct(
178-
private readonly PDependNode $node,
179-
) {
180-
}
181-
182-
public function getParent()
183-
{
184-
$this->node->getParent(); // ← no completion / definition
185-
}
186-
}
187-
```
188-
189-
The native type hint is `PDependNode`, but `@param TNode $node` overrides
190-
it via `resolve_effective_type`. The resolved type becomes `TNode`, which
191-
does not match any class. The resolver should detect that `TNode` is a
192-
template parameter declared on the enclosing class, read its bound
193-
(`of PDependNode`), and use that bound as the effective type.
194-
195-
**Fix:** in the property/parameter type resolution path (likely
196-
`resolve_subject_type` or `type_hint_to_classes` in
197-
`completion/resolver.rs`), when a resolved type string does not match any
198-
known class, check whether it appears in the enclosing class's
199-
`@template` / `@template-covariant` / `@template-contravariant`
200-
declarations. If it does and has an `of` bound, substitute the bound
201-
type. If the template parameter has no bound (bare `@template T`), fall
202-
back to the native type hint instead of the docblock type, since the
203-
native hint is the only concrete type information available. This also
204-
applies to method return types that use bare template parameters without
205-
a concrete substitution available.
136+
Resolved. Added `extract_template_params_with_bounds` to
137+
`docblock/templates.rs` that extracts both the parameter name and its
138+
optional `of` bound. A new `template_param_bounds: HashMap<String, String>`
139+
field on `ClassInfo` stores the bounds, populated during parsing. In
140+
`type_hint_to_classes_depth` in `completion/resolver.rs`, when a type
141+
hint does not match any known class, the resolver now checks whether it
142+
is a template parameter declared on the owning class and, if it has an
143+
`of` bound, resolves the bound type instead. This covers
144+
`@template`, `@template-covariant`, `@template-contravariant`, and
145+
`@phpstan-template` variants.
206146

207147
### 23. Match-expression class-string not forwarded to conditional return types
208148
**Priority: Medium**
@@ -302,17 +242,17 @@ target.
302242

303243
## Go-to-Implementation Gaps
304244

305-
### 5a. Transitive interface inheritance
306-
**Priority: Medium**
307-
308-
If `InterfaceB extends InterfaceA` and `ClassC implements InterfaceB`,
309-
go-to-implementation on `InterfaceA` will not find `ClassC`. The
310-
transitive check in `class_implements_or_extends` walks `parent_class`
311-
chains but does not walk interface-extends chains.
312-
313-
**Fix:** in `class_implements_or_extends`, when checking a class's
314-
`interfaces` list, load each interface via `class_loader` and recursively
315-
check whether it extends the target interface (with a depth bound).
245+
### ~~5a. Transitive interface inheritance~~
246+
247+
Resolved. Added `interface_extends_target` helper in
248+
`definition/implementation.rs` that recursively walks an interface's
249+
parent chain (both `parent_class` and `interfaces` fields) up to
250+
`MAX_INHERITANCE_DEPTH`. `class_implements_or_extends` now calls this
251+
helper for every directly-implemented interface and for interfaces found
252+
on parent classes. Also fixed the interface parser to store all extended
253+
interfaces in the `interfaces` field (not just the first one in
254+
`parent_class`), so multi-extends interfaces like
255+
`interface C extends A, B` are fully represented.
316256

317257
### 5b. Short-name collisions in `find_implementors`
318258
**Priority: Low**

example.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,22 @@
6868
$maybe?->getProfile()?->getDisplayName();
6969

7070

71+
// ── Multi-line Method Chains ────────────────────────────────────────────────
72+
// Try: trigger completion after `->` on continuation lines. The resolver joins
73+
// multi-line chains automatically, so fluent builder patterns work seamlessly.
74+
75+
$user->setName('Bob')
76+
->setStatus(Status::Active)
77+
->getEmail(); // resolves through the full chain
78+
79+
User::query()
80+
->where('active', true)
81+
->where('name', 'Bob'); // static call base + continuations
82+
83+
$maybe?->getProfile()
84+
?->getDisplayName(); // nullsafe continuations
85+
86+
7187
// ── Chained Method Calls in Variable Assignment ─────────────────────────────
7288
// When a variable is assigned from a chained call, the LSP walks the full
7389
// chain to resolve the stored type.
@@ -670,6 +686,34 @@ public function switchInsideCase(string $driver): void
670686
}
671687

672688

689+
// ── Template Parameter Bounds ───────────────────────────────────────────────
690+
// When a property's type is a template parameter (e.g. TNode), the resolver
691+
// falls back to the upper bound declared in @template TNode of SomeClass.
692+
693+
/**
694+
* @template-covariant TNode of AstNode
695+
*/
696+
class TemplateBoundsDemo
697+
{
698+
/** @var TNode */
699+
public $node;
700+
701+
/**
702+
* @param TNode $node
703+
*/
704+
public function __construct(AstNode $node)
705+
{
706+
$this->node = $node;
707+
}
708+
709+
public function demo(): void
710+
{
711+
$this->node->getChildren(); // resolves via TNode's bound: AstNode
712+
$this->node->getParent(); // AstNode::getParent()
713+
}
714+
}
715+
716+
673717
// ── Foreach, Key Types, and Destructuring ───────────────────────────────────
674718

675719
class IterationDemo
@@ -1259,6 +1303,19 @@ public function demo(): void
12591303
// ┃ Everything below exists to support the playground above. ┃
12601304
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12611305

1306+
// ── AST Node (template bounds demo) ────────────────────────────────────────
1307+
1308+
class AstNode
1309+
{
1310+
/** @return AstNode|null */
1311+
public function getParent(): ?AstNode { return null; }
1312+
1313+
/** @return AstNode[] */
1314+
public function getChildren(): array { return []; }
1315+
1316+
public function getType(): string { return ''; }
1317+
}
1318+
12621319
// ── ObjectMapper (method-level @template demo) ──────────────────────────────
12631320

12641321
class ObjectMapper

src/completion/resolver.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -914,7 +914,38 @@ impl Backend {
914914
vec![cls]
915915
}
916916
}
917-
None => vec![],
917+
None => {
918+
// ── Template parameter bound fallback ──────────────────
919+
// When the type hint doesn't match any known class, check
920+
// whether it is a template parameter declared on the
921+
// owning class. If it has an `of` bound (e.g.
922+
// `@template TNode of PDependNode`), resolve the bound
923+
// type so that completion and go-to-definition still work.
924+
let loaded;
925+
let owning = match all_classes.iter().find(|c| c.name == owning_class_name) {
926+
Some(c) => Some(c),
927+
None => {
928+
loaded = class_loader(owning_class_name);
929+
loaded.as_ref()
930+
}
931+
};
932+
933+
// Try class-level template param bounds on the owning class.
934+
if let Some(owner) = owning
935+
&& owner.template_params.contains(&lookup.to_string())
936+
&& let Some(bound) = owner.template_param_bounds.get(lookup)
937+
{
938+
return Self::type_hint_to_classes_depth(
939+
bound,
940+
owning_class_name,
941+
all_classes,
942+
class_loader,
943+
depth + 1,
944+
);
945+
}
946+
947+
vec![]
948+
}
918949
}
919950
}
920951

src/completion/target.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
use tower_lsp::lsp_types::*;
1313

1414
use crate::Backend;
15-
use crate::subject_extraction::{extract_arrow_subject, extract_double_colon_subject};
15+
use crate::subject_extraction::{
16+
collapse_continuation_lines, extract_arrow_subject, extract_double_colon_subject,
17+
};
1618
use crate::types::*;
1719

1820
impl Backend {
@@ -29,9 +31,15 @@ impl Backend {
2931
return None;
3032
}
3133

32-
let line = lines[position.line as usize];
34+
// Collapse multi-line method chains so that continuation lines
35+
// (starting with `->` or `?->`) are joined with preceding lines.
36+
let (line, col) = collapse_continuation_lines(
37+
&lines,
38+
position.line as usize,
39+
position.character as usize,
40+
);
3341
let chars: Vec<char> = line.chars().collect();
34-
let col = (position.character as usize).min(chars.len());
42+
let col = col.min(chars.len());
3543

3644
// Walk backwards past any partial identifier the user may have typed
3745
let mut i = col;

0 commit comments

Comments
 (0)