Skip to content

Commit 1342f93

Browse files
committed
Spread Operator Type Tracking
1 parent daf4c76 commit 1342f93

7 files changed

Lines changed: 1182 additions & 5 deletions

File tree

example.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,44 @@ function unionDemo(string|int $value, ?User $maybe): User|null { return $maybe;
478478
$fromClosure->getEmail(); // $result = $fn() resolves return type
479479

480480

481+
// ── Spread Operator Type Tracking ───────────────────────────────────────────
482+
// When array literals contain spread expressions (`...$var`), element types
483+
// are resolved from the spread variable's iterable annotation and merged.
484+
485+
/** @var list<User> $users */
486+
$users = [];
487+
/** @var list<AdminUser> $admins */
488+
$admins = [];
489+
490+
// Single spread — preserves element type:
491+
$allUsers = [...$users];
492+
$allUsers[0]->getEmail(); // resolves User from list<User>
493+
494+
// Multiple spreads — union of element types:
495+
$everyone = [...$users, ...$admins];
496+
$everyone[0]->getEmail(); // resolves User|AdminUser
497+
498+
// Works with array<K,V> and Type[] annotations too:
499+
/** @var array<int, User> $indexed */
500+
$indexed = [];
501+
$copy = [...$indexed];
502+
$copy[0]->getName(); // resolves User from array<int, User>
503+
504+
/** @var User[] $typed */
505+
$typed = [];
506+
$merged = [...$typed];
507+
$merged[0]->getEmail(); // resolves User from User[]
508+
509+
// Spread combined with push-style assignments:
510+
$mixed = [...$users];
511+
$mixed[] = new AdminUser('root', 'root@example.com');
512+
$mixed[0]->getName(); // resolves User|AdminUser
513+
514+
// Works with array() syntax too:
515+
$legacy = array(...$users, ...$admins);
516+
$legacy[0]->getEmail(); // resolves User|AdminUser
517+
518+
481519
// ── Multi-line @return & Broken Docblock Recovery ───────────────────────────
482520

483521
$collection = collect([]);

src/completion/array_shape.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,13 +581,41 @@ impl Backend {
581581
// and push-style `$var[] = expr;` assignments.
582582
let base_entries = parse_array_literal_entries(rhs_text);
583583

584+
// Extract spread element types from the array literal (e.g.
585+
// `[...$users, ...$admins]` → resolve each spread variable's
586+
// iterable element type).
587+
let spread_types = extract_spread_expressions(rhs_text)
588+
.unwrap_or_default()
589+
.iter()
590+
.filter_map(|expr| {
591+
// Only resolve bare variable expressions for now.
592+
if !expr.starts_with('$') {
593+
return None;
594+
}
595+
let raw = self.resolve_variable_raw_type(
596+
expr,
597+
content,
598+
cursor_offset,
599+
classes,
600+
file_use_map,
601+
file_namespace,
602+
)?;
603+
docblock::extract_iterable_element_type(&raw)
604+
})
605+
.collect::<Vec<_>>();
606+
584607
// Scan for incremental `$var['key'] = expr;` assignments.
585608
let after_assign = assign_pos + assign_pattern.len() + semi_pos + 1; // past the `;`
586609
let incremental =
587610
collect_incremental_key_assignments(var_name, content, after_assign, cursor_offset);
588611

589612
// Scan for push-style `$var[] = expr;` assignments.
590-
let push_types = collect_push_assignments(var_name, content, after_assign, cursor_offset);
613+
let mut push_types =
614+
collect_push_assignments(var_name, content, after_assign, cursor_offset);
615+
616+
// Merge spread element types into push types so they participate
617+
// in the `list<…>` inference.
618+
push_types.extend(spread_types);
591619

592620
if base_entries.is_some() || !incremental.is_empty() || !push_types.is_empty() {
593621
let mut entries: Vec<(String, String)> = base_entries.unwrap_or_default();
@@ -1027,6 +1055,52 @@ pub(super) fn parse_array_literal_entries(rhs: &str) -> Option<Vec<(String, Stri
10271055
Some(entries)
10281056
}
10291057

1058+
/// Extract spread expressions from an array literal.
1059+
///
1060+
/// Given an array literal like `[...$users, 'key' => 'val', ...$admins]`,
1061+
/// this returns `Some(vec!["$users", "$admins"])`.
1062+
///
1063+
/// Only elements that start with `...` are collected. Keyed entries and
1064+
/// non-spread positional entries are ignored.
1065+
///
1066+
/// Returns `None` if `rhs` is not an array literal, or `Some(vec![])` if
1067+
/// it is an array literal but contains no spread elements.
1068+
pub fn extract_spread_expressions(rhs: &str) -> Option<Vec<String>> {
1069+
let inner = if rhs.starts_with('[') && rhs.ends_with(']') {
1070+
&rhs[1..rhs.len() - 1]
1071+
} else {
1072+
let lower = rhs.to_ascii_lowercase();
1073+
if lower.starts_with("array(") && rhs.ends_with(')') {
1074+
&rhs[6..rhs.len() - 1]
1075+
} else {
1076+
return None;
1077+
}
1078+
};
1079+
1080+
let inner = inner.trim();
1081+
if inner.is_empty() {
1082+
return Some(vec![]);
1083+
}
1084+
1085+
let parts = split_array_literal_elements(inner);
1086+
let mut spreads = Vec::new();
1087+
1088+
for part in &parts {
1089+
let part = part.trim();
1090+
if part.is_empty() {
1091+
continue;
1092+
}
1093+
if let Some(expr) = part.strip_prefix("...") {
1094+
let expr = expr.trim();
1095+
if !expr.is_empty() {
1096+
spreads.push(expr.to_string());
1097+
}
1098+
}
1099+
}
1100+
1101+
Some(spreads)
1102+
}
1103+
10301104
/// Split array literal elements on commas at depth 0, respecting
10311105
/// `(…)`, `[…]`, `{…}`, `<…>` nesting and quoted strings.
10321106
fn split_array_literal_elements(s: &str) -> Vec<&str> {

src/completion/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
/// Class inheritance merging (traits, mixins, parent chain) lives in the
2323
/// top-level [`crate::inheritance`] module since it is shared infrastructure
2424
/// used by completion, definition, and future features (hover, references).
25-
pub(crate) mod array_shape;
25+
pub mod array_shape;
2626
pub mod builder;
2727
pub(crate) mod catch_completion;
2828
pub mod class_completion;

src/completion/resolver.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,34 @@ impl Backend {
431431
// and push-style `$var[] = expr;` assignments.
432432
let base_entries = super::array_shape::parse_array_literal_entries(rhs_text);
433433

434+
// Extract spread element types from the array literal (e.g.
435+
// `[...$users, ...$admins]` → resolve each spread variable's
436+
// iterable element type via docblock annotation).
437+
let spread_types = super::array_shape::extract_spread_expressions(rhs_text)
438+
.unwrap_or_default()
439+
.iter()
440+
.filter_map(|expr| {
441+
if !expr.starts_with('$') {
442+
return None;
443+
}
444+
// Try docblock annotation first (@var / @param).
445+
let raw =
446+
crate::docblock::find_iterable_raw_type_in_source(content, cursor_offset, expr)
447+
.or_else(|| {
448+
// Fall back to resolving through assignment.
449+
Self::extract_raw_type_from_assignment_text(
450+
expr,
451+
content,
452+
cursor_offset,
453+
current_class,
454+
all_classes,
455+
class_loader,
456+
)
457+
})?;
458+
crate::docblock::extract_iterable_element_type(&raw)
459+
})
460+
.collect::<Vec<_>>();
461+
434462
let after_assign = rhs_start + semi_pos + 1; // past the `;`
435463
let incremental = super::array_shape::collect_incremental_key_assignments(
436464
base_var,
@@ -440,13 +468,17 @@ impl Backend {
440468
);
441469

442470
// Scan for push-style `$var[] = expr;` assignments.
443-
let push_types = super::array_shape::collect_push_assignments(
471+
let mut push_types = super::array_shape::collect_push_assignments(
444472
base_var,
445473
content,
446474
after_assign,
447475
cursor_offset,
448476
);
449477

478+
// Merge spread element types into push types so they participate
479+
// in the `list<…>` inference.
480+
push_types.extend(spread_types);
481+
450482
if base_entries.is_some() || !incremental.is_empty() || !push_types.is_empty() {
451483
let mut entries: Vec<(String, String)> = base_entries.unwrap_or_default();
452484
// Merge incremental assignments — later assignments for the

src/docblock/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pub use conditional::extract_conditional_return_type;
5050
// Type utilities
5151
pub use types::{
5252
base_class_name, clean_type, extract_array_shape_value_type, extract_callable_return_type,
53-
extract_generic_key_type, extract_generic_value_type, extract_object_shape_property_type,
54-
is_object_shape, parse_array_shape, parse_object_shape, split_intersection_depth0,
53+
extract_generic_key_type, extract_generic_value_type, extract_iterable_element_type,
54+
extract_object_shape_property_type, is_object_shape, parse_array_shape, parse_object_shape,
55+
split_intersection_depth0,
5556
};

src/docblock/types.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,55 @@ pub fn extract_generic_value_type(raw_type: &str) -> Option<String> {
486486
Some(cleaned)
487487
}
488488

489+
/// Extract the element (value) type from an iterable type annotation,
490+
/// including scalar element types.
491+
///
492+
/// Unlike [`extract_generic_value_type`], which skips scalar element types
493+
/// (because it is used for class-based completion), this function returns
494+
/// the raw element type string regardless of whether it is a class or a
495+
/// scalar. This is needed for spread operator tracking where we merge
496+
/// element types into a union and the final `list<…>` type is resolved
497+
/// later.
498+
///
499+
/// # Supported patterns
500+
///
501+
/// - `User[]` → `Some("User")`
502+
/// - `int[]` → `Some("int")`
503+
/// - `list<User>` → `Some("User")`
504+
/// - `array<int, User>` → `Some("User")`
505+
/// - `iterable<string>` → `Some("string")`
506+
/// - `Collection<int, User>` → `Some("User")`
507+
/// - `?list<User>` → `Some("User")`
508+
/// - `\list<User>` → `Some("User")`
509+
/// - `string` → `None` (not iterable)
510+
/// - `Closure(): User` → `None` (not iterable)
511+
pub fn extract_iterable_element_type(raw_type: &str) -> Option<String> {
512+
let s = raw_type.strip_prefix('\\').unwrap_or(raw_type);
513+
let s = s.strip_prefix('?').unwrap_or(s);
514+
515+
// Handle `Type[]` shorthand → element type is everything before `[]`.
516+
if let Some(base) = s.strip_suffix("[]") {
517+
let trimmed = base.trim();
518+
if !trimmed.is_empty() {
519+
return Some(trimmed.to_string());
520+
}
521+
return None;
522+
}
523+
524+
// Handle `GenericType<…>` — extract the last generic parameter.
525+
let angle_pos = s.find('<')?;
526+
let inner = s.get(angle_pos + 1..)?.strip_suffix('>')?.trim();
527+
if inner.is_empty() {
528+
return None;
529+
}
530+
531+
let last = split_last_generic_param(inner).trim();
532+
if last.is_empty() {
533+
return None;
534+
}
535+
Some(last.to_string())
536+
}
537+
489538
/// Extract the key type from a generic iterable type annotation.
490539
///
491540
/// Handles the most common PHPDoc generic iterable patterns:

0 commit comments

Comments
 (0)