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