Skip to content

Commit e7ebe94

Browse files
committed
Add support iterable value type and some basic generics
1 parent e1a8916 commit e7ebe94

7 files changed

Lines changed: 1016 additions & 17 deletions

File tree

example.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,3 +648,25 @@ function handleIntersection(User&Loggable $entity): void
648648
} else {
649649
$l->grantPermission('y'); // negated else → AdminUser
650650
}
651+
652+
653+
// ── Generics / Foreach Element Types ────────────────────────────────────────
654+
655+
/** @var list<User> $users */
656+
$users = getUnknownValue();
657+
foreach ($users as $user) {
658+
$user->getEmail(); // $user resolved to User via list<User>
659+
$user->getName();
660+
}
661+
662+
/** @var User[] $members */
663+
$members = getUnknownValue();
664+
foreach ($members as $member) {
665+
$member->getStatus(); // $member resolved to User via User[]
666+
}
667+
668+
/** @var array<int, AdminUser> $admins */
669+
$admins = getUnknownValue();
670+
foreach ($admins as $admin) {
671+
$admin->grantPermission('x'); // $admin resolved to AdminUser via array<int, AdminUser>
672+
}

src/completion/variable_resolution.rs

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
/// - Conditional branches (if/else, try/catch, loops) — collects all
1111
/// possible types when the variable is assigned differently in each
1212
/// branch.
13+
/// - Foreach value variables: when iterating over a variable annotated
14+
/// with a generic iterable type (e.g. `@var list<User>`, `@param
15+
/// list<User>`, `User[]`), the foreach value variable is resolved to
16+
/// the element type.
1317
///
1418
/// Type narrowing (instanceof, assert, custom type guards) is delegated
1519
/// to the [`super::type_narrowing`] module. Closure/arrow-function scope
@@ -471,19 +475,33 @@ impl Backend {
471475
}
472476
}
473477
},
474-
Statement::Foreach(foreach) => match &foreach.body {
475-
ForeachBody::Statement(inner) => {
476-
Self::check_statement_for_assignments(inner, ctx, results, true);
477-
}
478-
ForeachBody::ColonDelimited(body) => {
479-
Self::walk_statements_for_assignments(
480-
body.statements.iter(),
481-
ctx,
482-
results,
483-
true,
484-
);
478+
Statement::Foreach(foreach) => {
479+
// ── Foreach value type from generic iterables ────
480+
// When the variable we're resolving is the foreach
481+
// *value* variable, try to infer its type from the
482+
// iterated expression's generic type annotation.
483+
//
484+
// Example:
485+
// /** @var list<User> $users */
486+
// foreach ($users as $user) { $user-> }
487+
//
488+
// Here `$user` is resolved to `User`.
489+
Self::try_resolve_foreach_value_type(foreach, ctx, results, conditional);
490+
491+
match &foreach.body {
492+
ForeachBody::Statement(inner) => {
493+
Self::check_statement_for_assignments(inner, ctx, results, true);
494+
}
495+
ForeachBody::ColonDelimited(body) => {
496+
Self::walk_statements_for_assignments(
497+
body.statements.iter(),
498+
ctx,
499+
results,
500+
true,
501+
);
502+
}
485503
}
486-
},
504+
}
487505
Statement::While(while_stmt) => match &while_stmt.body {
488506
WhileBody::Statement(inner) => {
489507
// ── instanceof narrowing for while-body ──
@@ -575,6 +593,85 @@ impl Backend {
575593
}
576594

577595
/// Helper: treat a single statement as an iterator of one and recurse.
596+
/// Try to resolve the foreach value variable's type from a generic
597+
/// iterable annotation on the iterated expression.
598+
///
599+
/// When the variable being resolved (`ctx.var_name`) matches the
600+
/// foreach value variable and the iterated expression is a simple
601+
/// `$variable` whose type is annotated as a generic iterable (via
602+
/// `@var list<User> $var` or `@param list<User> $var`), this method
603+
/// extracts the element type and pushes the resolved `ClassInfo` into
604+
/// `results`.
605+
fn try_resolve_foreach_value_type<'b>(
606+
foreach: &'b Foreach<'b>,
607+
ctx: &VarResolutionCtx<'_>,
608+
results: &mut Vec<ClassInfo>,
609+
conditional: bool,
610+
) {
611+
// Check if the foreach value variable is the one we're resolving.
612+
let value_expr = foreach.target.value();
613+
let value_var_name = match value_expr {
614+
Expression::Variable(Variable::Direct(dv)) => dv.name.to_string(),
615+
_ => return,
616+
};
617+
if value_var_name != ctx.var_name {
618+
return;
619+
}
620+
621+
// Extract the iterated expression's source text.
622+
let expr_span = foreach.expression.span();
623+
let expr_start = expr_span.start.offset as usize;
624+
let expr_end = expr_span.end.offset as usize;
625+
let expr_text = match ctx.content.get(expr_start..expr_end) {
626+
Some(t) => t.trim(),
627+
None => return,
628+
};
629+
630+
// Currently we handle simple `$variable` expressions.
631+
if !expr_text.starts_with('$') || expr_text.contains("->") || expr_text.contains("::") {
632+
return;
633+
}
634+
635+
// Search backward from the foreach for @var or @param annotations
636+
// on the iterated variable that include a generic type.
637+
let foreach_offset = foreach.foreach.span().start.offset as usize;
638+
let raw_type = match docblock::find_iterable_raw_type_in_source(
639+
ctx.content,
640+
foreach_offset,
641+
expr_text,
642+
) {
643+
Some(t) => t,
644+
None => return,
645+
};
646+
647+
// Extract the generic element type (e.g. `list<User>` → `User`).
648+
let element_type = match docblock::types::extract_generic_value_type(&raw_type) {
649+
Some(t) => t,
650+
None => return,
651+
};
652+
653+
// Resolve the element type to ClassInfo.
654+
let resolved = Self::type_hint_to_classes(
655+
&element_type,
656+
&ctx.current_class.name,
657+
ctx.all_classes,
658+
ctx.class_loader,
659+
);
660+
661+
if resolved.is_empty() {
662+
return;
663+
}
664+
665+
if !conditional {
666+
results.clear();
667+
}
668+
for cls in resolved {
669+
if !results.iter().any(|c| c.name == cls.name) {
670+
results.push(cls);
671+
}
672+
}
673+
}
674+
578675
pub(super) fn check_statement_for_assignments<'b>(
579676
stmt: &'b Statement<'b>,
580677
ctx: &VarResolutionCtx<'_>,

src/docblock/mod.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@ pub(crate) mod types;
3636

3737
// Tags
3838
pub use tags::{
39-
extract_method_tags, extract_mixin_tags, extract_property_tags, extract_return_type,
40-
extract_type_assertions, extract_var_type, extract_var_type_with_name,
41-
find_inline_var_docblock, get_docblock_text_for_node, has_deprecated_tag,
42-
resolve_effective_type, should_override_type,
39+
extract_method_tags, extract_mixin_tags, extract_param_raw_type, extract_property_tags,
40+
extract_return_type, extract_type_assertions, extract_var_type, extract_var_type_with_name,
41+
find_inline_var_docblock, find_iterable_raw_type_in_source, find_var_raw_type_in_source,
42+
get_docblock_text_for_node, has_deprecated_tag, resolve_effective_type, should_override_type,
4343
};
4444

4545
// Conditional return types
4646
pub use conditional::extract_conditional_return_type;
4747

4848
// Type utilities
49-
pub use types::clean_type;
49+
pub use types::{clean_type, extract_generic_value_type};

src/docblock/tags.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,163 @@ pub fn find_inline_var_docblock(
279279
extract_var_type_with_name(docblock)
280280
}
281281

282+
/// Search backward through `content` (up to `before_offset`) for any
283+
/// `/** @var RawType $var_name */` annotation and return the **raw**
284+
/// (uncleaned) type string — including generic parameters like `<User>`.
285+
///
286+
/// This is used by foreach element-type resolution: when iterating over
287+
/// a variable annotated as `list<User>`, we need the raw `list<User>`
288+
/// string so that the generic value type (`User`) can be extracted.
289+
///
290+
/// Only matches annotations that explicitly name the variable
291+
/// (e.g. `/** @var list<User> $users */`).
292+
pub fn find_var_raw_type_in_source(
293+
content: &str,
294+
before_offset: usize,
295+
var_name: &str,
296+
) -> Option<String> {
297+
let search_area = content.get(..before_offset)?;
298+
299+
for line in search_area.lines().rev() {
300+
let trimmed = line.trim();
301+
302+
// Quick reject: must mention both `@var` and the variable.
303+
if !trimmed.contains("@var") || !trimmed.contains(var_name) {
304+
continue;
305+
}
306+
307+
// Strip docblock delimiters — handles single-line `/** @var … */`.
308+
let inner = trimmed
309+
.strip_prefix("/**")
310+
.unwrap_or(trimmed)
311+
.strip_suffix("*/")
312+
.unwrap_or(trimmed);
313+
let inner = inner.trim().trim_start_matches('*').trim();
314+
315+
if let Some(rest) = inner.strip_prefix("@var") {
316+
let rest = rest.trim_start();
317+
if rest.is_empty() {
318+
continue;
319+
}
320+
321+
// Extract the full type token (respects `<…>` nesting).
322+
let (type_token, remainder) = split_type_token(rest);
323+
324+
// The next token must be our variable name.
325+
if let Some(name) = remainder.split_whitespace().next()
326+
&& name == var_name
327+
{
328+
return Some(type_token.to_string());
329+
}
330+
}
331+
}
332+
333+
None
334+
}
335+
336+
/// Extract the raw (uncleaned) type from a `@param` tag for a specific
337+
/// parameter in a docblock string.
338+
///
339+
/// Given a docblock and a parameter name (with `$` prefix), returns the
340+
/// raw type string including generic parameters.
341+
///
342+
/// Example:
343+
/// docblock containing `@param list<User> $users` with var_name `"$users"`
344+
/// → `Some("list<User>")`
345+
pub fn extract_param_raw_type(docblock: &str, var_name: &str) -> Option<String> {
346+
let inner = docblock
347+
.trim()
348+
.strip_prefix("/**")
349+
.unwrap_or(docblock)
350+
.strip_suffix("*/")
351+
.unwrap_or(docblock);
352+
353+
for line in inner.lines() {
354+
let trimmed = line.trim().trim_start_matches('*').trim();
355+
356+
if let Some(rest) = trimmed.strip_prefix("@param") {
357+
let rest = rest.trim_start();
358+
if rest.is_empty() {
359+
continue;
360+
}
361+
362+
// Extract the full type token (respects `<…>` nesting).
363+
let (type_token, remainder) = split_type_token(rest);
364+
365+
// The next token should be the parameter name.
366+
if let Some(name) = remainder.split_whitespace().next()
367+
&& name == var_name
368+
{
369+
return Some(type_token.to_string());
370+
}
371+
}
372+
}
373+
374+
None
375+
}
376+
377+
/// Search backward through `content` (up to `before_offset`) for any
378+
/// `@var` or `@param` annotation that assigns a raw (uncleaned) type to
379+
/// `$var_name`.
380+
///
381+
/// This combines the logic of [`find_var_raw_type_in_source`] (which looks
382+
/// for `@var Type $var`) and a backward scan for `@param Type $var` in
383+
/// method/function docblocks.
384+
///
385+
/// Returns the first matching raw type string (including generic parameters
386+
/// like `list<User>`), or `None` if no annotation is found.
387+
pub fn find_iterable_raw_type_in_source(
388+
content: &str,
389+
before_offset: usize,
390+
var_name: &str,
391+
) -> Option<String> {
392+
let search_area = content.get(..before_offset)?;
393+
394+
for line in search_area.lines().rev() {
395+
let trimmed = line.trim();
396+
397+
// Quick reject: must mention the variable name.
398+
if !trimmed.contains(var_name) {
399+
continue;
400+
}
401+
402+
// Strip docblock delimiters — handles single-line `/** @var … */`
403+
// and multi-line `* @param …` lines.
404+
let inner = trimmed
405+
.strip_prefix("/**")
406+
.unwrap_or(trimmed)
407+
.strip_suffix("*/")
408+
.unwrap_or(trimmed);
409+
let inner = inner.trim().trim_start_matches('*').trim();
410+
411+
// Try @var first, then @param.
412+
let rest = if let Some(r) = inner.strip_prefix("@var") {
413+
Some(r)
414+
} else {
415+
inner.strip_prefix("@param")
416+
};
417+
418+
if let Some(rest) = rest {
419+
let rest = rest.trim_start();
420+
if rest.is_empty() {
421+
continue;
422+
}
423+
424+
// Extract the full type token (respects `<…>` nesting).
425+
let (type_token, remainder) = split_type_token(rest);
426+
427+
// The next token must be our variable name.
428+
if let Some(name) = remainder.split_whitespace().next()
429+
&& name == var_name
430+
{
431+
return Some(type_token.to_string());
432+
}
433+
}
434+
}
435+
436+
None
437+
}
438+
282439
/// Extract all `@property` tags from a class-level docblock.
283440
///
284441
/// PHPDoc `@property` tags declare magic properties that are accessible via

0 commit comments

Comments
 (0)