Skip to content

Commit 941d642

Browse files
committed
Array Function Type Preservation
1 parent e982a64 commit 941d642

4 files changed

Lines changed: 2031 additions & 0 deletions

File tree

example.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,78 @@ public function listSyntaxNamedKey(): void
988988
}
989989

990990

991+
// ── Array Function Type Preservation ────────────────────────────────────────
992+
993+
class ArrayFuncDemo
994+
{
995+
/** @var list<User> */
996+
public array $users;
997+
998+
/** @return list<User> */
999+
public function getUsers(): array { return []; }
1000+
1001+
public function filterPreservesType(): void
1002+
{
1003+
// Try: $active[0]-> ← offers getName(), getEmail() (User members)
1004+
$active = array_filter($this->users, fn(User $u) => $u->getStatus() === Status::Active);
1005+
$active[0]->getName(); // User preserved through array_filter
1006+
}
1007+
1008+
public function valuesPreservesType(): void
1009+
{
1010+
$vals = array_values($this->users);
1011+
$vals[0]->getEmail(); // User preserved through array_values
1012+
}
1013+
1014+
public function reversePreservesType(): void
1015+
{
1016+
$reversed = array_reverse($this->users);
1017+
$reversed[0]->getName(); // User preserved through array_reverse
1018+
}
1019+
1020+
public function slicePreservesType(): void
1021+
{
1022+
$page = array_slice($this->users, 0, 10);
1023+
$page[0]->getEmail(); // User preserved through array_slice
1024+
}
1025+
1026+
public function popExtractsElement(): void
1027+
{
1028+
// Try: $last-> ← offers getName(), getEmail() (User members)
1029+
$users = $this->getUsers();
1030+
$last = array_pop($users);
1031+
$last->getName(); // single User from array_pop
1032+
1033+
$first = array_shift($users);
1034+
$first->getEmail(); // single User from array_shift
1035+
}
1036+
1037+
public function currentEndReset(): void
1038+
{
1039+
$cur = current($this->users);
1040+
$cur->getName(); // User from current()
1041+
1042+
$last = end($this->users);
1043+
$last->getEmail(); // User from end()
1044+
}
1045+
1046+
public function foreachOverFiltered(): void
1047+
{
1048+
// Try: $u-> ← offers getName(), getEmail() (User members)
1049+
foreach (array_filter($this->users, fn(User $u) => true) as $u) {
1050+
$u->getEmail(); // User preserved in foreach
1051+
}
1052+
}
1053+
1054+
public function arrayMapFallback(): void
1055+
{
1056+
// When callback has no return type, falls back to input element type
1057+
$mapped = array_map(fn($u) => $u, $this->users);
1058+
$mapped[0]->getName(); // User from array_map fallback
1059+
}
1060+
}
1061+
1062+
9911063
// ═══════════════════════════════════════════════════════════════════════════
9921064
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
9931065
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃

src/completion/resolver.rs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,19 @@ impl Backend {
543543
}
544544
}
545545

546+
// ── Known array functions — preserve element type ───────
547+
if let Some(raw) = Self::resolve_array_func_raw_type_from_text(
548+
callee,
549+
_args_text,
550+
content,
551+
assign_pos,
552+
current_class,
553+
all_classes,
554+
class_loader,
555+
) {
556+
return Some(raw);
557+
}
558+
546559
// Standalone function call — search all classes for a matching
547560
// global function. Since we don't have `function_loader` here,
548561
// search backward in the source for a `@return` in the
@@ -566,6 +579,210 @@ impl Backend {
566579
None
567580
}
568581

582+
/// Known array functions whose output preserves the input array's
583+
/// element type.
584+
const TEXT_ARRAY_PRESERVING_FUNCS: &'static [&'static str] = &[
585+
"array_filter",
586+
"array_values",
587+
"array_unique",
588+
"array_reverse",
589+
"array_slice",
590+
"array_splice",
591+
"array_chunk",
592+
"array_diff",
593+
"array_intersect",
594+
"array_merge",
595+
];
596+
597+
/// Known array functions that extract a single element (the element
598+
/// type is the output type, not wrapped in an array).
599+
const TEXT_ARRAY_ELEMENT_FUNCS: &'static [&'static str] = &[
600+
"array_pop",
601+
"array_shift",
602+
"current",
603+
"end",
604+
"reset",
605+
"next",
606+
"prev",
607+
];
608+
609+
/// Text-based resolution for known array functions.
610+
///
611+
/// Given a function name and its argument text, extract the first
612+
/// variable argument and look up its iterable raw type from docblock
613+
/// annotations. For type-preserving functions the raw type is returned
614+
/// as-is; for element-extracting functions the element type is returned.
615+
///
616+
/// This is the text-based counterpart of
617+
/// `variable_resolution::resolve_array_func_raw_type` and is used by
618+
/// `extract_raw_type_from_assignment_text` which operates on source
619+
/// text rather than the AST.
620+
fn resolve_array_func_raw_type_from_text(
621+
func_name: &str,
622+
args_text: &str,
623+
content: &str,
624+
before_offset: usize,
625+
current_class: Option<&ClassInfo>,
626+
all_classes: &[ClassInfo],
627+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
628+
) -> Option<String> {
629+
let is_preserving = Self::TEXT_ARRAY_PRESERVING_FUNCS
630+
.iter()
631+
.any(|f| f.eq_ignore_ascii_case(func_name));
632+
let is_element = Self::TEXT_ARRAY_ELEMENT_FUNCS
633+
.iter()
634+
.any(|f| f.eq_ignore_ascii_case(func_name));
635+
let is_array_map = func_name.eq_ignore_ascii_case("array_map");
636+
637+
if !is_preserving && !is_element && !is_array_map {
638+
return None;
639+
}
640+
641+
// For array_map the array is the second argument; for everything
642+
// else it's the first.
643+
let arg_index = if is_array_map { 1 } else { 0 };
644+
645+
// Try to resolve the raw iterable type from the nth argument.
646+
// First try plain `$variable` with docblock lookup, then try
647+
// `$this->prop` via the enclosing class's property type hints,
648+
// and finally try `$variable` assigned from a method call.
649+
let raw = Self::resolve_nth_arg_raw_type(
650+
args_text,
651+
arg_index,
652+
content,
653+
before_offset,
654+
current_class,
655+
all_classes,
656+
class_loader,
657+
)?;
658+
659+
// Make sure the raw type actually carries generic/array info.
660+
docblock::types::extract_generic_value_type(&raw)?;
661+
662+
if is_preserving || is_array_map {
663+
// Return the full raw type so downstream callers can extract
664+
// the element type via `extract_generic_value_type`.
665+
Some(raw)
666+
} else {
667+
// Element-extracting: return just the element type.
668+
docblock::types::extract_generic_value_type(&raw)
669+
}
670+
}
671+
672+
/// Resolve the raw iterable type of the nth argument in a text-based
673+
/// argument list.
674+
///
675+
/// Tries multiple strategies in order:
676+
/// 1. Plain `$variable` → docblock `@var` / `@param` lookup
677+
/// 2. `$this->prop` → property type hint from the enclosing class
678+
/// 3. Plain `$variable` → chase its assignment to extract the raw type
679+
fn resolve_nth_arg_raw_type(
680+
args_text: &str,
681+
n: usize,
682+
content: &str,
683+
before_offset: usize,
684+
current_class: Option<&ClassInfo>,
685+
all_classes: &[ClassInfo],
686+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
687+
) -> Option<String> {
688+
let arg_text = Self::extract_nth_arg_text(args_text, n)?;
689+
690+
// Strategy 1: plain `$variable` with @var / @param annotation.
691+
if let Some(var_name) = Self::extract_plain_variable(&arg_text) {
692+
if let Some(raw) =
693+
docblock::find_iterable_raw_type_in_source(content, before_offset, &var_name)
694+
{
695+
return Some(raw);
696+
}
697+
// Strategy 3: chase the variable's assignment to extract raw type.
698+
if let Some(raw) = Self::extract_raw_type_from_assignment_text(
699+
&var_name,
700+
content,
701+
before_offset,
702+
current_class,
703+
all_classes,
704+
class_loader,
705+
) {
706+
return Some(raw);
707+
}
708+
}
709+
710+
// Strategy 2: `$this->prop` — resolve via the enclosing class.
711+
if let Some(prop_name) = arg_text
712+
.strip_prefix("$this->")
713+
.or_else(|| arg_text.strip_prefix("$this?->"))
714+
&& prop_name.chars().all(|c| c.is_alphanumeric() || c == '_')
715+
{
716+
let owner = current_class?;
717+
let merged = Self::resolve_class_with_inheritance(owner, class_loader);
718+
return merged
719+
.properties
720+
.iter()
721+
.find(|p| p.name == prop_name)
722+
.and_then(|p| p.type_hint.clone());
723+
}
724+
725+
None
726+
}
727+
728+
/// Extract the nth (0-based) argument text from a comma-separated
729+
/// argument text string.
730+
///
731+
/// Returns the raw trimmed argument text, which may be a plain
732+
/// variable, a property access, a function call, etc. Respects
733+
/// nested parentheses and brackets so that commas inside sub-
734+
/// expressions are not treated as argument separators.
735+
fn extract_nth_arg_text(args_text: &str, n: usize) -> Option<String> {
736+
let trimmed = args_text.trim();
737+
let mut depth = 0i32;
738+
let mut arg_start = 0usize;
739+
let mut arg_index = 0usize;
740+
741+
let bytes = trimmed.as_bytes();
742+
for (i, &ch) in bytes.iter().enumerate() {
743+
match ch {
744+
b'(' | b'[' | b'{' => depth += 1,
745+
b')' | b']' | b'}' => depth -= 1,
746+
b',' if depth == 0 => {
747+
if arg_index == n {
748+
let arg = trimmed[arg_start..i].trim();
749+
if !arg.is_empty() {
750+
return Some(arg.to_string());
751+
}
752+
return None;
753+
}
754+
arg_index += 1;
755+
arg_start = i + 1;
756+
}
757+
_ => {}
758+
}
759+
}
760+
761+
// Last (or only) argument.
762+
if arg_index == n {
763+
let arg = trimmed[arg_start..].trim();
764+
if !arg.is_empty() {
765+
return Some(arg.to_string());
766+
}
767+
}
768+
769+
None
770+
}
771+
772+
/// If `text` is a plain variable reference (`$foo`), return it.
773+
/// Returns `None` for expressions like `$foo->bar`, `func()`, etc.
774+
fn extract_plain_variable(text: &str) -> Option<String> {
775+
let text = text.trim();
776+
if text.starts_with('$')
777+
&& text.len() > 1
778+
&& text[1..].chars().all(|c| c.is_alphanumeric() || c == '_')
779+
{
780+
Some(text.to_string())
781+
} else {
782+
None
783+
}
784+
}
785+
569786
/// Extract the class name from a `new` expression, handling both
570787
/// parenthesized and bare forms:
571788
///

0 commit comments

Comments
 (0)