Skip to content

Commit 0016d24

Browse files
committed
Another bucket of refactoring to compleation and diagnostics
1 parent 45f85ab commit 0016d24

17 files changed

Lines changed: 748 additions & 2542 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6666
- **`@switch`/`@case` with a class-constant case value no longer reports a syntax error.** `@case (Some\Namespaced\Enum::VALUE)` now translates to a valid `case` arm instead of silently corrupting the rest of the file.
6767
- **Generated return types and `@return` tags understand every kind of return expression, not just literals and variables.** Inferring a missing return type now resolves method/function calls, ternaries, matches, property access, and array literals split across multiple lines through the same type engine as hover, instead of degrading to `mixed` (or, for multi-line array literals, to a coarser `array<mixed>`) for anything beyond a simple literal, `new`, or a plain variable.
6868
- **Calls to functions declared in another `namespace` block of the same file resolve their return type.** In a file that declares more than one `namespace`, a call to a function from a later block used to leave the returned value untyped unless the function carried an `@return` docblock. The call and its return type now resolve regardless, so completion, hover, and diagnostics see the value's type.
69+
- **`@template` bindings resolve correctly when a call uses named arguments.** A generic function or method called with named arguments (for example `process(class: Product::class, flag: true, function ($p) { ... })`) previously bound its template parameter from the wrong argument whenever the named arguments were out of declaration order, leaving closure parameters untyped. Named arguments now route to the parameter they actually target.
6970

7071
## [0.9.0] - 2026-07-20
7172

docs/todo/bugs.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,59 @@ pipeline so it produces correct data. Downstream consumers
77
(diagnostics, hover, completion, definition) should never need
88
to second-guess upstream output.
99

10-
No outstanding items.
10+
## B115. Closure-parameter inference uses call-position instead of the declared parameter index, so named arguments that reorder a call break it
11+
12+
**Severity: Low-Medium (closure/arrow-function parameters silently lose
13+
their inferred type — falling back to no completions/hover — for any
14+
call that uses named arguments to reorder or skip parameters ahead of
15+
the callable argument) · Discovered while deduplicating the receiver
16+
resolution helpers shared between `closure_resolution.rs` and
17+
`forward_walk/callable_inference.rs`; confirmed with a regression test**
18+
19+
```php
20+
/**
21+
* @template T
22+
* @param bool $flag
23+
* @param class-string<T> $class
24+
* @param callable(T): void $cb
25+
*/
26+
function process(bool $flag, string $class, callable $cb): void {}
27+
28+
process(class: Product::class, flag: true, function ($p) {
29+
$p-> // no completions: T never binds to Product
30+
});
31+
```
32+
33+
The forward walker's closure-argument inference
34+
(`walk_closures_in_call_args` and `walk_closure_in_partial_call_args` in
35+
`src/completion/variable/forward_walk/diagnostic_walk.rs`, and the
36+
completion/hover-path counterpart `infer_callable_params_for_call` in
37+
`src/completion/variable/forward_walk/callable_inference.rs`) computes
38+
`arg_idx` from `arguments.iter().enumerate()` — the argument's position
39+
in the *call*. That index is then used directly as the *declared*
40+
parameter index, both to decide whether the argument at that slot is a
41+
closure and to look up `fi.parameters[arg_idx]` /
42+
`class.get_method(..).parameters[arg_idx]` for the callable's own
43+
`callable(...)`/`Closure(...)` type hint (see
44+
`extract_callable_params_at_fw` and its callers).
45+
46+
PHP argument binding does not guarantee call-position equals declared
47+
position once named arguments are involved: a named argument can appear
48+
anywhere in the call, and named arguments before the closure can be
49+
reordered or a preceding optional parameter can be omitted entirely.
50+
Whenever that happens, `arg_idx` no longer identifies the closure's own
51+
parameter, so `extract_callable_params_at_fw` looks up the wrong
52+
parameter (typically a non-callable one), returns no callable param
53+
types, and the closure gets none of its parameters inferred — even
54+
though the same call resolves correctly when arguments are positional
55+
and in declaration order.
56+
57+
Fix by resolving each argument's *declared* parameter index (via
58+
something like the existing `bind_text_args_to_params` /
59+
`param_name_bare` machinery in `src/call_args.rs`, which already knows
60+
how to route named arguments to their declared slot) before using that
61+
index to look up the callable's own parameter type or to decide which
62+
argument is "the closure at position N". This affects both the
63+
diagnostic-path walker (`diagnostic_walk.rs`) and the completion/hover
64+
path (`callable_inference.rs`), which must stay in sync per the
65+
project's shared-pipeline convention.

docs/todo/refactor.md

Lines changed: 46 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -214,79 +214,54 @@ Each item must include:
214214

215215
# Outstanding items
216216

217-
## Deduplicate parallel helpers inside the resolution pipeline
218-
219-
**What to do.** The resolution files contain several
220-
copies-for-another-code-path that should collapse onto one
221-
implementation (do this after — or as part of — the splits above):
222-
223-
1. **Callable-param inference.** The `*_fw`-suffixed family in
224-
`forward_walk/` parallels the logic in
225-
`completion/variable/closure_resolution.rs`. The suffix itself marks
226-
a copy; unify them.
227-
2. **`$this`/`self`/`static` resolution.** ~32 call sites spread across
228-
`util.rs` (`is_self_or_static`, `resolve_class_keyword`),
229-
`call_resolution/callable_target.rs` (`resolve_class_name_keyword`),
230-
`resolver/mod.rs` (`resolve_static_owner_class`), and `forward_walk/`
231-
(`seed_this`), plus hand-rolled `== "$this"` checks. Back them with
232-
one helper module.
233-
3. **Subclass checks.** `is_subclass_of` (`forward_walk/`),
234-
`is_type_subclass_of` and `is_valid_virtual_narrowing`
235-
(`call_resolution/return_types.rs`), and `util::is_subtype_of*`
236-
overlap; route through the `util`/`php_type` versions.
237-
4. **Property-assignment scanning.** The
238-
`find_*_this_property_assignment*` family
239-
(`rhs_resolution/property_access.rs`) and the
240-
`walk_property_narrowing_*` family (`resolver/property_narrowing.rs`)
241-
walk class members and statements with near-identical skeletons for
242-
different outputs. Share the traversal.
243-
5. **Argument-text extraction.** `extract_argument_texts_fw` /
244-
`extract_first_arg_string_fw` (`forward_walk/`) vs
245-
`extract_first_arg_text` / `resolve_inline_arg_raw_type`
246-
(`call_resolution/arg_type_resolution.rs`) vs
247-
`resolve_arg_text_to_type` (`call_resolution/template_subs.rs`) vs
248-
`resolve_arg_raw_type` (`resolution.rs`).
249-
250-
**Why it matters.** These duplications are exactly the "parallel type
251-
resolution systems" the conventions forbid, just internal to the
252-
pipeline: a narrowing or generics fix applied to one copy silently
253-
misses the others.
254-
255-
---
256-
257-
## Shared AST walker for the hand-rolled traversals
258-
259-
**What to do.** At least six modules hand-roll the same giant
260-
`match` over `Statement`/`Expression` variants, each independently
261-
re-typing the `IfBody`/`ForeachBody`/`WhileBody`/`SwitchBody` recursion:
262-
`symbol_map/extraction.rs` (28 statement / 101 expression matches),
263-
`selection_range.rs` (33/34), the anonymous-class walker in
264-
`parser/classes.rs` (33/36), `completion/types/narrowing.rs` (64
265-
expression matches), and the six boolean/name-set detector walker pairs
266-
in `diagnostics/undefined_variables.rs` (dynamic-vars, extract(),
267-
compact(), get_defined_vars(), `@`-suppression, isset/empty guards)
268-
plus the structural halves of `unused_variables.rs` and
269-
`type_errors.rs`.
270-
271-
`mago-syntax` already ships a generated `Walker` trait
272-
(`walker/mod.rs`, per-node `walk_in_*`/`walk_out_*` hooks with full
273-
recursive traversal) that none of these use. Migrate incrementally,
274-
starting where the payoff is largest and the risk lowest:
275-
276-
1. The six detector pairs in `undefined_variables.rs` — each becomes a
277-
~10-line visitor, removing ~1,400 lines of traversal boilerplate.
278-
2. The statement-dispatch halves of `unused_variables.rs` and
279-
`type_errors.rs`.
280-
3. The anonymous-class walker in `parser/classes.rs` (~1,000 lines).
281-
4. `symbol_map/extraction.rs` and `selection_range.rs` as follow-ups.
282-
283-
The forward walker is explicitly out of scope here (its traversal is
284-
interleaved with scope-state mutation; see the split item above).
217+
## Shared AST walker — remaining candidates (needs a decision)
218+
219+
**Done so far.** The stateless boolean/name-set/collection walkers have
220+
been migrated to the generated `mago-syntax` `Walker` trait (each is now
221+
a small visitor over a shared traversal): the six detector pairs in
222+
`diagnostics/undefined_variables/` (dynamic-vars, `extract()`,
223+
`compact()`, `get_defined_vars()`, `@`-suppression, isset/empty guards),
224+
the statement-dispatch halves of `diagnostics/unused_variables.rs` and
225+
`diagnostics/type_errors.rs`, and the anonymous-class walker in
226+
`parser/anonymous.rs`. That removed ~1,900 lines of hand-rolled
227+
traversal.
228+
229+
**What remains — and why it is not a clean fit.** The remaining
230+
hand-rolled walkers are *not* stateless collectors; they are
231+
positional/order-sensitive, which the typed `Walker` cannot express
232+
without re-typing per-node logic (it has no single "visit any node"
233+
hook, only per-node-type `walk_in_*`):
234+
235+
- `selection_range.rs` pushes **synthetic** spans (brace pairs, paren
236+
pairs, block interiors) that are not AST-node spans, and would need a
237+
`walk_in_*` override for essentially every node type to reproduce
238+
them — no net reduction.
239+
- `symbol_map/extraction.rs` records position-keyed symbol spans and
240+
subject text; order and per-kind span shaping dominate, so the same
241+
per-node-override problem applies.
242+
- The `property_access.rs` / `property_narrowing.rs` pair walk **from
243+
the method start down to the cursor**, accumulating narrowing state in
244+
source order with early returns. That interleaved-mutation,
245+
cursor-bounded shape is exactly why the forward walker is out of scope
246+
(below).
247+
248+
The forward walker is explicitly out of scope (its traversal is
249+
interleaved with scope-state mutation).
250+
251+
**Decision needed.** These three are arguably better left as-is: the
252+
`Walker` migration's payoff (delete child-dispatch boilerplate) does not
253+
apply when the walk pushes synthetic/positional data or mutates state in
254+
source order. If the goal is purely "one traversal so new AST variants
255+
can't create blind spots," a thin `visit_all_nodes(&Node, &mut FnMut)`
256+
helper over mago's `Node` enum would serve `selection_range` and
257+
`symbol_map/extraction` better than the typed `Walker`. Confirm whether
258+
to (a) leave these as-is, (b) migrate `selection_range` /
259+
`symbol_map/extraction` via a `Node`-enum visitor, or (c) migrate the
260+
property pair despite the state-threading cost.
285261

286262
**Why it matters.** Every new mago AST node variant (new PHP syntax)
287-
currently needs matching arms added in six places; missing one produces
288-
silent blind spots in exactly one feature. One traversal, many small
289-
visitors is the structure all three reference projects use.
263+
still needs matching arms in these remaining walkers; missing one
264+
produces a silent blind spot in exactly one feature.
290265

291266
---
292267

src/completion/call_resolution/template_subs.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -632,22 +632,21 @@ impl Backend {
632632
{
633633
// self::class / static::class / parent::class resolve relative
634634
// to the class at the call site.
635-
let class_named =
636-
if name.eq_ignore_ascii_case("self") || name.eq_ignore_ascii_case("static") {
637-
ctx.current_class
638-
.map(|c| PhpType::Named(c.fqn().to_string()))
639-
} else if name.eq_ignore_ascii_case("parent") {
640-
ctx.current_class
641-
.and_then(|c| c.parent_class.as_ref())
642-
.map(|p| PhpType::Named(p.to_string()))
635+
let class_named = if is_self_or_static(name) {
636+
ctx.current_class
637+
.map(|c| PhpType::Named(c.fqn().to_string()))
638+
} else if name.eq_ignore_ascii_case("parent") {
639+
ctx.current_class
640+
.and_then(|c| c.parent_class.as_ref())
641+
.map(|p| PhpType::Named(p.to_string()))
642+
} else {
643+
let resolved_name = if let Some(cls) = (ctx.class_loader)(name) {
644+
cls.fqn().to_string()
643645
} else {
644-
let resolved_name = if let Some(cls) = (ctx.class_loader)(name) {
645-
cls.fqn().to_string()
646-
} else {
647-
name.to_string()
648-
};
649-
Some(PhpType::Named(resolved_name))
646+
name.to_string()
650647
};
648+
Some(PhpType::Named(resolved_name))
649+
};
651650
return class_named.map(|n| PhpType::ClassString(Some(Box::new(n))));
652651
}
653652

src/completion/eloquent_string.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ impl Backend {
382382
// Static call: `User::with(...)` — subject is the class name.
383383
let fqn = self.resolve_class_name_to_fqn(subject, ctx)?;
384384
class_loader(&fqn)
385-
} else if subject == "$this" || subject == "static" || subject == "self" {
385+
} else if crate::class_lookup::is_self_or_static(subject) {
386386
// Inside the model class itself.
387387
let cursor_offset = position_to_offset(content, position);
388388
let current_class =

src/completion/resolver/property_narrowing.rs

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -126,22 +126,10 @@ fn walk_property_narrowing_in_members<'b>(
126126
ctx: &VarResolutionCtx<'_>,
127127
results: &mut Vec<ClassInfo>,
128128
) {
129-
use mago_syntax::cst::class_like::member::ClassLikeMember;
130-
use mago_syntax::cst::class_like::method::MethodBody;
131-
132-
for member in members {
133-
if let ClassLikeMember::Method(method) = member {
134-
let body = match &method.body {
135-
MethodBody::Concrete(block) => block,
136-
_ => continue,
137-
};
138-
let body_start = body.left_brace.start.offset;
139-
let body_end = body.right_brace.end.offset;
140-
if ctx.cursor_offset >= body_start && ctx.cursor_offset <= body_end {
141-
walk_property_narrowing_stmts(body.statements.iter(), ctx, results);
142-
return;
143-
}
144-
}
129+
if let Some(block) =
130+
crate::util::find_enclosing_method_block_in_members(members, ctx.cursor_offset)
131+
{
132+
walk_property_narrowing_stmts(block.statements.iter(), ctx, results);
145133
}
146134
}
147135

src/completion/variable/closure_resolution.rs

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -496,32 +496,82 @@ fn closure_this_from_function_params(
496496
resolve_closure_this_type(php_type, None, ctx)
497497
}
498498

499-
/// Look up `closure_this_type` on an instance method's parameter at
500-
/// `arg_idx`, resolving the receiver from the source span.
501-
fn closure_this_from_receiver(
499+
// ─── Shared receiver resolution ─────────────────────────────────────────
500+
//
501+
// These helpers resolve "the receiver/owner class of a call's method"
502+
// from the AST, independent of what the caller extracts from the target
503+
// method once found. Both the `@param-closure-this` resolution above
504+
// and the callable-parameter inference in
505+
// `forward_walk/callable_inference.rs` need this same receiver
506+
// resolution; they differ only in what they pull out of the matched
507+
// method (`closure_this_type` vs. `callable(...)` parameter types).
508+
509+
/// Resolve the receiver classes of an instance/nullsafe method call from
510+
/// its object span.
511+
///
512+
/// Overrides `cursor_offset` to the object's start so that variable
513+
/// resolution looks up the scope snapshot *before* the call (where the
514+
/// receiver variable is actually in scope), not at a position inside a
515+
/// later closure argument where the variable doesn't exist yet.
516+
pub(in crate::completion) fn resolve_receiver_types(
502517
obj_start: u32,
503518
obj_end: u32,
504-
method_name: &str,
505-
arg_idx: usize,
506519
ctx: &ResolutionCtx<'_>,
507-
) -> Option<ClassInfo> {
520+
) -> Vec<ResolvedType> {
508521
let start = obj_start as usize;
509522
let end = obj_end as usize;
510523
if end > ctx.content.len() {
511-
return None;
524+
return vec![];
512525
}
513526
let obj_text = ctx.content[start..end].trim();
514-
// Use the object's own offset as cursor_offset so that variable
515-
// resolution looks up the scope snapshot *before* the closure body
516-
// (where the variable is actually in scope), not at the diagnostic
517-
// offset inside the closure where the variable doesn't exist.
518527
let obj_ctx = ResolutionCtx {
519528
cursor_offset: obj_start,
520529
..*ctx
521530
};
522-
let receiver_classes = ResolvedType::into_arced_classes(
523-
crate::completion::resolver::resolve_target_classes(obj_text, AccessKind::Arrow, &obj_ctx),
524-
);
531+
crate::completion::resolver::resolve_target_classes(obj_text, AccessKind::Arrow, &obj_ctx)
532+
}
533+
534+
/// Resolve the class name referenced by a static-call receiver
535+
/// expression (`self`, `static`, `parent`, or a class identifier).
536+
pub(in crate::completion) fn static_receiver_class_name(
537+
class_expr: &Expression<'_>,
538+
current_class: Option<&ClassInfo>,
539+
) -> Option<String> {
540+
match class_expr {
541+
Expression::Self_(_) | Expression::Static(_) => current_class.map(|cc| cc.name.to_string()),
542+
Expression::Identifier(ident) => Some(bytes_to_str(ident.value()).to_string()),
543+
Expression::Parent(_) => {
544+
current_class.and_then(|cc| cc.parent_class.map(|a| a.to_string()))
545+
}
546+
_ => None,
547+
}
548+
}
549+
550+
/// Look up a class by name: local classes first, then the cross-file
551+
/// loader.
552+
pub(in crate::completion) fn find_owner_by_name(
553+
name: &str,
554+
all_classes: &[Arc<ClassInfo>],
555+
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
556+
) -> Option<ClassInfo> {
557+
all_classes
558+
.iter()
559+
.find(|c| c.name == name)
560+
.map(|c| ClassInfo::clone(c))
561+
.or_else(|| class_loader(name).map(Arc::unwrap_or_clone))
562+
}
563+
564+
/// Look up `closure_this_type` on an instance method's parameter at
565+
/// `arg_idx`, resolving the receiver from the source span.
566+
fn closure_this_from_receiver(
567+
obj_start: u32,
568+
obj_end: u32,
569+
method_name: &str,
570+
arg_idx: usize,
571+
ctx: &ResolutionCtx<'_>,
572+
) -> Option<ClassInfo> {
573+
let receiver_classes =
574+
ResolvedType::into_arced_classes(resolve_receiver_types(obj_start, obj_end, ctx));
525575
for cls in &receiver_classes {
526576
let resolved = crate::virtual_members::resolve_class_fully_maybe_cached(
527577
cls,
@@ -546,16 +596,7 @@ fn closure_this_from_static_receiver(
546596
arg_idx: usize,
547597
ctx: &ResolutionCtx<'_>,
548598
) -> Option<ClassInfo> {
549-
let class_name = match class_expr {
550-
Expression::Self_(_) | Expression::Static(_) => {
551-
ctx.current_class.map(|cc| cc.name.to_string())
552-
}
553-
Expression::Identifier(ident) => Some(bytes_to_str(ident.value()).to_string()),
554-
Expression::Parent(_) => ctx
555-
.current_class
556-
.and_then(|cc| cc.parent_class.map(|a| a.to_string())),
557-
_ => None,
558-
}?;
599+
let class_name = static_receiver_class_name(class_expr, ctx.current_class)?;
559600

560601
if method_name.eq_ignore_ascii_case("macro")
561602
&& arg_idx == 1
@@ -571,12 +612,7 @@ fn closure_this_from_static_receiver(
571612
));
572613
}
573614

574-
let owner = ctx
575-
.all_classes
576-
.iter()
577-
.find(|c| c.name == class_name)
578-
.map(|c| ClassInfo::clone(c))
579-
.or_else(|| (ctx.class_loader)(&class_name).map(Arc::unwrap_or_clone))?;
615+
let owner = find_owner_by_name(&class_name, ctx.all_classes, ctx.class_loader)?;
580616

581617
let resolved = crate::virtual_members::resolve_class_fully_maybe_cached(
582618
&owner,

0 commit comments

Comments
 (0)