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