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