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.to_string());
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.to_string());
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)
845 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
846 {
847 return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
848 }
849 StmtKind::Namespace(ns) => {
850 if let NamespaceBody::Braced(inner) = &ns.body
851 && let found @ Some(_) = parent_in_stmts(inner, class_name)
852 {
853 return found;
854 }
855 }
856 _ => {}
857 }
858 }
859 None
860}
861
862#[derive(Debug, Default)]
864pub struct ClassMembers {
865 pub methods: Vec<(String, bool)>,
867 pub properties: Vec<(String, bool)>,
869 pub readonly_properties: Vec<String>,
871 pub constants: Vec<String>,
872 pub parent: Option<String>,
874 pub trait_uses: Vec<String>,
876 pub found: bool,
880}
881
882pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
885 let mut out = ClassMembers::default();
886 out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
887 out
888}
889
890fn collect_members_stmts(
891 source: &str,
892 stmts: &[Stmt<'_, '_>],
893 class_name: &str,
894 out: &mut ClassMembers,
895) -> Option<String> {
896 for stmt in stmts {
897 match &stmt.kind {
898 StmtKind::Class(c)
899 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
900 {
901 out.found = true;
902 if let Some(raw) = docblock_before(source, stmt.span.start) {
904 let db = parse_docblock(&raw);
905 for prop in &db.properties {
906 out.properties.push((prop.name.clone(), false));
907 }
908 for method in &db.methods {
909 out.methods.push((method.name.clone(), method.is_static));
910 }
911 }
912 for member in c.members.iter() {
913 match &member.kind {
914 ClassMemberKind::Method(m) => {
915 out.methods.push((m.name.to_string(), m.is_static));
916 if m.name == "__construct" {
918 for p in m.params.iter() {
919 if p.visibility.is_some() {
920 out.properties.push((p.name.to_string(), false));
921 let param_src =
925 &source[p.span.start as usize..p.span.end as usize];
926 if param_src.contains("readonly") {
927 out.readonly_properties.push(p.name.to_string());
928 }
929 }
930 }
931 }
932 }
933 ClassMemberKind::Property(p) => {
934 out.properties.push((p.name.to_string(), p.is_static));
935 if p.is_readonly {
936 out.readonly_properties.push(p.name.to_string());
937 }
938 }
939 ClassMemberKind::ClassConst(c) => {
940 out.constants.push(c.name.to_string());
941 }
942 ClassMemberKind::TraitUse(t) => {
943 for name in t.traits.iter() {
944 out.trait_uses.push(name.to_string_repr().to_string());
945 }
946 }
947 }
948 }
949 return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
950 }
951 StmtKind::Enum(e) if e.name == class_name => {
952 out.found = true;
953 let is_backed = e.scalar_type.is_some();
954 out.properties.push(("name".to_string(), false));
956 if is_backed {
957 out.properties.push(("value".to_string(), false));
958 }
959 out.methods.push(("cases".to_string(), true));
961 if is_backed {
962 out.methods.push(("from".to_string(), true));
963 out.methods.push(("tryFrom".to_string(), true));
964 }
965 for member in e.members.iter() {
967 match &member.kind {
968 EnumMemberKind::Case(c) => {
969 out.constants.push(c.name.to_string());
970 }
971 EnumMemberKind::Method(m) => {
972 out.methods.push((m.name.to_string(), m.is_static));
973 }
974 EnumMemberKind::ClassConst(c) => {
975 out.constants.push(c.name.to_string());
976 }
977 _ => {}
978 }
979 }
980 return None; }
982 StmtKind::Trait(t) if t.name == class_name => {
983 out.found = true;
984 for member in t.members.iter() {
985 match &member.kind {
986 ClassMemberKind::Method(m) => {
987 out.methods.push((m.name.to_string(), m.is_static));
988 }
989 ClassMemberKind::Property(p) => {
990 out.properties.push((p.name.to_string(), p.is_static));
991 }
992 ClassMemberKind::ClassConst(c) => {
993 out.constants.push(c.name.to_string());
994 }
995 ClassMemberKind::TraitUse(t) => {
996 for name in t.traits.iter() {
997 out.trait_uses.push(name.to_string_repr().to_string());
998 }
999 }
1000 }
1001 }
1002 return None; }
1004 StmtKind::Namespace(ns) => {
1005 if let NamespaceBody::Braced(inner) = &ns.body {
1006 let result = collect_members_stmts(source, inner, class_name, out);
1007 if result.is_some() {
1008 return result;
1009 }
1010 }
1011 }
1012 _ => {}
1013 }
1014 }
1015 None
1016}
1017
1018pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
1020 let source = doc.source();
1021 mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
1022}
1023
1024fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
1025 for stmt in stmts {
1026 match &stmt.kind {
1027 StmtKind::Class(c)
1028 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
1029 {
1030 if let Some(raw) = docblock_before(source, stmt.span.start) {
1031 return parse_docblock(&raw).mixins;
1032 }
1033 return vec![];
1034 }
1035 StmtKind::Namespace(ns) => {
1036 if let NamespaceBody::Braced(inner) = &ns.body {
1037 let found = mixin_classes_in_stmts(source, inner, class_name);
1038 if !found.is_empty() {
1039 return found;
1040 }
1041 }
1042 }
1043 _ => {}
1044 }
1045 }
1046 vec![]
1047}
1048
1049pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
1051 let sv = doc.view();
1052 enclosing_class_in_stmts(sv, &doc.program().stmts, position)
1053}
1054
1055pub fn enclosing_class_range_at(
1060 doc: &ParsedDoc,
1061 position: Position,
1062) -> Option<tower_lsp::lsp_types::Range> {
1063 let sv = doc.view();
1064 enclosing_class_range_in_stmts(sv, &doc.program().stmts, position)
1065}
1066
1067pub fn collect_all_class_ranges(doc: &ParsedDoc) -> Vec<tower_lsp::lsp_types::Range> {
1071 let sv = doc.view();
1072 let mut out = Vec::new();
1073 collect_class_ranges_in_stmts(sv, &doc.program().stmts, &mut out);
1074 out
1075}
1076
1077fn collect_class_ranges_in_stmts(
1078 sv: SourceView<'_>,
1079 stmts: &[Stmt<'_, '_>],
1080 out: &mut Vec<tower_lsp::lsp_types::Range>,
1081) {
1082 for stmt in stmts {
1083 match &stmt.kind {
1084 StmtKind::Class(_)
1085 | StmtKind::Interface(_)
1086 | StmtKind::Trait(_)
1087 | StmtKind::Enum(_) => {
1088 out.push(sv.range_of(stmt.span));
1089 }
1090 StmtKind::Namespace(ns) => {
1091 if let NamespaceBody::Braced(inner) = &ns.body {
1092 collect_class_ranges_in_stmts(sv, inner, out);
1093 }
1094 }
1095 _ => {}
1096 }
1097 }
1098}
1099
1100fn enclosing_class_range_in_stmts(
1101 sv: SourceView<'_>,
1102 stmts: &[Stmt<'_, '_>],
1103 pos: Position,
1104) -> Option<tower_lsp::lsp_types::Range> {
1105 for stmt in stmts {
1106 match &stmt.kind {
1107 StmtKind::Class(_)
1108 | StmtKind::Interface(_)
1109 | StmtKind::Trait(_)
1110 | StmtKind::Enum(_) => {
1111 let r = sv.range_of(stmt.span);
1112 if pos.line >= r.start.line && pos.line <= r.end.line {
1113 return Some(r);
1114 }
1115 }
1116 StmtKind::Namespace(ns) => {
1117 if let NamespaceBody::Braced(inner) = &ns.body
1118 && let Some(r) = enclosing_class_range_in_stmts(sv, inner, pos)
1119 {
1120 return Some(r);
1121 }
1122 }
1123 _ => {}
1124 }
1125 }
1126 None
1127}
1128
1129fn enclosing_class_in_stmts(
1130 sv: SourceView<'_>,
1131 stmts: &[Stmt<'_, '_>],
1132 pos: Position,
1133) -> Option<String> {
1134 for stmt in stmts {
1135 match &stmt.kind {
1136 StmtKind::Class(c) => {
1137 let start = sv.position_of(stmt.span.start).line;
1138 let end = sv.position_of(stmt.span.end).line;
1139 if pos.line >= start && pos.line <= end {
1140 return c.name.map(|n| n.to_string());
1141 }
1142 }
1143 StmtKind::Interface(i) => {
1144 let start = sv.position_of(stmt.span.start).line;
1145 let end = sv.position_of(stmt.span.end).line;
1146 if pos.line >= start && pos.line <= end {
1147 return Some(i.name.to_string());
1148 }
1149 }
1150 StmtKind::Trait(t) => {
1151 let start = sv.position_of(stmt.span.start).line;
1152 let end = sv.position_of(stmt.span.end).line;
1153 if pos.line >= start && pos.line <= end {
1154 return Some(t.name.to_string());
1155 }
1156 }
1157 StmtKind::Enum(e) => {
1158 let start = sv.position_of(stmt.span.start).line;
1159 let end = sv.position_of(stmt.span.end).line;
1160 if pos.line >= start && pos.line <= end {
1161 return Some(e.name.to_string());
1162 }
1163 }
1164 StmtKind::Namespace(ns) => {
1165 if let NamespaceBody::Braced(inner) = &ns.body
1166 && let Some(found) = enclosing_class_in_stmts(sv, inner, pos)
1167 {
1168 return Some(found);
1169 }
1170 }
1171 _ => {}
1172 }
1173 }
1174 None
1175}
1176
1177pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
1179 let mut out = Vec::new();
1180 collect_params_stmts(&doc.program().stmts, func_name, &mut out);
1181 out
1182}
1183
1184pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
1187 let mut out = Vec::new();
1188 collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
1189 out
1190}
1191
1192fn collect_method_params_stmts(
1193 stmts: &[php_ast::Stmt<'_, '_>],
1194 class_name: &str,
1195 method_name: &str,
1196 out: &mut Vec<String>,
1197) {
1198 for stmt in stmts {
1199 match &stmt.kind {
1200 StmtKind::Class(c)
1201 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
1202 {
1203 for member in c.members.iter() {
1204 if let ClassMemberKind::Method(m) = &member.kind
1205 && m.name == method_name
1206 {
1207 for p in m.params.iter() {
1208 out.push(p.name.to_string());
1209 }
1210 return;
1211 }
1212 }
1213 }
1214 StmtKind::Namespace(ns) => {
1215 if let NamespaceBody::Braced(inner) = &ns.body {
1216 collect_method_params_stmts(inner, class_name, method_name, out);
1217 }
1218 }
1219 _ => {}
1220 }
1221 }
1222}
1223
1224pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1226 is_enum_in_stmts(&doc.program().stmts, class_name)
1227}
1228
1229fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1230 for stmt in stmts {
1231 match &stmt.kind {
1232 StmtKind::Enum(e) if e.name == name => return true,
1233 StmtKind::Namespace(ns) => {
1234 if let NamespaceBody::Braced(inner) = &ns.body
1235 && is_enum_in_stmts(inner, name)
1236 {
1237 return true;
1238 }
1239 }
1240 _ => {}
1241 }
1242 }
1243 false
1244}
1245
1246pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1249 is_backed_enum_in_stmts(&doc.program().stmts, class_name)
1250}
1251
1252fn is_backed_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1253 for stmt in stmts {
1254 match &stmt.kind {
1255 StmtKind::Enum(e) if e.name == name => return e.scalar_type.is_some(),
1256 StmtKind::Namespace(ns) => {
1257 if let NamespaceBody::Braced(inner) = &ns.body
1258 && is_backed_enum_in_stmts(inner, name)
1259 {
1260 return true;
1261 }
1262 }
1263 _ => {}
1264 }
1265 }
1266 false
1267}
1268
1269fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
1270 for stmt in stmts {
1271 match &stmt.kind {
1272 StmtKind::Function(f) if f.name == func_name => {
1273 for p in f.params.iter() {
1274 out.push(p.name.to_string());
1275 }
1276 return;
1277 }
1278 StmtKind::Class(c) => {
1279 for member in c.members.iter() {
1280 if let ClassMemberKind::Method(m) = &member.kind
1281 && m.name == func_name
1282 {
1283 for p in m.params.iter() {
1284 out.push(p.name.to_string());
1285 }
1286 return;
1287 }
1288 }
1289 }
1290 StmtKind::Namespace(ns) => {
1291 if let NamespaceBody::Braced(inner) = &ns.body {
1292 collect_params_stmts(inner, func_name, out);
1293 }
1294 }
1295 _ => {}
1296 }
1297 }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303
1304 #[test]
1305 fn infers_type_from_new_expression() {
1306 let src = "<?php\n$obj = new Foo();";
1307 let doc = ParsedDoc::parse(src.to_string());
1308 let tm = TypeMap::from_doc(&doc);
1309 assert_eq!(tm.get("$obj"), Some("Foo"));
1310 }
1311
1312 #[test]
1313 fn unknown_variable_returns_none() {
1314 let src = "<?php\n$obj = new Foo();";
1315 let doc = ParsedDoc::parse(src.to_string());
1316 let tm = TypeMap::from_doc(&doc);
1317 assert!(tm.get("$other").is_none());
1318 }
1319
1320 #[test]
1321 fn multiple_assignments() {
1322 let src = "<?php\n$a = new Foo();\n$b = new Bar();";
1323 let doc = ParsedDoc::parse(src.to_string());
1324 let tm = TypeMap::from_doc(&doc);
1325 assert_eq!(tm.get("$a"), Some("Foo"));
1326 assert_eq!(tm.get("$b"), Some("Bar"));
1327 }
1328
1329 #[test]
1330 fn later_assignment_overwrites() {
1331 let src = "<?php\n$a = new Foo();\n$a = new Bar();";
1332 let doc = ParsedDoc::parse(src.to_string());
1333 let tm = TypeMap::from_doc(&doc);
1334 assert_eq!(tm.get("$a"), Some("Bar"));
1335 }
1336
1337 #[test]
1338 fn infers_type_from_typed_param() {
1339 let src = "<?php\nfunction process(Mailer $mailer): void { $mailer-> }";
1340 let doc = ParsedDoc::parse(src.to_string());
1341 let tm = TypeMap::from_doc(&doc);
1342 assert_eq!(tm.get("$mailer"), Some("Mailer"));
1343 }
1344
1345 #[test]
1346 fn parent_class_name_finds_parent() {
1347 let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1348 let doc = ParsedDoc::parse(src.to_string());
1349 assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
1350 }
1351
1352 #[test]
1353 fn parent_class_name_returns_none_for_top_level() {
1354 let src = "<?php\nclass Base {}";
1355 let doc = ParsedDoc::parse(src.to_string());
1356 assert!(parent_class_name(&doc, "Base").is_none());
1357 }
1358
1359 #[test]
1360 fn members_of_class_includes_parent_field() {
1361 let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1362 let doc = ParsedDoc::parse(src.to_string());
1363 let m = members_of_class(&doc, "Child");
1364 assert_eq!(m.parent.as_deref(), Some("Base"));
1365 }
1366
1367 #[test]
1368 fn members_of_class_finds_methods() {
1369 let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
1370 let doc = ParsedDoc::parse(src.to_string());
1371 let members = members_of_class(&doc, "Calc");
1372 let names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1373 assert!(names.contains(&"add"), "missing 'add'");
1374 assert!(names.contains(&"sub"), "missing 'sub'");
1375 }
1376
1377 #[test]
1378 fn members_of_unknown_class_is_empty() {
1379 let src = "<?php\nclass Calc { public function add() {} }";
1380 let doc = ParsedDoc::parse(src.to_string());
1381 let members = members_of_class(&doc, "Unknown");
1382 assert!(members.methods.is_empty());
1383 }
1384
1385 #[test]
1386 fn constructor_promoted_params_appear_as_properties() {
1387 let src = "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}";
1388 let doc = ParsedDoc::parse(src.to_string());
1389 let members = members_of_class(&doc, "Point");
1390 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1391 assert!(
1392 prop_names.contains(&"x"),
1393 "promoted param x should be a property"
1394 );
1395 assert!(
1396 prop_names.contains(&"y"),
1397 "promoted param y should be a property"
1398 );
1399 }
1400
1401 #[test]
1402 fn promoted_readonly_params_appear_in_readonly_properties() {
1403 let src = "<?php\nclass User {\n public function __construct(\n public readonly string $name,\n public int $age,\n ) {}\n}";
1404 let doc = ParsedDoc::parse(src.to_string());
1405 let members = members_of_class(&doc, "User");
1406 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1407 assert!(
1408 prop_names.contains(&"name"),
1409 "promoted param name should be a property"
1410 );
1411 assert!(
1412 prop_names.contains(&"age"),
1413 "promoted param age should be a property"
1414 );
1415 assert!(
1416 members.readonly_properties.contains(&"name".to_string()),
1417 "readonly promoted param name should be in readonly_properties"
1418 );
1419 assert!(
1420 !members.readonly_properties.contains(&"age".to_string()),
1421 "non-readonly promoted param age should not be in readonly_properties"
1422 );
1423 }
1424
1425 #[test]
1426 fn enum_instance_members_include_name() {
1427 let src = "<?php\nenum Status { case Active; case Inactive; }";
1428 let doc = ParsedDoc::parse(src.to_string());
1429 let members = members_of_class(&doc, "Status");
1430 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1431 assert!(
1432 prop_names.contains(&"name"),
1433 "pure enum should expose ->name"
1434 );
1435 assert!(
1436 !prop_names.contains(&"value"),
1437 "pure enum should not expose ->value"
1438 );
1439 }
1440
1441 #[test]
1442 fn backed_enum_exposes_value_and_factory_methods() {
1443 let src = "<?php\nenum Color: string { case Red = 'red'; }";
1444 let doc = ParsedDoc::parse(src.to_string());
1445 let members = members_of_class(&doc, "Color");
1446 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1447 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1448 assert!(
1449 prop_names.contains(&"value"),
1450 "backed enum should expose ->value"
1451 );
1452 assert!(
1453 method_names.contains(&"from"),
1454 "backed enum should have ::from()"
1455 );
1456 assert!(
1457 method_names.contains(&"tryFrom"),
1458 "backed enum should have ::tryFrom()"
1459 );
1460 assert!(
1461 method_names.contains(&"cases"),
1462 "enum should have ::cases()"
1463 );
1464 }
1465
1466 #[test]
1467 fn enum_cases_appear_as_constants() {
1468 let src = "<?php\nenum Status { case Active; case Inactive; }";
1469 let doc = ParsedDoc::parse(src.to_string());
1470 let members = members_of_class(&doc, "Status");
1471 assert!(members.constants.contains(&"Active".to_string()));
1472 assert!(members.constants.contains(&"Inactive".to_string()));
1473 }
1474
1475 #[test]
1476 fn trait_members_are_collected() {
1477 let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
1478 let doc = ParsedDoc::parse(src.to_string());
1479 let members = members_of_class(&doc, "Logging");
1480 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1481 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1482 assert!(
1483 method_names.contains(&"log"),
1484 "trait method log should be collected"
1485 );
1486 assert!(
1487 prop_names.contains(&"logFile"),
1488 "trait property logFile should be collected"
1489 );
1490 }
1491
1492 #[test]
1493 fn class_with_trait_use_lists_trait() {
1494 let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
1495 let doc = ParsedDoc::parse(src.to_string());
1496 let members = members_of_class(&doc, "App");
1497 assert!(
1498 members.trait_uses.contains(&"Logging".to_string()),
1499 "should list used trait"
1500 );
1501 }
1502
1503 #[test]
1504 fn var_docblock_with_explicit_varname_infers_type() {
1505 let src = "<?php\n/** @var Mailer $mailer */\n$mailer = $container->get('mailer');";
1506 let doc = ParsedDoc::parse(src.to_string());
1507 let tm = TypeMap::from_doc(&doc);
1508 assert_eq!(
1509 tm.get("$mailer"),
1510 Some("Mailer"),
1511 "@var with explicit name should map the variable"
1512 );
1513 }
1514
1515 #[test]
1516 fn var_docblock_without_varname_infers_from_assignment() {
1517 let src = "<?php\n/** @var Repository */\n$repo = $this->getRepository();";
1518 let doc = ParsedDoc::parse(src.to_string());
1519 let tm = TypeMap::from_doc(&doc);
1520 assert_eq!(
1521 tm.get("$repo"),
1522 Some("Repository"),
1523 "@var without name should use assignment LHS"
1524 );
1525 }
1526
1527 #[test]
1528 fn var_docblock_does_not_map_primitive_types() {
1529 let src = "<?php\n/** @var string */\n$name = 'hello';";
1530 let doc = ParsedDoc::parse(src.to_string());
1531 let tm = TypeMap::from_doc(&doc);
1532 assert!(
1534 tm.get("$name").is_none(),
1535 "primitive @var should not produce a class mapping"
1536 );
1537 }
1538
1539 #[test]
1540 fn var_nullable_docblock_maps_to_class() {
1541 let src = "<?php\n/** @var ?Mailer $mailer */\n$mailer = null;";
1544 let doc = ParsedDoc::parse(src.to_string());
1545 let tm = TypeMap::from_doc(&doc);
1546 assert_eq!(
1547 tm.get("$mailer"),
1548 Some("Mailer"),
1549 "@var ?Foo should map to 'Foo', not 'Foo|null'"
1550 );
1551 }
1552
1553 #[test]
1554 fn var_union_docblock_maps_first_class() {
1555 let src = "<?php\n/** @var Repository|null $repo */\n$repo = null;";
1557 let doc = ParsedDoc::parse(src.to_string());
1558 let tm = TypeMap::from_doc(&doc);
1559 assert_eq!(
1560 tm.get("$repo"),
1561 Some("Repository"),
1562 "@var Foo|null should map to 'Foo', not 'Foo|null'"
1563 );
1564 }
1565
1566 #[test]
1567 fn is_enum_pure() {
1568 let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
1569 let doc = ParsedDoc::parse(src.to_string());
1570 assert!(is_enum(&doc, "Suit"));
1571 assert!(!is_backed_enum(&doc, "Suit"));
1572 }
1573
1574 #[test]
1575 fn is_backed_enum_string() {
1576 let src = "<?php\nenum Status: string { case Active = 'active'; }";
1577 let doc = ParsedDoc::parse(src.to_string());
1578 assert!(is_enum(&doc, "Status"));
1579 assert!(is_backed_enum(&doc, "Status"));
1580 }
1581
1582 #[test]
1583 fn is_enum_false_for_class() {
1584 let src = "<?php\nclass Foo {}";
1585 let doc = ParsedDoc::parse(src.to_string());
1586 assert!(!is_enum(&doc, "Foo"));
1587 assert!(!is_backed_enum(&doc, "Foo"));
1588 }
1589
1590 #[test]
1591 fn array_map_with_typed_closure_populates_element_type() {
1592 let src = "<?php\n$objs = new Foo();\n$result = array_map(fn($x): Bar => $x->transform(), $objs);";
1593 let doc = ParsedDoc::parse(src.to_string());
1594 let tm = TypeMap::from_doc(&doc);
1595 assert_eq!(
1596 tm.get("$result[]"),
1597 Some("Bar"),
1598 "array_map with typed fn callback should store element type as $result[]"
1599 );
1600 }
1601
1602 #[test]
1603 fn foreach_propagates_array_map_element_type() {
1604 let src = "<?php\n$items = array_map(fn($x): Widget => $x, []);\nforeach ($items as $item) { $item-> }";
1605 let doc = ParsedDoc::parse(src.to_string());
1606 let tm = TypeMap::from_doc(&doc);
1607 assert_eq!(
1608 tm.get("$item"),
1609 Some("Widget"),
1610 "foreach over array_map result should propagate element type to loop variable"
1611 );
1612 }
1613
1614 #[test]
1615 fn closure_use_var_type_is_available_inside_body() {
1616 let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc->process(); };";
1617 let doc = ParsedDoc::parse(src.to_string());
1618 let tm = TypeMap::from_doc(&doc);
1619 assert_eq!(
1620 tm.get("$svc"),
1621 Some("PaymentService"),
1622 "captured use variable should retain its outer type inside closure body"
1623 );
1624 }
1625
1626 #[test]
1627 fn closure_use_var_inner_assignment_does_not_override_outer_type() {
1628 let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc = new OtherService(); };";
1631 let doc = ParsedDoc::parse(src.to_string());
1632 let tm = TypeMap::from_doc(&doc);
1633 assert_eq!(
1635 tm.get("$svc"),
1636 Some("PaymentService"),
1637 "outer type should not be overwritten by inner assignment in closure"
1638 );
1639 }
1640
1641 #[test]
1642 fn closure_bind_maps_this_to_obj_class() {
1643 let src = "<?php\n$service = new Mailer();\n$fn = Closure::bind(function() {}, $service);";
1644 let doc = ParsedDoc::parse(src.to_string());
1645 let tm = TypeMap::from_doc(&doc);
1646 assert_eq!(
1647 tm.get("$this"),
1648 Some("Mailer"),
1649 "Closure::bind with typed object should map $this to that class"
1650 );
1651 }
1652
1653 #[test]
1654 fn instanceof_narrows_variable_type() {
1655 let src = "<?php\nif ($x instanceof Foo) { $x->foo(); }";
1656 let doc = ParsedDoc::parse(src.to_string());
1657 let tm = TypeMap::from_doc(&doc);
1658 assert_eq!(
1659 tm.get("$x"),
1660 Some("Foo"),
1661 "instanceof should narrow $x to Foo inside the if body"
1662 );
1663 }
1664
1665 #[test]
1666 fn instanceof_narrows_fqn_to_short_name() {
1667 let src = "<?php\nif ($x instanceof App\\Services\\Mailer) { $x->send(); }";
1668 let doc = ParsedDoc::parse(src.to_string());
1669 let tm = TypeMap::from_doc(&doc);
1670 assert_eq!(
1671 tm.get("$x"),
1672 Some("Mailer"),
1673 "instanceof with FQN should narrow to short name"
1674 );
1675 }
1676
1677 #[test]
1678 fn closure_bind_to_maps_this_to_obj_class() {
1679 let src = "<?php\n$svc = new Logger();\n$fn = function() {};\n$bound = $fn->bindTo($svc);";
1680 let doc = ParsedDoc::parse(src.to_string());
1681 let tm = TypeMap::from_doc(&doc);
1682 assert_eq!(
1683 tm.get("$this"),
1684 Some("Logger"),
1685 "bindTo() should map $this to the bound object's class"
1686 );
1687 }
1688
1689 #[test]
1690 fn param_docblock_type_inferred() {
1691 let src = "<?php\n/**\n * @param Mailer $mailer\n */\nfunction send($mailer) { $mailer-> }";
1692 let doc = ParsedDoc::parse(src.to_string());
1693 let tm = TypeMap::from_doc(&doc);
1694 assert_eq!(tm.get("$mailer"), Some("Mailer"));
1695 }
1696
1697 #[test]
1698 fn param_docblock_does_not_override_ast_hint() {
1699 let src = "<?php\n/**\n * @param OtherClass $x\n */\nfunction foo(Foo $x) {}";
1700 let doc = ParsedDoc::parse(src.to_string());
1701 let tm = TypeMap::from_doc(&doc);
1702 assert_eq!(tm.get("$x"), Some("Foo"));
1704 }
1705
1706 #[test]
1707 fn method_chain_return_type_from_ast_hint() {
1708 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();";
1709 let doc = ParsedDoc::parse(src.to_string());
1710 let tm = TypeMap::from_doc(&doc);
1711 assert_eq!(tm.get("$user"), Some("User"));
1712 }
1713
1714 #[test]
1715 fn method_chain_return_type_from_docblock() {
1716 let src = "<?php\nclass Repo {\n /** @return Product */\n public function latest() {}\n}\n$repo = new Repo();\n$product = $repo->latest();";
1717 let doc = ParsedDoc::parse(src.to_string());
1718 let tm = TypeMap::from_doc(&doc);
1719 assert_eq!(tm.get("$product"), Some("Product"));
1720 }
1721
1722 #[test]
1723 fn not_null_check_preserves_existing_type() {
1724 let src = "<?php\n$x = new Foo();\nif ($x !== null) { $x-> }";
1725 let doc = ParsedDoc::parse(src.to_string());
1726 let tm = TypeMap::from_doc(&doc);
1727 assert_eq!(tm.get("$x"), Some("Foo"));
1728 }
1729
1730 #[test]
1731 fn self_return_type_resolves_to_class() {
1732 let src = "<?php\nclass Builder {\n public function setName(string $n): self { return $this; }\n}\n$b = new Builder();\n$b2 = $b->setName('x');";
1733 let doc = ParsedDoc::parse(src.to_string());
1734 let tm = TypeMap::from_doc(&doc);
1735 assert_eq!(tm.get("$b2"), Some("Builder"));
1736 }
1737
1738 #[test]
1739 fn null_coalesce_assign_infers_type() {
1740 let src = "<?php\n$obj ??= new Foo();";
1741 let doc = ParsedDoc::parse(src.to_string());
1742 let tm = TypeMap::from_doc(&doc);
1743 assert_eq!(tm.get("$obj"), Some("Foo"));
1744 }
1745
1746 #[test]
1747 fn docblock_property_appears_in_members() {
1748 let src =
1749 "<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
1750 let doc = ParsedDoc::parse(src.to_string());
1751 let members = members_of_class(&doc, "User");
1752 let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1753 assert!(props.contains(&"email"));
1754 assert!(props.contains(&"id"));
1755 }
1756
1757 #[test]
1758 fn docblock_method_appears_in_members() {
1759 let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
1760 let doc = ParsedDoc::parse(src.to_string());
1761 let members = members_of_class(&doc, "Model");
1762 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1763 assert!(method_names.contains(&"find"));
1764 assert!(method_names.contains(&"where"));
1765 let where_static = members
1766 .methods
1767 .iter()
1768 .find(|(n, _)| n == "where")
1769 .map(|(_, s)| *s);
1770 assert_eq!(where_static, Some(true));
1771 }
1772
1773 #[test]
1774 fn union_type_param_maps_both_classes() {
1775 let src = "<?php\nfunction f(Foo|Bar $x) {}";
1777 let doc = ParsedDoc::parse(src.to_string());
1778 let tm = TypeMap::from_doc(&doc);
1779 let val = tm.get("$x").expect("$x should be in the type map");
1780 assert!(
1781 val.contains("Foo"),
1782 "union type should contain 'Foo', got: {}",
1783 val
1784 );
1785 assert!(
1786 val.contains("Bar"),
1787 "union type should contain 'Bar', got: {}",
1788 val
1789 );
1790 }
1791
1792 #[test]
1793 fn nullable_param_resolves_to_class() {
1794 let src = "<?php\nfunction f(?Foo $x) {}";
1796 let doc = ParsedDoc::parse(src.to_string());
1797 let tm = TypeMap::from_doc(&doc);
1798 assert_eq!(
1799 tm.get("$x"),
1800 Some("Foo"),
1801 "nullable type hint ?Foo should map $x to Foo"
1802 );
1803 }
1804
1805 #[test]
1806 fn static_return_type_resolves_to_class() {
1807 let src = concat!(
1809 "<?php\n",
1810 "class Builder {\n",
1811 " public function build(): static { return $this; }\n",
1812 "}\n",
1813 "$b = new Builder();\n",
1814 "$b2 = $b->build();\n",
1815 );
1816 let doc = ParsedDoc::parse(src.to_string());
1817 let tm = TypeMap::from_doc(&doc);
1818 assert_eq!(
1819 tm.get("$b2"),
1820 Some("Builder"),
1821 "method returning :static should resolve to the enclosing class 'Builder'"
1822 );
1823 }
1824
1825 #[test]
1826 fn null_assignment_does_not_overwrite_class() {
1827 let src = "<?php\n$x = new Foo();\n$x = null;\n";
1830 let doc = ParsedDoc::parse(src.to_string());
1831 let tm = TypeMap::from_doc(&doc);
1832 assert_eq!(
1835 tm.get("$x"),
1836 Some("Foo"),
1837 "$x should retain its Foo type after being assigned null"
1838 );
1839 }
1840
1841 #[test]
1842 fn infers_type_from_assignment_inside_trait_method() {
1843 let src = "<?php\ntrait Builder { public function make(): void { $obj = new Widget(); } }";
1844 let doc = ParsedDoc::parse(src.to_string());
1845 let tm = TypeMap::from_doc(&doc);
1846 assert_eq!(
1847 tm.get("$obj"),
1848 Some("Widget"),
1849 "type map should walk into trait method bodies"
1850 );
1851 }
1852
1853 #[test]
1854 fn infers_type_from_assignment_inside_enum_method() {
1855 let src = "<?php\nenum Color { case Red; public function make(): void { $obj = new Palette(); } }";
1856 let doc = ParsedDoc::parse(src.to_string());
1857 let tm = TypeMap::from_doc(&doc);
1858 assert_eq!(
1859 tm.get("$obj"),
1860 Some("Palette"),
1861 "type map should walk into enum method bodies"
1862 );
1863 }
1864}