@@ -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