1use std::collections::HashMap;
5
6use php_ast::{
7 BinaryOp, ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Stmt, StmtKind, TypeHint,
8 TypeHintKind,
9};
10use tower_lsp::lsp_types::Position;
11
12use crate::ast::{MethodReturnsMap, ParsedDoc, SourceView};
13use crate::docblock::{docblock_before, parse_docblock};
14use crate::phpstorm_meta::PhpStormMeta;
15
16#[derive(Debug, Default, Clone)]
18pub struct TypeMap(HashMap<String, String>);
19
20impl TypeMap {
21 #[cfg(test)]
25 pub fn from_doc(doc: &ParsedDoc) -> Self {
26 Self::from_doc_with_meta(doc, None, None)
27 }
28
29 pub fn from_doc_with_meta(
34 doc: &ParsedDoc,
35 meta: Option<&PhpStormMeta>,
36 doc_returns: Option<&MethodReturnsMap>,
37 ) -> Self {
38 let owned;
39 let returns: &MethodReturnsMap = match doc_returns {
40 Some(r) => r,
41 None => {
42 owned = build_method_returns(doc);
43 &owned
44 }
45 };
46 let mut map = HashMap::new();
47 collect_types_stmts(
48 doc.source(),
49 &doc.program().stmts,
50 &mut map,
51 meta,
52 std::slice::from_ref(&returns),
53 None,
54 );
55 TypeMap(map)
56 }
57
58 pub fn from_docs_with_meta<'a>(
62 doc: &ParsedDoc,
63 doc_returns: &MethodReturnsMap,
64 other_docs: impl IntoIterator<Item = (&'a ParsedDoc, &'a MethodReturnsMap)>,
65 meta: Option<&'a PhpStormMeta>,
66 ) -> Self {
67 let mut all_returns: Vec<&MethodReturnsMap> = vec![doc_returns];
68 all_returns.extend(other_docs.into_iter().map(|(_, r)| r));
69 let mut map = HashMap::new();
70 collect_types_stmts(
71 doc.source(),
72 &doc.program().stmts,
73 &mut map,
74 meta,
75 &all_returns,
76 None,
77 );
78 TypeMap(map)
79 }
80
81 pub fn from_docs_at_position<'a>(
87 doc: &ParsedDoc,
88 doc_returns: &MethodReturnsMap,
89 other_docs: impl IntoIterator<Item = (&'a ParsedDoc, &'a MethodReturnsMap)>,
90 meta: Option<&'a PhpStormMeta>,
91 position: Position,
92 ) -> Self {
93 let cursor_byte = {
94 let line_starts = doc.line_starts();
95 let line = position.line as usize;
96 if line < line_starts.len() {
97 let line_start = line_starts[line] as usize;
98 let col_byte = crate::util::utf16_offset_to_byte(
99 &doc.source()[line_start..],
100 position.character as usize,
101 );
102 Some((line_start + col_byte) as u32)
103 } else {
104 None
105 }
106 };
107 let mut all_returns: Vec<&MethodReturnsMap> = vec![doc_returns];
108 all_returns.extend(other_docs.into_iter().map(|(_, r)| r));
109 let mut map = HashMap::new();
110 collect_types_stmts(
111 doc.source(),
112 &doc.program().stmts,
113 &mut map,
114 meta,
115 &all_returns,
116 cursor_byte,
117 );
118 TypeMap(map)
119 }
120
121 pub fn get<'a>(&'a self, var: &str) -> Option<&'a str> {
123 self.0.get(var).map(|s| s.as_str())
124 }
125}
126
127pub fn build_method_returns(doc: &ParsedDoc) -> MethodReturnsMap {
129 let mut out = HashMap::new();
130 collect_method_returns_stmts(doc.source(), &doc.program().stmts, &mut out);
131 out
132}
133
134fn lookup_method_return<'a>(
138 maps: &'a [&'a MethodReturnsMap],
139 class_name: &str,
140 method_name: &str,
141) -> Option<&'a str> {
142 for m in maps.iter().rev() {
143 if let Some(class_rets) = m.get(class_name)
144 && let Some(ret) = class_rets.get(method_name)
145 {
146 return Some(ret.as_str());
147 }
148 }
149 None
150}
151
152fn collect_method_returns_stmts(
153 source: &str,
154 stmts: &[Stmt<'_, '_>],
155 out: &mut HashMap<String, HashMap<String, String>>,
156) {
157 for stmt in stmts {
158 match &stmt.kind {
159 StmtKind::Class(c) => {
160 let class_name = match c.name {
161 Some(n) => n.to_string(),
162 None => continue,
163 };
164 for member in c.members.iter() {
165 if let ClassMemberKind::Method(m) = &member.kind
166 && let Some(ret) =
167 extract_method_return_class(source, member.span.start, m, &class_name)
168 {
169 out.entry(class_name.clone())
170 .or_default()
171 .insert(m.name.to_string(), ret);
172 }
173 }
174 }
175 StmtKind::Trait(t) => {
176 let trait_name = t.name.to_string();
177 for member in t.members.iter() {
178 if let ClassMemberKind::Method(m) = &member.kind
179 && let Some(ret) =
180 extract_method_return_class(source, member.span.start, m, &trait_name)
181 {
182 out.entry(trait_name.clone())
183 .or_default()
184 .insert(m.name.to_string(), ret);
185 }
186 }
187 }
188 StmtKind::Enum(e) => {
189 let enum_name = e.name.to_string();
190 for member in e.members.iter() {
191 if let EnumMemberKind::Method(m) = &member.kind
192 && let Some(ret) =
193 extract_method_return_class(source, member.span.start, m, &enum_name)
194 {
195 out.entry(enum_name.clone())
196 .or_default()
197 .insert(m.name.to_string(), ret);
198 }
199 }
200 }
201 StmtKind::Namespace(ns) => {
202 if let NamespaceBody::Braced(inner) = &ns.body {
203 collect_method_returns_stmts(source, inner, out);
204 }
205 }
206 _ => {}
207 }
208 }
209}
210
211fn extract_method_return_class(
212 source: &str,
213 member_start: u32,
214 m: &php_ast::MethodDecl<'_, '_>,
215 enclosing_class: &str,
216) -> Option<String> {
217 if let Some(hint) = &m.return_type
219 && let Some(s) = type_hint_to_class_string(hint, Some(enclosing_class))
220 {
221 return Some(s);
222 }
223 if let Some(raw) = docblock_before(source, member_start) {
225 let db = parse_docblock(&raw);
226 if let Some(ret) = db.return_type {
227 for part in ret.type_hint.split('|') {
228 let part = part.trim().trim_start_matches('\\').trim_start_matches('?');
229 let short = part.rsplit('\\').next().unwrap_or(part);
230 if short == "self" || short == "static" {
231 return Some(enclosing_class.to_string());
232 }
233 let first = short.chars().next().unwrap_or('_');
234 if first.is_uppercase() && !matches!(short, "void" | "never" | "null") {
235 return Some(short.to_string());
236 }
237 }
238 }
239 }
240 None
241}
242
243fn type_hint_to_class_string(
250 hint: &TypeHint<'_, '_>,
251 enclosing_class: Option<&str>,
252) -> Option<String> {
253 use mir_types::Atomic;
254 let union = mir_analyzer::parser::type_from_hint(hint, enclosing_class);
255 let classes: Vec<String> = union
256 .types
257 .iter()
258 .filter_map(|a| match a {
259 Atomic::TNamedObject { fqcn, .. }
260 | Atomic::TSelf { fqcn }
261 | Atomic::TStaticObject { fqcn }
262 | Atomic::TParent { fqcn } => {
263 let short = fqcn.rsplit('\\').next().unwrap_or(fqcn.as_ref());
264 Some(short.to_string())
265 }
266 _ => None,
267 })
268 .collect();
269 if classes.is_empty() {
270 None
271 } else {
272 Some(classes.join("|"))
273 }
274}
275
276fn collect_types_stmts(
277 source: &str,
278 stmts: &[Stmt<'_, '_>],
279 map: &mut HashMap<String, String>,
280 meta: Option<&PhpStormMeta>,
281 method_returns: &[&MethodReturnsMap],
282 cursor_byte: Option<u32>,
283) {
284 for stmt in stmts {
285 if let Some(raw) = docblock_before(source, stmt.span.start) {
287 let db = parse_docblock(&raw);
288 if let Some(type_str) = db.var_type {
289 let class_name = type_str
292 .split('|')
293 .map(|p| p.trim().trim_start_matches('\\').trim_start_matches('?'))
294 .find(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
295 .and_then(|p| p.rsplit('\\').next())
296 .map(|p| p.to_string());
297 if let Some(class_name) = class_name {
298 if let Some(vname) = db.var_name {
299 map.insert(format!("${}", vname.as_str()), class_name);
301 } else if let StmtKind::Expression(e) = &stmt.kind {
302 if let ExprKind::Assign(a) = &e.kind
304 && let ExprKind::Variable(vn) = &a.target.kind
305 {
306 map.insert(format!("${}", vn.as_str()), class_name);
307 }
308 }
309 }
310 }
311 }
312
313 match &stmt.kind {
314 StmtKind::Expression(e) => {
315 collect_types_expr(source, e, map, meta, method_returns, cursor_byte)
316 }
317 StmtKind::Function(f) => {
318 let in_scope =
320 cursor_byte.is_none_or(|c| stmt.span.start <= c && c <= stmt.span.end);
321 if !in_scope {
322 continue;
323 }
324 if let Some(raw) = docblock_before(source, stmt.span.start) {
326 let db = parse_docblock(&raw);
327 for param in &db.params {
328 let classes: Vec<&str> = param
330 .type_hint
331 .split('|')
332 .map(|p| p.trim().trim_start_matches('\\').trim_start_matches('?'))
333 .filter(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
334 .filter_map(|p| p.rsplit('\\').next())
335 .collect();
336 if !classes.is_empty() {
337 let key = if param.name.starts_with('$') {
338 param.name.clone()
339 } else {
340 format!("${}", param.name)
341 };
342 map.entry(key).or_insert_with(|| classes.join("|"));
343 }
344 }
345 }
346 for p in f.params.iter() {
347 if let Some(hint) = &p.type_hint
348 && let Some(class_str) = type_hint_to_class_string(hint, None)
349 {
350 map.insert(format!("${}", p.name), class_str);
351 }
352 }
353 collect_types_stmts(source, &f.body, map, meta, method_returns, cursor_byte);
354 }
355 StmtKind::Class(c) => {
356 let class_name = c.name.map(|n| n.to_string());
357 for member in c.members.iter() {
358 if let ClassMemberKind::Method(m) = &member.kind {
359 let in_scope = cursor_byte
361 .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
362 if !in_scope {
363 continue;
364 }
365 if let Some(raw) = docblock_before(source, member.span.start) {
367 let db = parse_docblock(&raw);
368 for param in &db.params {
369 let classes: Vec<&str> = param
371 .type_hint
372 .split('|')
373 .map(|p| {
374 p.trim().trim_start_matches('\\').trim_start_matches('?')
375 })
376 .filter(|p| {
377 p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
378 })
379 .filter_map(|p| p.rsplit('\\').next())
380 .collect();
381 if !classes.is_empty() {
382 let key = if param.name.starts_with('$') {
383 param.name.clone()
384 } else {
385 format!("${}", param.name)
386 };
387 map.entry(key).or_insert_with(|| classes.join("|"));
388 }
389 }
390 }
391 for p in m.params.iter() {
392 if let Some(hint) = &p.type_hint
393 && let Some(class_str) =
394 type_hint_to_class_string(hint, class_name.as_deref())
395 {
396 map.insert(format!("${}", p.name), class_str);
397 }
398 }
399 if !m.is_static
401 && let Some(ref cname) = class_name
402 {
403 map.insert("$this".to_string(), cname.clone());
404 }
405 if let Some(body) = &m.body {
406 collect_types_stmts(
407 source,
408 body,
409 map,
410 meta,
411 method_returns,
412 cursor_byte,
413 );
414 }
415 }
416 }
417 }
418 StmtKind::Trait(t) => {
419 for member in t.members.iter() {
420 if let ClassMemberKind::Method(m) = &member.kind {
421 let in_scope = cursor_byte
422 .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
423 if !in_scope {
424 continue;
425 }
426 for p in m.params.iter() {
427 if let Some(hint) = &p.type_hint
428 && let Some(class_str) = type_hint_to_class_string(hint, None)
429 {
430 map.insert(format!("${}", p.name), class_str);
431 }
432 }
433 if let Some(body) = &m.body {
434 collect_types_stmts(
435 source,
436 body,
437 map,
438 meta,
439 method_returns,
440 cursor_byte,
441 );
442 }
443 }
444 }
445 }
446 StmtKind::Enum(e) => {
447 for member in e.members.iter() {
448 if let EnumMemberKind::Method(m) = &member.kind {
449 let in_scope = cursor_byte
450 .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
451 if !in_scope {
452 continue;
453 }
454 for p in m.params.iter() {
455 if let Some(hint) = &p.type_hint
456 && let Some(class_str) = type_hint_to_class_string(hint, None)
457 {
458 map.insert(format!("${}", p.name), class_str);
459 }
460 }
461 if let Some(body) = &m.body {
462 collect_types_stmts(
463 source,
464 body,
465 map,
466 meta,
467 method_returns,
468 cursor_byte,
469 );
470 }
471 }
472 }
473 }
474 StmtKind::Namespace(ns) => {
475 if let NamespaceBody::Braced(inner) = &ns.body {
476 collect_types_stmts(source, inner, map, meta, method_returns, cursor_byte);
477 }
478 }
479 StmtKind::If(if_stmt) => {
481 if let ExprKind::Binary(b) = &if_stmt.condition.kind
483 && b.op == BinaryOp::Instanceof
484 && let (ExprKind::Variable(var_name), ExprKind::Identifier(class)) =
485 (&b.left.kind, &b.right.kind)
486 {
487 let var_key = format!("${}", var_name.as_str());
488 let narrowed = class
489 .as_str()
490 .trim_start_matches('\\')
491 .rsplit('\\')
492 .next()
493 .unwrap_or(class)
494 .to_string();
495 map.insert(var_key, narrowed);
500 }
501 collect_types_stmts(
502 source,
503 std::slice::from_ref(if_stmt.then_branch),
504 map,
505 meta,
506 method_returns,
507 cursor_byte,
508 );
509 for elseif in if_stmt.elseif_branches.iter() {
510 collect_types_stmts(
511 source,
512 std::slice::from_ref(&elseif.body),
513 map,
514 meta,
515 method_returns,
516 cursor_byte,
517 );
518 }
519 if let Some(else_branch) = if_stmt.else_branch {
520 collect_types_stmts(
521 source,
522 std::slice::from_ref(else_branch),
523 map,
524 meta,
525 method_returns,
526 cursor_byte,
527 );
528 }
529 }
530
531 StmtKind::Foreach(f) => {
533 if let ExprKind::Variable(arr_name) = &f.expr.kind {
534 let elem_key = format!("${}[]", arr_name.as_str());
535 if let Some(elem_type) = map.get(&elem_key).cloned()
536 && let ExprKind::Variable(val_name) = &f.value.kind
537 {
538 map.insert(format!("${}", val_name.as_str()), elem_type);
539 }
540 }
541 collect_types_stmts(
542 source,
543 std::slice::from_ref(f.body),
544 map,
545 meta,
546 method_returns,
547 cursor_byte,
548 );
549 }
550 StmtKind::TryCatch(t) => {
553 collect_types_stmts(source, &t.body, map, meta, method_returns, cursor_byte);
554 for catch in t.catches.iter() {
555 if let Some(var_name) = &catch.var
556 && let Some(first_type) = catch.types.first()
557 {
558 let class_name = first_type
559 .to_string_repr()
560 .trim_start_matches('\\')
561 .rsplit('\\')
562 .next()
563 .unwrap_or("")
564 .to_string();
565 if !class_name.is_empty() {
566 map.insert(format!("${}", var_name), class_name);
567 }
568 }
569 collect_types_stmts(
570 source,
571 &catch.body,
572 map,
573 meta,
574 method_returns,
575 cursor_byte,
576 );
577 }
578 if let Some(finally) = &t.finally {
579 collect_types_stmts(source, finally, map, meta, method_returns, cursor_byte);
580 }
581 }
582
583 StmtKind::StaticVar(vars) => {
585 for var in vars.iter() {
586 let var_key = format!("${}", var.name);
587 if let Some(default) = &var.default {
588 if let ExprKind::New(new_expr) = &default.kind
589 && let Some(class_name) = extract_class_name(new_expr.class)
590 {
591 map.insert(var_key.clone(), class_name);
592 }
593 if let ExprKind::Array(_) = &default.kind {
594 map.insert(var_key, "array".to_string());
595 }
596 }
597 }
598 }
599
600 _ => {}
601 }
602 }
603}
604
605fn collect_types_expr(
606 source: &str,
607 expr: &php_ast::Expr<'_, '_>,
608 map: &mut HashMap<String, String>,
609 meta: Option<&PhpStormMeta>,
610 method_returns: &[&MethodReturnsMap],
611 cursor_byte: Option<u32>,
612) {
613 match &expr.kind {
614 ExprKind::Assign(assign) => {
615 if let ExprKind::Variable(var_name) = &assign.target.kind {
616 if assign.op == php_ast::AssignOp::Coalesce {
619 if let ExprKind::New(new_expr) = &assign.value.kind
620 && let Some(class_name) = extract_class_name(new_expr.class)
621 {
622 map.entry(format!("${}", var_name.as_str()))
623 .or_insert(class_name);
624 }
625 collect_types_expr(
626 source,
627 assign.value,
628 map,
629 meta,
630 method_returns,
631 cursor_byte,
632 );
633 return;
634 }
635 if let ExprKind::New(new_expr) = &assign.value.kind
636 && let Some(class_name) = extract_class_name(new_expr.class)
637 {
638 map.insert(format!("${}", var_name.as_str()), class_name);
639 }
640 if let ExprKind::MethodCall(mc) = &assign.value.kind
642 && let (ExprKind::Variable(obj_var), ExprKind::Identifier(method_name)) =
643 (&mc.object.kind, &mc.method.kind)
644 && let Some(obj_class) = map.get(&format!("${}", obj_var.as_str())).cloned()
645 && let Some(ret_type) =
646 lookup_method_return(method_returns, &obj_class, method_name.as_str())
647 {
648 map.insert(format!("${}", var_name.as_str()), ret_type.to_string());
649 }
650 if let Some(meta) = meta
652 && let Some(inferred) = infer_from_meta_method_call(assign.value, map, meta)
653 {
654 map.insert(format!("${}", var_name.as_str()), inferred);
655 }
656 if let Some(elem_type) = extract_array_callback_return_type(assign.value) {
658 map.insert(format!("${}[]", var_name.as_str()), elem_type);
659 }
660 }
661 collect_types_expr(source, assign.value, map, meta, method_returns, cursor_byte);
662 }
663
664 ExprKind::StaticMethodCall(s) => {
666 if let ExprKind::Identifier(class) = &s.class.kind
667 && class.as_str() == "Closure"
668 && s.method.name_str() == Some("bind")
669 && let Some(obj_arg) = s.args.get(1)
670 && let Some(cls) = resolve_var_type_str(&obj_arg.value, map)
671 {
672 map.insert("$this".to_string(), cls);
673 }
674 }
675
676 ExprKind::MethodCall(m) => {
678 if let ExprKind::Identifier(method) = &m.method.kind {
679 let mname = method.as_str();
680 if (mname == "bindTo" || mname == "call")
681 && let Some(obj_arg) = m.args.first()
682 && let Some(cls) = resolve_var_type_str(&obj_arg.value, map)
683 {
684 map.insert("$this".to_string(), cls);
685 }
686 }
687 }
688
689 ExprKind::Closure(c) => {
691 for p in c.params.iter() {
692 if let Some(hint) = &p.type_hint
693 && let TypeHintKind::Named(name) = &hint.kind
694 {
695 map.insert(format!("${}", p.name), name.to_string_repr().to_string());
696 }
697 }
698 let use_var_snapshot: Vec<(String, String)> = c
702 .use_vars
703 .iter()
704 .filter_map(|uv| {
705 let key = format!("${}", uv.name);
706 map.get(&key).map(|ty| (key, ty.clone()))
707 })
708 .collect();
709 collect_types_stmts(source, &c.body, map, meta, method_returns, cursor_byte);
710 for (key, ty) in use_var_snapshot {
713 map.insert(key, ty);
714 }
715 }
716
717 ExprKind::ArrowFunction(af) => {
718 for p in af.params.iter() {
719 if let Some(hint) = &p.type_hint
720 && let TypeHintKind::Named(name) = &hint.kind
721 {
722 map.insert(format!("${}", p.name), name.to_string_repr().to_string());
723 }
724 }
725 collect_types_expr(source, af.body, map, meta, method_returns, cursor_byte);
726 }
727
728 _ => {}
729 }
730}
731
732fn extract_array_callback_return_type(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
736 let ExprKind::FunctionCall(call) = &expr.kind else {
737 return None;
738 };
739 let fn_name = match &call.name.kind {
740 ExprKind::Identifier(n) => n.as_str(),
741 _ => return None,
742 };
743 if fn_name != "array_map" && fn_name != "array_filter" {
744 return None;
745 }
746 let callback_arg = call.args.first()?;
747 extract_callback_return_type(&callback_arg.value)
748}
749
750fn extract_callback_return_type(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
752 let hint = match &expr.kind {
753 ExprKind::Closure(c) => c.return_type.as_ref()?,
754 ExprKind::ArrowFunction(af) => af.return_type.as_ref()?,
755 _ => return None,
756 };
757 if let TypeHintKind::Named(name) = &hint.kind {
758 let s = name.to_string_repr();
759 let base = s.trim_start_matches('\\');
760 let short = base.rsplit('\\').next().unwrap_or(base);
761 if short
762 .chars()
763 .next()
764 .map(|c| c.is_uppercase())
765 .unwrap_or(false)
766 {
767 return Some(short.to_string());
768 }
769 }
770 None
771}
772
773fn resolve_var_type_str(
775 expr: &php_ast::Expr<'_, '_>,
776 map: &HashMap<String, String>,
777) -> Option<String> {
778 if let ExprKind::Variable(v) = &expr.kind {
779 map.get(&format!("${}", v.as_str())).cloned()
780 } else {
781 None
782 }
783}
784
785fn extract_class_name(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
786 match &expr.kind {
787 ExprKind::Identifier(name) => Some(name.as_str().to_string()),
788 _ => None,
789 }
790}
791
792fn infer_from_meta_method_call(
795 expr: &php_ast::Expr<'_, '_>,
796 var_map: &HashMap<String, String>,
797 meta: &PhpStormMeta,
798) -> Option<String> {
799 let ExprKind::MethodCall(m) = &expr.kind else {
800 return None;
801 };
802 let receiver_class = match &m.object.kind {
804 ExprKind::Variable(v) => {
805 let key = format!("${}", v.as_str());
806 var_map.get(&key)?.clone()
807 }
808 _ => return None,
809 };
810 let method_name = match &m.method.kind {
812 ExprKind::Identifier(n) => n.to_string(),
813 _ => return None,
814 };
815 let arg = m.args.first()?;
817 let arg_str = match &arg.value.kind {
818 ExprKind::String(s) => s.trim_start_matches('\\').to_string(),
819 ExprKind::ClassConstAccess(c) if c.member.name_str() == Some("class") => {
820 match &c.class.kind {
821 ExprKind::Identifier(n) => n
822 .trim_start_matches('\\')
823 .rsplit('\\')
824 .next()
825 .unwrap_or(n)
826 .to_string(),
827 _ => return None,
828 }
829 }
830 _ => return None,
831 };
832 meta.resolve_return_type(&receiver_class, &method_name, &arg_str)
833 .map(|s| s.to_string())
834}
835
836pub fn parent_class_name(doc: &ParsedDoc, class_name: &str) -> Option<String> {
838 parent_in_stmts(&doc.program().stmts, class_name)
839}
840
841fn parent_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str) -> Option<String> {
842 for stmt in stmts {
843 match &stmt.kind {
844 StmtKind::Class(c) if c.name == Some(class_name) => {
845 return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
846 }
847 StmtKind::Namespace(ns) => {
848 if let NamespaceBody::Braced(inner) = &ns.body
849 && let found @ Some(_) = parent_in_stmts(inner, class_name)
850 {
851 return found;
852 }
853 }
854 _ => {}
855 }
856 }
857 None
858}
859
860#[derive(Debug, Default)]
862pub struct ClassMembers {
863 pub methods: Vec<(String, bool)>,
865 pub properties: Vec<(String, bool)>,
867 pub readonly_properties: Vec<String>,
869 pub constants: Vec<String>,
870 pub parent: Option<String>,
872 pub trait_uses: Vec<String>,
874}
875
876pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
879 let mut out = ClassMembers::default();
880 out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
881 out
882}
883
884fn collect_members_stmts(
885 source: &str,
886 stmts: &[Stmt<'_, '_>],
887 class_name: &str,
888 out: &mut ClassMembers,
889) -> Option<String> {
890 for stmt in stmts {
891 match &stmt.kind {
892 StmtKind::Class(c) if c.name == Some(class_name) => {
893 if let Some(raw) = docblock_before(source, stmt.span.start) {
895 let db = parse_docblock(&raw);
896 for prop in &db.properties {
897 out.properties.push((prop.name.clone(), false));
898 }
899 for method in &db.methods {
900 out.methods.push((method.name.clone(), method.is_static));
901 }
902 }
903 for member in c.members.iter() {
904 match &member.kind {
905 ClassMemberKind::Method(m) => {
906 out.methods.push((m.name.to_string(), m.is_static));
907 if m.name == "__construct" {
909 for p in m.params.iter() {
910 if p.visibility.is_some() {
911 out.properties.push((p.name.to_string(), false));
912 let param_src =
916 &source[p.span.start as usize..p.span.end as usize];
917 if param_src.contains("readonly") {
918 out.readonly_properties.push(p.name.to_string());
919 }
920 }
921 }
922 }
923 }
924 ClassMemberKind::Property(p) => {
925 out.properties.push((p.name.to_string(), p.is_static));
926 if p.is_readonly {
927 out.readonly_properties.push(p.name.to_string());
928 }
929 }
930 ClassMemberKind::ClassConst(c) => {
931 out.constants.push(c.name.to_string());
932 }
933 ClassMemberKind::TraitUse(t) => {
934 for name in t.traits.iter() {
935 out.trait_uses.push(name.to_string_repr().to_string());
936 }
937 }
938 }
939 }
940 return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
941 }
942 StmtKind::Enum(e) if e.name == class_name => {
943 let is_backed = e.scalar_type.is_some();
944 out.properties.push(("name".to_string(), false));
946 if is_backed {
947 out.properties.push(("value".to_string(), false));
948 }
949 out.methods.push(("cases".to_string(), true));
951 if is_backed {
952 out.methods.push(("from".to_string(), true));
953 out.methods.push(("tryFrom".to_string(), true));
954 }
955 for member in e.members.iter() {
957 match &member.kind {
958 EnumMemberKind::Case(c) => {
959 out.constants.push(c.name.to_string());
960 }
961 EnumMemberKind::Method(m) => {
962 out.methods.push((m.name.to_string(), m.is_static));
963 }
964 EnumMemberKind::ClassConst(c) => {
965 out.constants.push(c.name.to_string());
966 }
967 _ => {}
968 }
969 }
970 return None; }
972 StmtKind::Trait(t) if t.name == class_name => {
973 for member in t.members.iter() {
974 match &member.kind {
975 ClassMemberKind::Method(m) => {
976 out.methods.push((m.name.to_string(), m.is_static));
977 }
978 ClassMemberKind::Property(p) => {
979 out.properties.push((p.name.to_string(), p.is_static));
980 }
981 ClassMemberKind::ClassConst(c) => {
982 out.constants.push(c.name.to_string());
983 }
984 ClassMemberKind::TraitUse(t) => {
985 for name in t.traits.iter() {
986 out.trait_uses.push(name.to_string_repr().to_string());
987 }
988 }
989 }
990 }
991 return None; }
993 StmtKind::Namespace(ns) => {
994 if let NamespaceBody::Braced(inner) = &ns.body {
995 let result = collect_members_stmts(source, inner, class_name, out);
996 if result.is_some() {
997 return result;
998 }
999 }
1000 }
1001 _ => {}
1002 }
1003 }
1004 None
1005}
1006
1007pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
1009 let source = doc.source();
1010 mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
1011}
1012
1013fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
1014 for stmt in stmts {
1015 match &stmt.kind {
1016 StmtKind::Class(c) if c.name == Some(class_name) => {
1017 if let Some(raw) = docblock_before(source, stmt.span.start) {
1018 return parse_docblock(&raw).mixins;
1019 }
1020 return vec![];
1021 }
1022 StmtKind::Namespace(ns) => {
1023 if let NamespaceBody::Braced(inner) = &ns.body {
1024 let found = mixin_classes_in_stmts(source, inner, class_name);
1025 if !found.is_empty() {
1026 return found;
1027 }
1028 }
1029 }
1030 _ => {}
1031 }
1032 }
1033 vec![]
1034}
1035
1036pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
1038 let sv = doc.view();
1039 enclosing_class_in_stmts(sv, &doc.program().stmts, position)
1040}
1041
1042fn enclosing_class_in_stmts(
1043 sv: SourceView<'_>,
1044 stmts: &[Stmt<'_, '_>],
1045 pos: Position,
1046) -> Option<String> {
1047 for stmt in stmts {
1048 match &stmt.kind {
1049 StmtKind::Class(c) => {
1050 let start = sv.position_of(stmt.span.start).line;
1051 let end = sv.position_of(stmt.span.end).line;
1052 if pos.line >= start && pos.line <= end {
1053 return c.name.map(|n| n.to_string());
1054 }
1055 }
1056 StmtKind::Interface(i) => {
1057 let start = sv.position_of(stmt.span.start).line;
1058 let end = sv.position_of(stmt.span.end).line;
1059 if pos.line >= start && pos.line <= end {
1060 return Some(i.name.to_string());
1061 }
1062 }
1063 StmtKind::Trait(t) => {
1064 let start = sv.position_of(stmt.span.start).line;
1065 let end = sv.position_of(stmt.span.end).line;
1066 if pos.line >= start && pos.line <= end {
1067 return Some(t.name.to_string());
1068 }
1069 }
1070 StmtKind::Enum(e) => {
1071 let start = sv.position_of(stmt.span.start).line;
1072 let end = sv.position_of(stmt.span.end).line;
1073 if pos.line >= start && pos.line <= end {
1074 return Some(e.name.to_string());
1075 }
1076 }
1077 StmtKind::Namespace(ns) => {
1078 if let NamespaceBody::Braced(inner) = &ns.body
1079 && let Some(found) = enclosing_class_in_stmts(sv, inner, pos)
1080 {
1081 return Some(found);
1082 }
1083 }
1084 _ => {}
1085 }
1086 }
1087 None
1088}
1089
1090pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
1092 let mut out = Vec::new();
1093 collect_params_stmts(&doc.program().stmts, func_name, &mut out);
1094 out
1095}
1096
1097pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
1100 let mut out = Vec::new();
1101 collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
1102 out
1103}
1104
1105fn collect_method_params_stmts(
1106 stmts: &[php_ast::Stmt<'_, '_>],
1107 class_name: &str,
1108 method_name: &str,
1109 out: &mut Vec<String>,
1110) {
1111 for stmt in stmts {
1112 match &stmt.kind {
1113 StmtKind::Class(c) if c.name == Some(class_name) => {
1114 for member in c.members.iter() {
1115 if let ClassMemberKind::Method(m) = &member.kind
1116 && m.name == method_name
1117 {
1118 for p in m.params.iter() {
1119 out.push(p.name.to_string());
1120 }
1121 return;
1122 }
1123 }
1124 }
1125 StmtKind::Namespace(ns) => {
1126 if let NamespaceBody::Braced(inner) = &ns.body {
1127 collect_method_params_stmts(inner, class_name, method_name, out);
1128 }
1129 }
1130 _ => {}
1131 }
1132 }
1133}
1134
1135pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1137 is_enum_in_stmts(&doc.program().stmts, class_name)
1138}
1139
1140fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1141 for stmt in stmts {
1142 match &stmt.kind {
1143 StmtKind::Enum(e) if e.name == name => return true,
1144 StmtKind::Namespace(ns) => {
1145 if let NamespaceBody::Braced(inner) = &ns.body
1146 && is_enum_in_stmts(inner, name)
1147 {
1148 return true;
1149 }
1150 }
1151 _ => {}
1152 }
1153 }
1154 false
1155}
1156
1157pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1160 is_backed_enum_in_stmts(&doc.program().stmts, class_name)
1161}
1162
1163fn is_backed_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1164 for stmt in stmts {
1165 match &stmt.kind {
1166 StmtKind::Enum(e) if e.name == name => return e.scalar_type.is_some(),
1167 StmtKind::Namespace(ns) => {
1168 if let NamespaceBody::Braced(inner) = &ns.body
1169 && is_backed_enum_in_stmts(inner, name)
1170 {
1171 return true;
1172 }
1173 }
1174 _ => {}
1175 }
1176 }
1177 false
1178}
1179
1180fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
1181 for stmt in stmts {
1182 match &stmt.kind {
1183 StmtKind::Function(f) if f.name == func_name => {
1184 for p in f.params.iter() {
1185 out.push(p.name.to_string());
1186 }
1187 return;
1188 }
1189 StmtKind::Class(c) => {
1190 for member in c.members.iter() {
1191 if let ClassMemberKind::Method(m) = &member.kind
1192 && m.name == func_name
1193 {
1194 for p in m.params.iter() {
1195 out.push(p.name.to_string());
1196 }
1197 return;
1198 }
1199 }
1200 }
1201 StmtKind::Namespace(ns) => {
1202 if let NamespaceBody::Braced(inner) = &ns.body {
1203 collect_params_stmts(inner, func_name, out);
1204 }
1205 }
1206 _ => {}
1207 }
1208 }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214
1215 #[test]
1216 fn infers_type_from_new_expression() {
1217 let src = "<?php\n$obj = new Foo();";
1218 let doc = ParsedDoc::parse(src.to_string());
1219 let tm = TypeMap::from_doc(&doc);
1220 assert_eq!(tm.get("$obj"), Some("Foo"));
1221 }
1222
1223 #[test]
1224 fn unknown_variable_returns_none() {
1225 let src = "<?php\n$obj = new Foo();";
1226 let doc = ParsedDoc::parse(src.to_string());
1227 let tm = TypeMap::from_doc(&doc);
1228 assert!(tm.get("$other").is_none());
1229 }
1230
1231 #[test]
1232 fn multiple_assignments() {
1233 let src = "<?php\n$a = new Foo();\n$b = new Bar();";
1234 let doc = ParsedDoc::parse(src.to_string());
1235 let tm = TypeMap::from_doc(&doc);
1236 assert_eq!(tm.get("$a"), Some("Foo"));
1237 assert_eq!(tm.get("$b"), Some("Bar"));
1238 }
1239
1240 #[test]
1241 fn later_assignment_overwrites() {
1242 let src = "<?php\n$a = new Foo();\n$a = new Bar();";
1243 let doc = ParsedDoc::parse(src.to_string());
1244 let tm = TypeMap::from_doc(&doc);
1245 assert_eq!(tm.get("$a"), Some("Bar"));
1246 }
1247
1248 #[test]
1249 fn infers_type_from_typed_param() {
1250 let src = "<?php\nfunction process(Mailer $mailer): void { $mailer-> }";
1251 let doc = ParsedDoc::parse(src.to_string());
1252 let tm = TypeMap::from_doc(&doc);
1253 assert_eq!(tm.get("$mailer"), Some("Mailer"));
1254 }
1255
1256 #[test]
1257 fn parent_class_name_finds_parent() {
1258 let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1259 let doc = ParsedDoc::parse(src.to_string());
1260 assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
1261 }
1262
1263 #[test]
1264 fn parent_class_name_returns_none_for_top_level() {
1265 let src = "<?php\nclass Base {}";
1266 let doc = ParsedDoc::parse(src.to_string());
1267 assert!(parent_class_name(&doc, "Base").is_none());
1268 }
1269
1270 #[test]
1271 fn members_of_class_includes_parent_field() {
1272 let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1273 let doc = ParsedDoc::parse(src.to_string());
1274 let m = members_of_class(&doc, "Child");
1275 assert_eq!(m.parent.as_deref(), Some("Base"));
1276 }
1277
1278 #[test]
1279 fn members_of_class_finds_methods() {
1280 let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
1281 let doc = ParsedDoc::parse(src.to_string());
1282 let members = members_of_class(&doc, "Calc");
1283 let names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1284 assert!(names.contains(&"add"), "missing 'add'");
1285 assert!(names.contains(&"sub"), "missing 'sub'");
1286 }
1287
1288 #[test]
1289 fn members_of_unknown_class_is_empty() {
1290 let src = "<?php\nclass Calc { public function add() {} }";
1291 let doc = ParsedDoc::parse(src.to_string());
1292 let members = members_of_class(&doc, "Unknown");
1293 assert!(members.methods.is_empty());
1294 }
1295
1296 #[test]
1297 fn constructor_promoted_params_appear_as_properties() {
1298 let src = "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}";
1299 let doc = ParsedDoc::parse(src.to_string());
1300 let members = members_of_class(&doc, "Point");
1301 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1302 assert!(
1303 prop_names.contains(&"x"),
1304 "promoted param x should be a property"
1305 );
1306 assert!(
1307 prop_names.contains(&"y"),
1308 "promoted param y should be a property"
1309 );
1310 }
1311
1312 #[test]
1313 fn promoted_readonly_params_appear_in_readonly_properties() {
1314 let src = "<?php\nclass User {\n public function __construct(\n public readonly string $name,\n public int $age,\n ) {}\n}";
1315 let doc = ParsedDoc::parse(src.to_string());
1316 let members = members_of_class(&doc, "User");
1317 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1318 assert!(
1319 prop_names.contains(&"name"),
1320 "promoted param name should be a property"
1321 );
1322 assert!(
1323 prop_names.contains(&"age"),
1324 "promoted param age should be a property"
1325 );
1326 assert!(
1327 members.readonly_properties.contains(&"name".to_string()),
1328 "readonly promoted param name should be in readonly_properties"
1329 );
1330 assert!(
1331 !members.readonly_properties.contains(&"age".to_string()),
1332 "non-readonly promoted param age should not be in readonly_properties"
1333 );
1334 }
1335
1336 #[test]
1337 fn enum_instance_members_include_name() {
1338 let src = "<?php\nenum Status { case Active; case Inactive; }";
1339 let doc = ParsedDoc::parse(src.to_string());
1340 let members = members_of_class(&doc, "Status");
1341 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1342 assert!(
1343 prop_names.contains(&"name"),
1344 "pure enum should expose ->name"
1345 );
1346 assert!(
1347 !prop_names.contains(&"value"),
1348 "pure enum should not expose ->value"
1349 );
1350 }
1351
1352 #[test]
1353 fn backed_enum_exposes_value_and_factory_methods() {
1354 let src = "<?php\nenum Color: string { case Red = 'red'; }";
1355 let doc = ParsedDoc::parse(src.to_string());
1356 let members = members_of_class(&doc, "Color");
1357 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1358 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1359 assert!(
1360 prop_names.contains(&"value"),
1361 "backed enum should expose ->value"
1362 );
1363 assert!(
1364 method_names.contains(&"from"),
1365 "backed enum should have ::from()"
1366 );
1367 assert!(
1368 method_names.contains(&"tryFrom"),
1369 "backed enum should have ::tryFrom()"
1370 );
1371 assert!(
1372 method_names.contains(&"cases"),
1373 "enum should have ::cases()"
1374 );
1375 }
1376
1377 #[test]
1378 fn enum_cases_appear_as_constants() {
1379 let src = "<?php\nenum Status { case Active; case Inactive; }";
1380 let doc = ParsedDoc::parse(src.to_string());
1381 let members = members_of_class(&doc, "Status");
1382 assert!(members.constants.contains(&"Active".to_string()));
1383 assert!(members.constants.contains(&"Inactive".to_string()));
1384 }
1385
1386 #[test]
1387 fn trait_members_are_collected() {
1388 let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
1389 let doc = ParsedDoc::parse(src.to_string());
1390 let members = members_of_class(&doc, "Logging");
1391 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1392 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1393 assert!(
1394 method_names.contains(&"log"),
1395 "trait method log should be collected"
1396 );
1397 assert!(
1398 prop_names.contains(&"logFile"),
1399 "trait property logFile should be collected"
1400 );
1401 }
1402
1403 #[test]
1404 fn class_with_trait_use_lists_trait() {
1405 let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
1406 let doc = ParsedDoc::parse(src.to_string());
1407 let members = members_of_class(&doc, "App");
1408 assert!(
1409 members.trait_uses.contains(&"Logging".to_string()),
1410 "should list used trait"
1411 );
1412 }
1413
1414 #[test]
1415 fn var_docblock_with_explicit_varname_infers_type() {
1416 let src = "<?php\n/** @var Mailer $mailer */\n$mailer = $container->get('mailer');";
1417 let doc = ParsedDoc::parse(src.to_string());
1418 let tm = TypeMap::from_doc(&doc);
1419 assert_eq!(
1420 tm.get("$mailer"),
1421 Some("Mailer"),
1422 "@var with explicit name should map the variable"
1423 );
1424 }
1425
1426 #[test]
1427 fn var_docblock_without_varname_infers_from_assignment() {
1428 let src = "<?php\n/** @var Repository */\n$repo = $this->getRepository();";
1429 let doc = ParsedDoc::parse(src.to_string());
1430 let tm = TypeMap::from_doc(&doc);
1431 assert_eq!(
1432 tm.get("$repo"),
1433 Some("Repository"),
1434 "@var without name should use assignment LHS"
1435 );
1436 }
1437
1438 #[test]
1439 fn var_docblock_does_not_map_primitive_types() {
1440 let src = "<?php\n/** @var string */\n$name = 'hello';";
1441 let doc = ParsedDoc::parse(src.to_string());
1442 let tm = TypeMap::from_doc(&doc);
1443 assert!(
1445 tm.get("$name").is_none(),
1446 "primitive @var should not produce a class mapping"
1447 );
1448 }
1449
1450 #[test]
1451 fn var_nullable_docblock_maps_to_class() {
1452 let src = "<?php\n/** @var ?Mailer $mailer */\n$mailer = null;";
1455 let doc = ParsedDoc::parse(src.to_string());
1456 let tm = TypeMap::from_doc(&doc);
1457 assert_eq!(
1458 tm.get("$mailer"),
1459 Some("Mailer"),
1460 "@var ?Foo should map to 'Foo', not 'Foo|null'"
1461 );
1462 }
1463
1464 #[test]
1465 fn var_union_docblock_maps_first_class() {
1466 let src = "<?php\n/** @var Repository|null $repo */\n$repo = null;";
1468 let doc = ParsedDoc::parse(src.to_string());
1469 let tm = TypeMap::from_doc(&doc);
1470 assert_eq!(
1471 tm.get("$repo"),
1472 Some("Repository"),
1473 "@var Foo|null should map to 'Foo', not 'Foo|null'"
1474 );
1475 }
1476
1477 #[test]
1478 fn is_enum_pure() {
1479 let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
1480 let doc = ParsedDoc::parse(src.to_string());
1481 assert!(is_enum(&doc, "Suit"));
1482 assert!(!is_backed_enum(&doc, "Suit"));
1483 }
1484
1485 #[test]
1486 fn is_backed_enum_string() {
1487 let src = "<?php\nenum Status: string { case Active = 'active'; }";
1488 let doc = ParsedDoc::parse(src.to_string());
1489 assert!(is_enum(&doc, "Status"));
1490 assert!(is_backed_enum(&doc, "Status"));
1491 }
1492
1493 #[test]
1494 fn is_enum_false_for_class() {
1495 let src = "<?php\nclass Foo {}";
1496 let doc = ParsedDoc::parse(src.to_string());
1497 assert!(!is_enum(&doc, "Foo"));
1498 assert!(!is_backed_enum(&doc, "Foo"));
1499 }
1500
1501 #[test]
1502 fn array_map_with_typed_closure_populates_element_type() {
1503 let src = "<?php\n$objs = new Foo();\n$result = array_map(fn($x): Bar => $x->transform(), $objs);";
1504 let doc = ParsedDoc::parse(src.to_string());
1505 let tm = TypeMap::from_doc(&doc);
1506 assert_eq!(
1507 tm.get("$result[]"),
1508 Some("Bar"),
1509 "array_map with typed fn callback should store element type as $result[]"
1510 );
1511 }
1512
1513 #[test]
1514 fn foreach_propagates_array_map_element_type() {
1515 let src = "<?php\n$items = array_map(fn($x): Widget => $x, []);\nforeach ($items as $item) { $item-> }";
1516 let doc = ParsedDoc::parse(src.to_string());
1517 let tm = TypeMap::from_doc(&doc);
1518 assert_eq!(
1519 tm.get("$item"),
1520 Some("Widget"),
1521 "foreach over array_map result should propagate element type to loop variable"
1522 );
1523 }
1524
1525 #[test]
1526 fn closure_use_var_type_is_available_inside_body() {
1527 let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc->process(); };";
1528 let doc = ParsedDoc::parse(src.to_string());
1529 let tm = TypeMap::from_doc(&doc);
1530 assert_eq!(
1531 tm.get("$svc"),
1532 Some("PaymentService"),
1533 "captured use variable should retain its outer type inside closure body"
1534 );
1535 }
1536
1537 #[test]
1538 fn closure_use_var_inner_assignment_does_not_override_outer_type() {
1539 let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc = new OtherService(); };";
1542 let doc = ParsedDoc::parse(src.to_string());
1543 let tm = TypeMap::from_doc(&doc);
1544 assert_eq!(
1546 tm.get("$svc"),
1547 Some("PaymentService"),
1548 "outer type should not be overwritten by inner assignment in closure"
1549 );
1550 }
1551
1552 #[test]
1553 fn closure_bind_maps_this_to_obj_class() {
1554 let src = "<?php\n$service = new Mailer();\n$fn = Closure::bind(function() {}, $service);";
1555 let doc = ParsedDoc::parse(src.to_string());
1556 let tm = TypeMap::from_doc(&doc);
1557 assert_eq!(
1558 tm.get("$this"),
1559 Some("Mailer"),
1560 "Closure::bind with typed object should map $this to that class"
1561 );
1562 }
1563
1564 #[test]
1565 fn instanceof_narrows_variable_type() {
1566 let src = "<?php\nif ($x instanceof Foo) { $x->foo(); }";
1567 let doc = ParsedDoc::parse(src.to_string());
1568 let tm = TypeMap::from_doc(&doc);
1569 assert_eq!(
1570 tm.get("$x"),
1571 Some("Foo"),
1572 "instanceof should narrow $x to Foo inside the if body"
1573 );
1574 }
1575
1576 #[test]
1577 fn instanceof_narrows_fqn_to_short_name() {
1578 let src = "<?php\nif ($x instanceof App\\Services\\Mailer) { $x->send(); }";
1579 let doc = ParsedDoc::parse(src.to_string());
1580 let tm = TypeMap::from_doc(&doc);
1581 assert_eq!(
1582 tm.get("$x"),
1583 Some("Mailer"),
1584 "instanceof with FQN should narrow to short name"
1585 );
1586 }
1587
1588 #[test]
1589 fn closure_bind_to_maps_this_to_obj_class() {
1590 let src = "<?php\n$svc = new Logger();\n$fn = function() {};\n$bound = $fn->bindTo($svc);";
1591 let doc = ParsedDoc::parse(src.to_string());
1592 let tm = TypeMap::from_doc(&doc);
1593 assert_eq!(
1594 tm.get("$this"),
1595 Some("Logger"),
1596 "bindTo() should map $this to the bound object's class"
1597 );
1598 }
1599
1600 #[test]
1601 fn param_docblock_type_inferred() {
1602 let src = "<?php\n/**\n * @param Mailer $mailer\n */\nfunction send($mailer) { $mailer-> }";
1603 let doc = ParsedDoc::parse(src.to_string());
1604 let tm = TypeMap::from_doc(&doc);
1605 assert_eq!(tm.get("$mailer"), Some("Mailer"));
1606 }
1607
1608 #[test]
1609 fn param_docblock_does_not_override_ast_hint() {
1610 let src = "<?php\n/**\n * @param OtherClass $x\n */\nfunction foo(Foo $x) {}";
1611 let doc = ParsedDoc::parse(src.to_string());
1612 let tm = TypeMap::from_doc(&doc);
1613 assert_eq!(tm.get("$x"), Some("Foo"));
1615 }
1616
1617 #[test]
1618 fn method_chain_return_type_from_ast_hint() {
1619 let src = "<?php\nclass Repo {\n public function findFirst(): User { }\n}\nclass User { public function getName(): string {} }\n$repo = new Repo();\n$user = $repo->findFirst();";
1620 let doc = ParsedDoc::parse(src.to_string());
1621 let tm = TypeMap::from_doc(&doc);
1622 assert_eq!(tm.get("$user"), Some("User"));
1623 }
1624
1625 #[test]
1626 fn method_chain_return_type_from_docblock() {
1627 let src = "<?php\nclass Repo {\n /** @return Product */\n public function latest() {}\n}\n$repo = new Repo();\n$product = $repo->latest();";
1628 let doc = ParsedDoc::parse(src.to_string());
1629 let tm = TypeMap::from_doc(&doc);
1630 assert_eq!(tm.get("$product"), Some("Product"));
1631 }
1632
1633 #[test]
1634 fn not_null_check_preserves_existing_type() {
1635 let src = "<?php\n$x = new Foo();\nif ($x !== null) { $x-> }";
1636 let doc = ParsedDoc::parse(src.to_string());
1637 let tm = TypeMap::from_doc(&doc);
1638 assert_eq!(tm.get("$x"), Some("Foo"));
1639 }
1640
1641 #[test]
1642 fn self_return_type_resolves_to_class() {
1643 let src = "<?php\nclass Builder {\n public function setName(string $n): self { return $this; }\n}\n$b = new Builder();\n$b2 = $b->setName('x');";
1644 let doc = ParsedDoc::parse(src.to_string());
1645 let tm = TypeMap::from_doc(&doc);
1646 assert_eq!(tm.get("$b2"), Some("Builder"));
1647 }
1648
1649 #[test]
1650 fn null_coalesce_assign_infers_type() {
1651 let src = "<?php\n$obj ??= new Foo();";
1652 let doc = ParsedDoc::parse(src.to_string());
1653 let tm = TypeMap::from_doc(&doc);
1654 assert_eq!(tm.get("$obj"), Some("Foo"));
1655 }
1656
1657 #[test]
1658 fn docblock_property_appears_in_members() {
1659 let src =
1660 "<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
1661 let doc = ParsedDoc::parse(src.to_string());
1662 let members = members_of_class(&doc, "User");
1663 let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1664 assert!(props.contains(&"email"));
1665 assert!(props.contains(&"id"));
1666 }
1667
1668 #[test]
1669 fn docblock_method_appears_in_members() {
1670 let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
1671 let doc = ParsedDoc::parse(src.to_string());
1672 let members = members_of_class(&doc, "Model");
1673 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1674 assert!(method_names.contains(&"find"));
1675 assert!(method_names.contains(&"where"));
1676 let where_static = members
1677 .methods
1678 .iter()
1679 .find(|(n, _)| n == "where")
1680 .map(|(_, s)| *s);
1681 assert_eq!(where_static, Some(true));
1682 }
1683
1684 #[test]
1685 fn union_type_param_maps_both_classes() {
1686 let src = "<?php\nfunction f(Foo|Bar $x) {}";
1688 let doc = ParsedDoc::parse(src.to_string());
1689 let tm = TypeMap::from_doc(&doc);
1690 let val = tm.get("$x").expect("$x should be in the type map");
1691 assert!(
1692 val.contains("Foo"),
1693 "union type should contain 'Foo', got: {}",
1694 val
1695 );
1696 assert!(
1697 val.contains("Bar"),
1698 "union type should contain 'Bar', got: {}",
1699 val
1700 );
1701 }
1702
1703 #[test]
1704 fn nullable_param_resolves_to_class() {
1705 let src = "<?php\nfunction f(?Foo $x) {}";
1707 let doc = ParsedDoc::parse(src.to_string());
1708 let tm = TypeMap::from_doc(&doc);
1709 assert_eq!(
1710 tm.get("$x"),
1711 Some("Foo"),
1712 "nullable type hint ?Foo should map $x to Foo"
1713 );
1714 }
1715
1716 #[test]
1717 fn static_return_type_resolves_to_class() {
1718 let src = concat!(
1720 "<?php\n",
1721 "class Builder {\n",
1722 " public function build(): static { return $this; }\n",
1723 "}\n",
1724 "$b = new Builder();\n",
1725 "$b2 = $b->build();\n",
1726 );
1727 let doc = ParsedDoc::parse(src.to_string());
1728 let tm = TypeMap::from_doc(&doc);
1729 assert_eq!(
1730 tm.get("$b2"),
1731 Some("Builder"),
1732 "method returning :static should resolve to the enclosing class 'Builder'"
1733 );
1734 }
1735
1736 #[test]
1737 fn null_assignment_does_not_overwrite_class() {
1738 let src = "<?php\n$x = new Foo();\n$x = null;\n";
1741 let doc = ParsedDoc::parse(src.to_string());
1742 let tm = TypeMap::from_doc(&doc);
1743 assert_eq!(
1746 tm.get("$x"),
1747 Some("Foo"),
1748 "$x should retain its Foo type after being assigned null"
1749 );
1750 }
1751
1752 #[test]
1753 fn infers_type_from_assignment_inside_trait_method() {
1754 let src = "<?php\ntrait Builder { public function make(): void { $obj = new Widget(); } }";
1755 let doc = ParsedDoc::parse(src.to_string());
1756 let tm = TypeMap::from_doc(&doc);
1757 assert_eq!(
1758 tm.get("$obj"),
1759 Some("Widget"),
1760 "type map should walk into trait method bodies"
1761 );
1762 }
1763
1764 #[test]
1765 fn infers_type_from_assignment_inside_enum_method() {
1766 let src = "<?php\nenum Color { case Red; public function make(): void { $obj = new Palette(); } }";
1767 let doc = ParsedDoc::parse(src.to_string());
1768 let tm = TypeMap::from_doc(&doc);
1769 assert_eq!(
1770 tm.get("$obj"),
1771 Some("Palette"),
1772 "type map should walk into enum method bodies"
1773 );
1774 }
1775}