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 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::text::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) {
110 if let Some(parent) = parent_class_name(doc, enc_class) {
111 let short = fqn_short_name(&parent);
112 Some(short.to_string())
113 } else {
114 let short = fqn_short_name(fqcn);
115 Some(short.to_string())
116 }
117 } else {
118 let short = fqn_short_name(fqcn);
119 Some(short.to_string())
120 }
121 }
122 Atomic::TIntersection { parts } => {
123 let intersection_classes: Vec<String> = parts
124 .iter()
125 .flat_map(|part| {
126 part.types.iter().filter_map(|a| match a {
127 Atomic::TNamedObject { fqcn, .. }
128 | Atomic::TSelf { fqcn }
129 | Atomic::TStaticObject { fqcn } => {
130 let short = fqn_short_name(fqcn);
131 Some(short.to_string())
132 }
133 Atomic::TParent { fqcn } => {
134 if let (Some(doc), Some(enc_class)) = (doc, enclosing_class) {
135 if let Some(parent) = parent_class_name(doc, enc_class) {
136 let short = fqn_short_name(&parent);
137 Some(short.to_string())
138 } else {
139 let short =
140 fqcn.rsplit('\\').next().unwrap_or(fqcn.as_ref());
141 Some(short.to_string())
142 }
143 } else {
144 let short = fqn_short_name(fqcn);
145 Some(short.to_string())
146 }
147 }
148 _ => None,
149 })
150 })
151 .collect();
152 if intersection_classes.is_empty() {
153 None
154 } else {
155 Some(intersection_classes.join("|"))
156 }
157 }
158 _ => None,
159 })
160 .collect();
161 if classes.is_empty() {
162 None
163 } else {
164 Some(classes.join("|"))
165 }
166}
167
168fn docblock_class_parts(type_hint: &str) -> Vec<String> {
174 type_hint
175 .split('|')
176 .map(|p| p.trim().trim_start_matches('\\').trim_start_matches('?'))
177 .filter(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
178 .filter_map(|p| p.rsplit('\\').next())
179 .map(|p| p.to_string())
180 .collect()
181}
182
183fn collect_param_docblock_types(source: &str, span_start: u32, map: &mut HashMap<String, String>) {
187 let Some(raw) = docblock_before(source, span_start) else {
188 return;
189 };
190 let db = parse_docblock(&raw);
191 for param in &db.params {
192 let classes = docblock_class_parts(¶m.type_hint);
193 if classes.is_empty() {
194 continue;
195 }
196 let key = if param.name.starts_with('$') {
197 param.name.clone()
198 } else {
199 format!("${}", param.name)
200 };
201 map.entry(key).or_insert_with(|| classes.join("|"));
202 }
203}
204
205#[allow(clippy::too_many_arguments)]
206fn collect_types_stmts(
207 source: &str,
208 stmts: &[Stmt<'_, '_>],
209 map: &mut HashMap<String, String>,
210 meta: Option<&PhpStormMeta>,
211 cursor_byte: Option<u32>,
212 doc: &ParsedDoc,
213) {
214 for stmt in stmts {
215 if let Some(raw) = docblock_before(source, stmt.span.start) {
216 let db = parse_docblock(&raw);
217 if let Some(type_str) = db.var_type {
218 let class_name = docblock_class_parts(&type_str).into_iter().next();
219 if let Some(class_name) = class_name {
220 if let Some(vname) = db.var_name {
221 map.insert(format!("${}", vname.as_str()), class_name);
222 } else if let StmtKind::Expression(e) = &stmt.kind
223 && let ExprKind::Assign(a) = &e.kind
224 && let ExprKind::Variable(vn) = &a.target.kind
225 {
226 map.insert(format!("${}", vn.as_str()), class_name);
227 }
228 }
229 }
230 }
231
232 match &stmt.kind {
233 StmtKind::Expression(e) => collect_types_expr(source, e, map, meta, cursor_byte, doc),
234 StmtKind::Function(f) => {
235 let in_scope =
236 cursor_byte.is_none_or(|c| stmt.span.start <= c && c <= stmt.span.end);
237 if !in_scope {
238 continue;
239 }
240 collect_param_docblock_types(source, stmt.span.start, map);
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
255 .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
256 if !in_scope {
257 continue;
258 }
259 collect_param_docblock_types(source, member.span.start, map);
260 for p in m.params.iter() {
261 if let Some(hint) = &p.type_hint
262 && let Some(class_str) = type_hint_to_class_string(
263 hint,
264 class_name.as_deref(),
265 Some(doc),
266 )
267 {
268 map.insert(format!("${}", p.name), class_str);
269 }
270 }
271 if !m.is_static
272 && let Some(ref cname) = class_name
273 {
274 map.insert("$this".to_string(), cname.clone());
275 }
276 if let Some(body) = &m.body {
277 collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
278 }
279 }
280 }
281 }
282 StmtKind::Trait(t) => {
283 for member in t.body.members.iter() {
284 if let ClassMemberKind::Method(m) = &member.kind {
285 let in_scope = cursor_byte
286 .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
287 if !in_scope {
288 continue;
289 }
290 for p in m.params.iter() {
291 if let Some(hint) = &p.type_hint
292 && let Some(class_str) =
293 type_hint_to_class_string(hint, None, Some(doc))
294 {
295 map.insert(format!("${}", p.name), class_str);
296 }
297 }
298 if let Some(body) = &m.body {
299 collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
300 }
301 }
302 }
303 }
304 StmtKind::Enum(e) => {
305 for member in e.body.members.iter() {
306 if let EnumMemberKind::Method(m) = &member.kind {
307 let in_scope = cursor_byte
308 .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
309 if !in_scope {
310 continue;
311 }
312 for p in m.params.iter() {
313 if let Some(hint) = &p.type_hint
314 && let Some(class_str) =
315 type_hint_to_class_string(hint, None, Some(doc))
316 {
317 map.insert(format!("${}", p.name), class_str);
318 }
319 }
320 if let Some(body) = &m.body {
321 collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
322 }
323 }
324 }
325 }
326 StmtKind::Namespace(ns) => {
327 if let NamespaceBody::Braced(inner) = &ns.body {
328 collect_types_stmts(source, &inner.stmts, map, meta, cursor_byte, doc);
329 }
330 }
331 StmtKind::If(if_stmt) => {
333 collect_types_stmts(
334 source,
335 std::slice::from_ref(if_stmt.then_branch),
336 map,
337 meta,
338 cursor_byte,
339 doc,
340 );
341 for elseif in if_stmt.elseif_branches.iter() {
342 collect_types_stmts(
343 source,
344 std::slice::from_ref(&elseif.body),
345 map,
346 meta,
347 cursor_byte,
348 doc,
349 );
350 }
351 if let Some(else_branch) = if_stmt.else_branch {
352 collect_types_stmts(
353 source,
354 std::slice::from_ref(else_branch),
355 map,
356 meta,
357 cursor_byte,
358 doc,
359 );
360 }
361 }
362
363 StmtKind::Foreach(f) => {
364 collect_types_stmts(
365 source,
366 std::slice::from_ref(f.body),
367 map,
368 meta,
369 cursor_byte,
370 doc,
371 );
372 }
373 StmtKind::TryCatch(t) => {
374 collect_types_stmts(source, &t.body.stmts, map, meta, cursor_byte, doc);
375 for catch in t.catches.iter() {
376 collect_types_stmts(source, &catch.body.stmts, map, meta, cursor_byte, doc);
377 }
378 if let Some(finally) = &t.finally {
379 collect_types_stmts(source, &finally.stmts, map, meta, cursor_byte, doc);
380 }
381 }
382
383 StmtKind::StaticVar(vars) => {
384 for var in vars.iter() {
385 let var_key = format!("${}", &var.name.to_string());
386 if let Some(default) = &var.default {
387 if let ExprKind::New(new_expr) = &default.kind
388 && let Some(class_name) = extract_class_name(new_expr.class)
389 {
390 map.insert(var_key.clone(), class_name);
391 }
392 if let ExprKind::Array(_) = &default.kind {
393 map.insert(var_key, "array".to_string());
394 }
395 }
396 }
397 }
398
399 _ => {}
400 }
401 }
402}
403
404fn collect_types_expr(
405 source: &str,
406 expr: &php_ast::Expr<'_, '_>,
407 map: &mut HashMap<String, String>,
408 meta: Option<&PhpStormMeta>,
409 cursor_byte: Option<u32>,
410 doc: &ParsedDoc,
411) {
412 match &expr.kind {
413 ExprKind::Assign(assign) => {
414 if let ExprKind::Variable(var_name) = &assign.target.kind {
415 if let ExprKind::New(new_expr) = &assign.value.kind
416 && let Some(class_name) = extract_class_name(new_expr.class)
417 {
418 map.insert(format!("${}", var_name.as_str()), class_name);
419 }
420 if let Some(meta) = meta
424 && let Some(inferred) = infer_from_meta_method_call(assign.value, map, meta)
425 {
426 map.insert(format!("${}", var_name.as_str()), inferred);
427 }
428 }
429 collect_types_expr(source, assign.value, map, meta, cursor_byte, doc);
430 }
431
432 ExprKind::Closure(c) => {
433 for p in c.params.iter() {
434 if let Some(hint) = &p.type_hint
435 && let TypeHintKind::Named(name) = &hint.kind
436 {
437 map.insert(format!("${}", p.name), name.to_string_repr().to_string());
438 }
439 }
440 let use_var_snapshot: Vec<(String, String)> = c
444 .use_vars
445 .iter()
446 .filter_map(|uv| {
447 let key = format!("${}", &uv.name.to_string());
448 map.get(&key).map(|ty| (key, ty.clone()))
449 })
450 .collect();
451 collect_types_stmts(source, &c.body.stmts, map, meta, cursor_byte, doc);
452 for (key, ty) in use_var_snapshot {
455 map.insert(key, ty);
456 }
457 }
458
459 ExprKind::ArrowFunction(af) => {
460 for p in af.params.iter() {
461 if let Some(hint) = &p.type_hint
462 && let TypeHintKind::Named(name) = &hint.kind
463 {
464 map.insert(format!("${}", p.name), name.to_string_repr().to_string());
465 }
466 }
467 collect_types_expr(source, af.body, map, meta, cursor_byte, doc);
468 }
469
470 _ => {}
471 }
472}
473
474fn extract_class_name(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
475 match &expr.kind {
476 ExprKind::Identifier(name) => Some(name.as_str().to_string()),
477 _ => None,
478 }
479}
480
481fn infer_from_meta_method_call(
484 expr: &php_ast::Expr<'_, '_>,
485 var_map: &HashMap<String, String>,
486 meta: &PhpStormMeta,
487) -> Option<String> {
488 let ExprKind::MethodCall(m) = &expr.kind else {
489 return None;
490 };
491 let receiver_class = match &m.object.kind {
493 ExprKind::Variable(v) => {
494 let key = format!("${}", v.as_str());
495 var_map.get(&key)?.clone()
496 }
497 _ => return None,
498 };
499 let method_name = match &m.method.kind {
501 ExprKind::Identifier(n) => n.to_string(),
502 _ => return None,
503 };
504 let arg = m.args.first()?;
506 let arg_str = match &arg.value.kind {
507 ExprKind::String(s) => s.trim_start_matches('\\').to_string(),
508 ExprKind::ClassConstAccess(c) if c.member.name_str() == Some("class") => {
509 match &c.class.kind {
510 ExprKind::Identifier(n) => n
511 .trim_start_matches('\\')
512 .rsplit('\\')
513 .next()
514 .unwrap_or(n)
515 .to_string(),
516 _ => return None,
517 }
518 }
519 _ => return None,
520 };
521 meta.resolve_return_type(&receiver_class, &method_name, &arg_str)
522 .map(|s| s.to_string())
523}
524
525pub fn parent_class_name(doc: &ParsedDoc, class_name: &str) -> Option<String> {
527 parent_in_stmts(&doc.program().stmts, class_name)
528}
529
530fn parent_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str) -> Option<String> {
531 for stmt in stmts {
532 match &stmt.kind {
533 StmtKind::Class(c)
534 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
535 {
536 return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
537 }
538 StmtKind::Namespace(ns) => {
539 if let NamespaceBody::Braced(inner) = &ns.body
540 && let found @ Some(_) = parent_in_stmts(&inner.stmts, class_name)
541 {
542 return found;
543 }
544 }
545 _ => {}
546 }
547 }
548 None
549}
550
551#[derive(Debug, Default)]
553pub struct ClassMembers {
554 pub methods: Vec<(String, bool)>,
556 pub properties: Vec<(String, bool)>,
558 pub readonly_properties: Vec<String>,
560 pub constants: Vec<String>,
561 pub parent: Option<String>,
563 pub trait_uses: Vec<String>,
565 pub found: bool,
569}
570
571pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
574 let mut out = ClassMembers::default();
575 out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
576 out
577}
578
579fn collect_members_stmts(
580 source: &str,
581 stmts: &[Stmt<'_, '_>],
582 class_name: &str,
583 out: &mut ClassMembers,
584) -> Option<String> {
585 for stmt in stmts {
586 match &stmt.kind {
587 StmtKind::Class(c)
588 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
589 {
590 out.found = true;
591 if let Some(raw) = docblock_before(source, stmt.span.start) {
593 let db = parse_docblock(&raw);
594 for prop in &db.properties {
595 out.properties.push((prop.name.clone(), false));
596 }
597 for method in &db.methods {
598 out.methods.push((method.name.clone(), method.is_static));
599 }
600 }
601 for member in c.body.members.iter() {
602 match &member.kind {
603 ClassMemberKind::Method(m) => {
604 out.methods.push((m.name.to_string(), m.is_static));
605 if m.name == "__construct" {
606 for p in m.params.iter() {
607 if p.visibility.is_some() {
608 out.properties.push((p.name.to_string(), false));
609 let param_src =
611 &source[p.span.start as usize..p.span.end as usize];
612 if param_src.contains("readonly") {
613 out.readonly_properties.push(p.name.to_string());
614 }
615 }
616 }
617 }
618 }
619 ClassMemberKind::Property(p) => {
620 out.properties.push((p.name.to_string(), p.is_static));
621 if p.is_readonly {
622 out.readonly_properties.push(p.name.to_string());
623 }
624 }
625 ClassMemberKind::ClassConst(c) => {
626 out.constants.push(c.name.to_string());
627 }
628 ClassMemberKind::TraitUse(t) => {
629 for name in t.traits.iter() {
630 out.trait_uses.push(name.to_string_repr().to_string());
631 }
632 }
633 }
634 }
635 return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
636 }
637 StmtKind::Enum(e) if e.name == class_name => {
638 out.found = true;
639 let is_backed = e.scalar_type.is_some();
640 out.properties.push(("name".to_string(), false));
641 if is_backed {
642 out.properties.push(("value".to_string(), false));
643 }
644 out.methods.push(("cases".to_string(), true));
645 if is_backed {
646 out.methods.push(("from".to_string(), true));
647 out.methods.push(("tryFrom".to_string(), true));
648 }
649 for member in e.body.members.iter() {
650 match &member.kind {
651 EnumMemberKind::Case(c) => {
652 out.constants.push(c.name.to_string());
653 }
654 EnumMemberKind::Method(m) => {
655 out.methods.push((m.name.to_string(), m.is_static));
656 }
657 EnumMemberKind::ClassConst(c) => {
658 out.constants.push(c.name.to_string());
659 }
660 _ => {}
661 }
662 }
663 return None; }
665 StmtKind::Trait(t) if t.name == class_name => {
666 out.found = true;
667 for member in t.body.members.iter() {
668 match &member.kind {
669 ClassMemberKind::Method(m) => {
670 out.methods.push((m.name.to_string(), m.is_static));
671 }
672 ClassMemberKind::Property(p) => {
673 out.properties.push((p.name.to_string(), p.is_static));
674 }
675 ClassMemberKind::ClassConst(c) => {
676 out.constants.push(c.name.to_string());
677 }
678 ClassMemberKind::TraitUse(t) => {
679 for name in t.traits.iter() {
680 out.trait_uses.push(name.to_string_repr().to_string());
681 }
682 }
683 }
684 }
685 return None; }
687 StmtKind::Namespace(ns) => {
688 if let NamespaceBody::Braced(inner) = &ns.body {
689 let result = collect_members_stmts(source, &inner.stmts, class_name, out);
690 if result.is_some() {
691 return result;
692 }
693 }
694 }
695 _ => {}
696 }
697 }
698 None
699}
700
701pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
703 let source = doc.source();
704 mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
705}
706
707fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
708 for stmt in stmts {
709 match &stmt.kind {
710 StmtKind::Class(c)
711 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
712 {
713 if let Some(raw) = docblock_before(source, stmt.span.start) {
714 return parse_docblock(&raw).mixins;
715 }
716 return vec![];
717 }
718 StmtKind::Namespace(ns) => {
719 if let NamespaceBody::Braced(inner) = &ns.body {
720 let found = mixin_classes_in_stmts(source, &inner.stmts, class_name);
721 if !found.is_empty() {
722 return found;
723 }
724 }
725 }
726 _ => {}
727 }
728 }
729 vec![]
730}
731
732pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
734 let sv = doc.view();
735 enclosing_class_in_stmts(sv, &doc.program().stmts, position)
736}
737
738pub fn enclosing_class_range_at(
743 doc: &ParsedDoc,
744 position: Position,
745) -> Option<tower_lsp::lsp_types::Range> {
746 let sv = doc.view();
747 enclosing_class_range_in_stmts(sv, &doc.program().stmts, position)
748}
749
750pub fn collect_all_class_ranges(doc: &ParsedDoc) -> Vec<tower_lsp::lsp_types::Range> {
754 let sv = doc.view();
755 let mut out = Vec::new();
756 collect_class_ranges_in_stmts(sv, &doc.program().stmts, &mut out);
757 out
758}
759
760fn collect_class_ranges_in_stmts(
761 sv: SourceView<'_>,
762 stmts: &[Stmt<'_, '_>],
763 out: &mut Vec<tower_lsp::lsp_types::Range>,
764) {
765 for stmt in stmts {
766 match &stmt.kind {
767 StmtKind::Class(_)
768 | StmtKind::Interface(_)
769 | StmtKind::Trait(_)
770 | StmtKind::Enum(_) => {
771 out.push(sv.range_of(stmt.span));
772 }
773 StmtKind::Namespace(ns) => {
774 if let NamespaceBody::Braced(inner) = &ns.body {
775 collect_class_ranges_in_stmts(sv, &inner.stmts, out);
776 }
777 }
778 _ => {}
779 }
780 }
781}
782
783fn enclosing_class_range_in_stmts(
784 sv: SourceView<'_>,
785 stmts: &[Stmt<'_, '_>],
786 pos: Position,
787) -> Option<tower_lsp::lsp_types::Range> {
788 for stmt in stmts {
789 match &stmt.kind {
790 StmtKind::Class(_)
791 | StmtKind::Interface(_)
792 | StmtKind::Trait(_)
793 | StmtKind::Enum(_) => {
794 let r = sv.range_of(stmt.span);
795 if pos.line >= r.start.line && pos.line <= r.end.line {
796 return Some(r);
797 }
798 }
799 StmtKind::Namespace(ns) => {
800 if let NamespaceBody::Braced(inner) = &ns.body
801 && let Some(r) = enclosing_class_range_in_stmts(sv, &inner.stmts, pos)
802 {
803 return Some(r);
804 }
805 }
806 _ => {}
807 }
808 }
809 None
810}
811
812fn enclosing_class_in_stmts(
813 sv: SourceView<'_>,
814 stmts: &[Stmt<'_, '_>],
815 pos: Position,
816) -> Option<String> {
817 for stmt in stmts {
818 match &stmt.kind {
819 StmtKind::Class(c) => {
820 let start = sv.position_of(stmt.span.start).line;
821 let end = sv.position_of(stmt.span.end).line;
822 if pos.line >= start && pos.line <= end {
823 return c.name.map(|n| n.to_string());
824 }
825 }
826 StmtKind::Interface(i) => {
827 let start = sv.position_of(stmt.span.start).line;
828 let end = sv.position_of(stmt.span.end).line;
829 if pos.line >= start && pos.line <= end {
830 return Some(i.name.to_string());
831 }
832 }
833 StmtKind::Trait(t) => {
834 let start = sv.position_of(stmt.span.start).line;
835 let end = sv.position_of(stmt.span.end).line;
836 if pos.line >= start && pos.line <= end {
837 return Some(t.name.to_string());
838 }
839 }
840 StmtKind::Enum(e) => {
841 let start = sv.position_of(stmt.span.start).line;
842 let end = sv.position_of(stmt.span.end).line;
843 if pos.line >= start && pos.line <= end {
844 return Some(e.name.to_string());
845 }
846 }
847 StmtKind::Namespace(ns) => {
848 if let NamespaceBody::Braced(inner) = &ns.body
849 && let Some(found) = enclosing_class_in_stmts(sv, &inner.stmts, pos)
850 {
851 return Some(found);
852 }
853 }
854 _ => {}
855 }
856 }
857 None
858}
859
860pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
862 let mut out = Vec::new();
863 collect_params_stmts(&doc.program().stmts, func_name, &mut out);
864 out
865}
866
867pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
870 let mut out = Vec::new();
871 collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
872 out
873}
874
875fn collect_method_params_stmts(
876 stmts: &[php_ast::Stmt<'_, '_>],
877 class_name: &str,
878 method_name: &str,
879 out: &mut Vec<String>,
880) {
881 for stmt in stmts {
882 match &stmt.kind {
883 StmtKind::Class(c)
884 if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
885 {
886 for member in c.body.members.iter() {
887 if let ClassMemberKind::Method(m) = &member.kind
888 && m.name == method_name
889 {
890 for p in m.params.iter() {
891 out.push(p.name.to_string());
892 }
893 return;
894 }
895 }
896 }
897 StmtKind::Namespace(ns) => {
898 if let NamespaceBody::Braced(inner) = &ns.body {
899 collect_method_params_stmts(&inner.stmts, class_name, method_name, out);
900 }
901 }
902 _ => {}
903 }
904 }
905}
906
907pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
909 is_enum_in_stmts(&doc.program().stmts, class_name)
910}
911
912fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
913 for stmt in stmts {
914 match &stmt.kind {
915 StmtKind::Enum(e) if e.name == name => return true,
916 StmtKind::Namespace(ns) => {
917 if let NamespaceBody::Braced(inner) = &ns.body
918 && is_enum_in_stmts(&inner.stmts, name)
919 {
920 return true;
921 }
922 }
923 _ => {}
924 }
925 }
926 false
927}
928
929pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
932 is_backed_enum_in_stmts(&doc.program().stmts, class_name)
933}
934
935fn is_backed_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
936 for stmt in stmts {
937 match &stmt.kind {
938 StmtKind::Enum(e) if e.name == name => return e.scalar_type.is_some(),
939 StmtKind::Namespace(ns) => {
940 if let NamespaceBody::Braced(inner) = &ns.body
941 && is_backed_enum_in_stmts(&inner.stmts, name)
942 {
943 return true;
944 }
945 }
946 _ => {}
947 }
948 }
949 false
950}
951
952fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
953 for stmt in stmts {
954 match &stmt.kind {
955 StmtKind::Function(f) if f.name == func_name => {
956 for p in f.params.iter() {
957 out.push(p.name.to_string());
958 }
959 return;
960 }
961 StmtKind::Class(c) => {
962 for member in c.body.members.iter() {
963 if let ClassMemberKind::Method(m) = &member.kind
964 && m.name == func_name
965 {
966 for p in m.params.iter() {
967 out.push(p.name.to_string());
968 }
969 return;
970 }
971 }
972 }
973 StmtKind::Namespace(ns) => {
974 if let NamespaceBody::Braced(inner) = &ns.body {
975 collect_params_stmts(&inner.stmts, func_name, out);
976 }
977 }
978 _ => {}
979 }
980 }
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986
987 #[test]
988 fn infers_type_from_new_expression() {
989 let src = "<?php\n$obj = new Foo();";
990 let doc = ParsedDoc::parse(src.to_string());
991 let tm = TypeMap::from_doc(&doc);
992 assert_eq!(tm.get("$obj"), Some("Foo"));
993 }
994
995 #[test]
996 fn unknown_variable_returns_none() {
997 let src = "<?php\n$obj = new Foo();";
998 let doc = ParsedDoc::parse(src.to_string());
999 let tm = TypeMap::from_doc(&doc);
1000 assert!(tm.get("$other").is_none());
1001 }
1002
1003 #[test]
1004 fn multiple_assignments() {
1005 let src = "<?php\n$a = new Foo();\n$b = new Bar();";
1006 let doc = ParsedDoc::parse(src.to_string());
1007 let tm = TypeMap::from_doc(&doc);
1008 assert_eq!(tm.get("$a"), Some("Foo"));
1009 assert_eq!(tm.get("$b"), Some("Bar"));
1010 }
1011
1012 #[test]
1013 fn later_assignment_overwrites() {
1014 let src = "<?php\n$a = new Foo();\n$a = new Bar();";
1015 let doc = ParsedDoc::parse(src.to_string());
1016 let tm = TypeMap::from_doc(&doc);
1017 assert_eq!(tm.get("$a"), Some("Bar"));
1018 }
1019
1020 #[test]
1021 fn infers_type_from_typed_param() {
1022 let src = "<?php\nfunction process(Mailer $mailer): void { $mailer-> }";
1023 let doc = ParsedDoc::parse(src.to_string());
1024 let tm = TypeMap::from_doc(&doc);
1025 assert_eq!(tm.get("$mailer"), Some("Mailer"));
1026 }
1027
1028 #[test]
1029 fn parent_class_name_finds_parent() {
1030 let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1031 let doc = ParsedDoc::parse(src.to_string());
1032 assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
1033 }
1034
1035 #[test]
1036 fn parent_class_name_returns_none_for_top_level() {
1037 let src = "<?php\nclass Base {}";
1038 let doc = ParsedDoc::parse(src.to_string());
1039 assert!(parent_class_name(&doc, "Base").is_none());
1040 }
1041
1042 #[test]
1043 fn members_of_class_includes_parent_field() {
1044 let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1045 let doc = ParsedDoc::parse(src.to_string());
1046 let m = members_of_class(&doc, "Child");
1047 assert_eq!(m.parent.as_deref(), Some("Base"));
1048 }
1049
1050 #[test]
1051 fn members_of_class_finds_methods() {
1052 let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
1053 let doc = ParsedDoc::parse(src.to_string());
1054 let members = members_of_class(&doc, "Calc");
1055 let names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1056 assert!(names.contains(&"add"), "missing 'add'");
1057 assert!(names.contains(&"sub"), "missing 'sub'");
1058 }
1059
1060 #[test]
1061 fn members_of_unknown_class_is_empty() {
1062 let src = "<?php\nclass Calc { public function add() {} }";
1063 let doc = ParsedDoc::parse(src.to_string());
1064 let members = members_of_class(&doc, "Unknown");
1065 assert!(members.methods.is_empty());
1066 }
1067
1068 #[test]
1069 fn constructor_promoted_params_appear_as_properties() {
1070 let src = "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}";
1071 let doc = ParsedDoc::parse(src.to_string());
1072 let members = members_of_class(&doc, "Point");
1073 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1074 assert!(
1075 prop_names.contains(&"x"),
1076 "promoted param x should be a property"
1077 );
1078 assert!(
1079 prop_names.contains(&"y"),
1080 "promoted param y should be a property"
1081 );
1082 }
1083
1084 #[test]
1085 fn promoted_readonly_params_appear_in_readonly_properties() {
1086 let src = "<?php\nclass User {\n public function __construct(\n public readonly string $name,\n public int $age,\n ) {}\n}";
1087 let doc = ParsedDoc::parse(src.to_string());
1088 let members = members_of_class(&doc, "User");
1089 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1090 assert!(
1091 prop_names.contains(&"name"),
1092 "promoted param name should be a property"
1093 );
1094 assert!(
1095 prop_names.contains(&"age"),
1096 "promoted param age should be a property"
1097 );
1098 assert!(
1099 members.readonly_properties.contains(&"name".to_string()),
1100 "readonly promoted param name should be in readonly_properties"
1101 );
1102 assert!(
1103 !members.readonly_properties.contains(&"age".to_string()),
1104 "non-readonly promoted param age should not be in readonly_properties"
1105 );
1106 }
1107
1108 #[test]
1109 fn enum_instance_members_include_name() {
1110 let src = "<?php\nenum Status { case Active; case Inactive; }";
1111 let doc = ParsedDoc::parse(src.to_string());
1112 let members = members_of_class(&doc, "Status");
1113 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1114 assert!(
1115 prop_names.contains(&"name"),
1116 "pure enum should expose ->name"
1117 );
1118 assert!(
1119 !prop_names.contains(&"value"),
1120 "pure enum should not expose ->value"
1121 );
1122 }
1123
1124 #[test]
1125 fn backed_enum_exposes_value_and_factory_methods() {
1126 let src = "<?php\nenum Color: string { case Red = 'red'; }";
1127 let doc = ParsedDoc::parse(src.to_string());
1128 let members = members_of_class(&doc, "Color");
1129 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1130 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1131 assert!(
1132 prop_names.contains(&"value"),
1133 "backed enum should expose ->value"
1134 );
1135 assert!(
1136 method_names.contains(&"from"),
1137 "backed enum should have ::from()"
1138 );
1139 assert!(
1140 method_names.contains(&"tryFrom"),
1141 "backed enum should have ::tryFrom()"
1142 );
1143 assert!(
1144 method_names.contains(&"cases"),
1145 "enum should have ::cases()"
1146 );
1147 }
1148
1149 #[test]
1150 fn enum_cases_appear_as_constants() {
1151 let src = "<?php\nenum Status { case Active; case Inactive; }";
1152 let doc = ParsedDoc::parse(src.to_string());
1153 let members = members_of_class(&doc, "Status");
1154 assert!(members.constants.contains(&"Active".to_string()));
1155 assert!(members.constants.contains(&"Inactive".to_string()));
1156 }
1157
1158 #[test]
1159 fn trait_members_are_collected() {
1160 let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
1161 let doc = ParsedDoc::parse(src.to_string());
1162 let members = members_of_class(&doc, "Logging");
1163 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1164 let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1165 assert!(
1166 method_names.contains(&"log"),
1167 "trait method log should be collected"
1168 );
1169 assert!(
1170 prop_names.contains(&"logFile"),
1171 "trait property logFile should be collected"
1172 );
1173 }
1174
1175 #[test]
1176 fn class_with_trait_use_lists_trait() {
1177 let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
1178 let doc = ParsedDoc::parse(src.to_string());
1179 let members = members_of_class(&doc, "App");
1180 assert!(
1181 members.trait_uses.contains(&"Logging".to_string()),
1182 "should list used trait"
1183 );
1184 }
1185
1186 #[test]
1187 fn var_docblock_with_explicit_varname_infers_type() {
1188 let src = "<?php\n/** @var Mailer $mailer */\n$mailer = $container->get('mailer');";
1189 let doc = ParsedDoc::parse(src.to_string());
1190 let tm = TypeMap::from_doc(&doc);
1191 assert_eq!(
1192 tm.get("$mailer"),
1193 Some("Mailer"),
1194 "@var with explicit name should map the variable"
1195 );
1196 }
1197
1198 #[test]
1199 fn var_docblock_without_varname_infers_from_assignment() {
1200 let src = "<?php\n/** @var Repository */\n$repo = $this->getRepository();";
1201 let doc = ParsedDoc::parse(src.to_string());
1202 let tm = TypeMap::from_doc(&doc);
1203 assert_eq!(
1204 tm.get("$repo"),
1205 Some("Repository"),
1206 "@var without name should use assignment LHS"
1207 );
1208 }
1209
1210 #[test]
1211 fn var_docblock_does_not_map_primitive_types() {
1212 let src = "<?php\n/** @var string */\n$name = 'hello';";
1213 let doc = ParsedDoc::parse(src.to_string());
1214 let tm = TypeMap::from_doc(&doc);
1215 assert!(
1217 tm.get("$name").is_none(),
1218 "primitive @var should not produce a class mapping"
1219 );
1220 }
1221
1222 #[test]
1223 fn var_nullable_docblock_maps_to_class() {
1224 let src = "<?php\n/** @var ?Mailer $mailer */\n$mailer = null;";
1227 let doc = ParsedDoc::parse(src.to_string());
1228 let tm = TypeMap::from_doc(&doc);
1229 assert_eq!(
1230 tm.get("$mailer"),
1231 Some("Mailer"),
1232 "@var ?Foo should map to 'Foo', not 'Foo|null'"
1233 );
1234 }
1235
1236 #[test]
1237 fn var_union_docblock_maps_first_class() {
1238 let src = "<?php\n/** @var Repository|null $repo */\n$repo = null;";
1240 let doc = ParsedDoc::parse(src.to_string());
1241 let tm = TypeMap::from_doc(&doc);
1242 assert_eq!(
1243 tm.get("$repo"),
1244 Some("Repository"),
1245 "@var Foo|null should map to 'Foo', not 'Foo|null'"
1246 );
1247 }
1248
1249 #[test]
1250 fn is_enum_pure() {
1251 let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
1252 let doc = ParsedDoc::parse(src.to_string());
1253 assert!(is_enum(&doc, "Suit"));
1254 assert!(!is_backed_enum(&doc, "Suit"));
1255 }
1256
1257 #[test]
1258 fn is_backed_enum_string() {
1259 let src = "<?php\nenum Status: string { case Active = 'active'; }";
1260 let doc = ParsedDoc::parse(src.to_string());
1261 assert!(is_enum(&doc, "Status"));
1262 assert!(is_backed_enum(&doc, "Status"));
1263 }
1264
1265 #[test]
1266 fn is_enum_false_for_class() {
1267 let src = "<?php\nclass Foo {}";
1268 let doc = ParsedDoc::parse(src.to_string());
1269 assert!(!is_enum(&doc, "Foo"));
1270 assert!(!is_backed_enum(&doc, "Foo"));
1271 }
1272
1273 #[test]
1274 fn closure_use_var_type_is_available_inside_body() {
1275 let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc->process(); };";
1276 let doc = ParsedDoc::parse(src.to_string());
1277 let tm = TypeMap::from_doc(&doc);
1278 assert_eq!(
1279 tm.get("$svc"),
1280 Some("PaymentService"),
1281 "captured use variable should retain its outer type inside closure body"
1282 );
1283 }
1284
1285 #[test]
1286 fn closure_use_var_inner_assignment_does_not_override_outer_type() {
1287 let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc = new OtherService(); };";
1290 let doc = ParsedDoc::parse(src.to_string());
1291 let tm = TypeMap::from_doc(&doc);
1292 assert_eq!(
1294 tm.get("$svc"),
1295 Some("PaymentService"),
1296 "outer type should not be overwritten by inner assignment in closure"
1297 );
1298 }
1299
1300 #[test]
1301 fn param_docblock_type_inferred() {
1302 let src = "<?php\n/**\n * @param Mailer $mailer\n */\nfunction send($mailer) { $mailer-> }";
1303 let doc = ParsedDoc::parse(src.to_string());
1304 let tm = TypeMap::from_doc(&doc);
1305 assert_eq!(tm.get("$mailer"), Some("Mailer"));
1306 }
1307
1308 #[test]
1309 fn param_docblock_does_not_override_ast_hint() {
1310 let src = "<?php\n/**\n * @param OtherClass $x\n */\nfunction foo(Foo $x) {}";
1311 let doc = ParsedDoc::parse(src.to_string());
1312 let tm = TypeMap::from_doc(&doc);
1313 assert_eq!(tm.get("$x"), Some("Foo"));
1315 }
1316
1317 #[test]
1318 fn not_null_check_preserves_existing_type() {
1319 let src = "<?php\n$x = new Foo();\nif ($x !== null) { $x-> }";
1320 let doc = ParsedDoc::parse(src.to_string());
1321 let tm = TypeMap::from_doc(&doc);
1322 assert_eq!(tm.get("$x"), Some("Foo"));
1323 }
1324
1325 #[test]
1326 fn docblock_property_appears_in_members() {
1327 let src =
1328 "<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
1329 let doc = ParsedDoc::parse(src.to_string());
1330 let members = members_of_class(&doc, "User");
1331 let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1332 assert!(props.contains(&"email"));
1333 assert!(props.contains(&"id"));
1334 }
1335
1336 #[test]
1337 fn docblock_method_appears_in_members() {
1338 let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
1339 let doc = ParsedDoc::parse(src.to_string());
1340 let members = members_of_class(&doc, "Model");
1341 let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1342 assert!(method_names.contains(&"find"));
1343 assert!(method_names.contains(&"where"));
1344 let where_static = members
1345 .methods
1346 .iter()
1347 .find(|(n, _)| n == "where")
1348 .map(|(_, s)| *s);
1349 assert_eq!(where_static, Some(true));
1350 }
1351
1352 #[test]
1353 fn union_type_param_maps_both_classes() {
1354 let src = "<?php\nfunction f(Foo|Bar $x) {}";
1356 let doc = ParsedDoc::parse(src.to_string());
1357 let tm = TypeMap::from_doc(&doc);
1358 let val = tm.get("$x").expect("$x should be in the type map");
1359 assert!(
1360 val.contains("Foo"),
1361 "union type should contain 'Foo', got: {}",
1362 val
1363 );
1364 assert!(
1365 val.contains("Bar"),
1366 "union type should contain 'Bar', got: {}",
1367 val
1368 );
1369 }
1370
1371 #[test]
1372 fn nullable_param_resolves_to_class() {
1373 let src = "<?php\nfunction f(?Foo $x) {}";
1375 let doc = ParsedDoc::parse(src.to_string());
1376 let tm = TypeMap::from_doc(&doc);
1377 assert_eq!(
1378 tm.get("$x"),
1379 Some("Foo"),
1380 "nullable type hint ?Foo should map $x to Foo"
1381 );
1382 }
1383
1384 #[test]
1385 fn null_assignment_does_not_overwrite_class() {
1386 let src = "<?php\n$x = new Foo();\n$x = null;\n";
1389 let doc = ParsedDoc::parse(src.to_string());
1390 let tm = TypeMap::from_doc(&doc);
1391 assert_eq!(
1394 tm.get("$x"),
1395 Some("Foo"),
1396 "$x should retain its Foo type after being assigned null"
1397 );
1398 }
1399
1400 #[test]
1401 fn infers_type_from_assignment_inside_trait_method() {
1402 let src = "<?php\ntrait Builder { public function make(): void { $obj = new Widget(); } }";
1403 let doc = ParsedDoc::parse(src.to_string());
1404 let tm = TypeMap::from_doc(&doc);
1405 assert_eq!(
1406 tm.get("$obj"),
1407 Some("Widget"),
1408 "type map should walk into trait method bodies"
1409 );
1410 }
1411
1412 #[test]
1413 fn infers_type_from_assignment_inside_enum_method() {
1414 let src = "<?php\nenum Color { case Red; public function make(): void { $obj = new Palette(); } }";
1415 let doc = ParsedDoc::parse(src.to_string());
1416 let tm = TypeMap::from_doc(&doc);
1417 assert_eq!(
1418 tm.get("$obj"),
1419 Some("Palette"),
1420 "type map should walk into enum method bodies"
1421 );
1422 }
1423}