1use std::sync::Arc;
17
18use php_ast::{
19 ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind, TraitAdaptationKind,
20};
21
22use crate::document::ast::{ParsedDoc, format_type_hint};
23use crate::lang::docblock::parse_docblock;
24
25#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
26pub struct FileIndex {
27 pub namespace: Option<Box<str>>,
28 pub functions: Vec<FunctionDef>,
29 pub classes: Vec<ClassDef>,
30 pub constants: Vec<Box<str>>,
31 pub symbols: Vec<WorkspaceSymbolRow>,
33}
34
35#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
37pub struct WorkspaceSymbolRow {
38 pub name: Box<str>,
39 pub kind: tower_lsp_server::ls_types::SymbolKind,
40 pub line: u32,
41 pub container: Option<u32>,
43 pub deprecated: Option<bool>,
44}
45
46impl FileIndex {
47 pub fn declares_nothing(&self) -> bool {
50 self.functions.is_empty() && self.classes.is_empty() && self.constants.is_empty()
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
55pub struct FunctionDef {
56 pub name: Box<str>,
57 pub fqn: Box<str>,
59 pub params: Vec<ParamDef>,
60 pub return_type: Option<Box<str>>,
61 pub start_line: u32,
62 pub name_char: u32,
64}
65
66#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
67pub struct ParamDef {
68 pub name: Box<str>,
69 pub type_hint: Option<Box<str>>,
70 pub has_default: bool,
71 pub variadic: bool,
72}
73
74#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
75pub struct ClassDef {
76 pub name: Box<str>,
77 pub fqn: Box<str>,
79 pub kind: ClassKind,
80 pub is_abstract: bool,
81 pub is_readonly: bool,
83 pub parent: Option<Arc<str>>,
85 pub implements: Vec<Arc<str>>,
86 pub traits: Vec<Arc<str>>,
87 pub methods: Vec<MethodDef>,
88 pub properties: Vec<PropertyDef>,
89 pub constants: Vec<Box<str>>,
90 pub cases: Vec<Box<str>>,
92 pub start_line: u32,
93 pub name_char: u32,
95 pub doc_methods: Vec<DocMethodEntry>,
97 pub mixins: Vec<Arc<str>>,
99 pub trait_method_aliases: Vec<TraitMethodAlias>,
101}
102
103#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
105pub struct TraitMethodAlias {
106 pub alias: Box<str>,
108 pub original: Box<str>,
110 pub trait_name: Option<Box<str>>,
112}
113
114#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
116pub struct DocMethodParam {
117 pub name: Box<str>,
118 pub type_hint: Option<Box<str>>,
119 pub is_variadic: bool,
120 pub is_byref: bool,
121 pub is_optional: bool,
122}
123
124#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
128pub struct DocMethodEntry {
129 pub name: Box<str>,
130 pub is_static: bool,
131 pub return_type: Option<Box<str>>,
133 pub start_line: u32,
135 pub params: Vec<DocMethodParam>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
140pub enum ClassKind {
141 Class,
142 Interface,
143 Trait,
144 Enum,
145}
146
147#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
148pub struct MethodDef {
149 pub name: Box<str>,
150 pub is_static: bool,
151 pub is_abstract: bool,
152 pub visibility: Visibility,
153 pub params: Vec<ParamDef>,
154 pub return_type: Option<Box<str>>,
155 pub start_line: u32,
156 pub name_char: u32,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
161pub enum Visibility {
162 Public,
163 Protected,
164 Private,
165}
166
167#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
168pub struct PropertyDef {
169 pub name: Box<str>,
170 pub is_static: bool,
171 pub is_readonly: bool,
172 pub type_hint: Option<Box<str>>,
173 pub visibility: Visibility,
174 pub start_line: u32,
175 pub name_char: u32,
177}
178
179pub trait SameDeclarations {
182 fn same_declarations(&self, other: &Self) -> bool;
183}
184
185impl<T: SameDeclarations> SameDeclarations for [T] {
186 fn same_declarations(&self, other: &Self) -> bool {
187 self.len() == other.len() && self.iter().zip(other).all(|(a, b)| a.same_declarations(b))
188 }
189}
190
191impl SameDeclarations for FileIndex {
192 fn same_declarations(&self, other: &Self) -> bool {
193 let FileIndex {
195 namespace,
196 functions,
197 classes,
198 constants,
199 symbols: _,
200 } = self;
201 *namespace == other.namespace
202 && functions.same_declarations(&other.functions)
203 && classes.same_declarations(&other.classes)
204 && *constants == other.constants
205 }
206}
207
208impl SameDeclarations for FunctionDef {
209 fn same_declarations(&self, other: &Self) -> bool {
210 let FunctionDef {
211 name,
212 fqn,
213 params,
214 return_type,
215 start_line: _,
216 name_char: _,
217 } = self;
218 *name == other.name
219 && *fqn == other.fqn
220 && *params == other.params
221 && *return_type == other.return_type
222 }
223}
224
225impl SameDeclarations for ClassDef {
226 fn same_declarations(&self, other: &Self) -> bool {
227 let ClassDef {
228 name,
229 fqn,
230 kind,
231 is_abstract,
232 is_readonly,
233 parent,
234 implements,
235 traits,
236 methods,
237 properties,
238 constants,
239 cases,
240 start_line: _,
241 name_char: _,
242 doc_methods,
243 mixins,
244 trait_method_aliases,
245 } = self;
246 *name == other.name
247 && *fqn == other.fqn
248 && *kind == other.kind
249 && *is_abstract == other.is_abstract
250 && *is_readonly == other.is_readonly
251 && *parent == other.parent
252 && *implements == other.implements
253 && *traits == other.traits
254 && methods.same_declarations(&other.methods)
255 && properties.same_declarations(&other.properties)
256 && *constants == other.constants
257 && *cases == other.cases
258 && doc_methods.same_declarations(&other.doc_methods)
259 && *mixins == other.mixins
260 && *trait_method_aliases == other.trait_method_aliases
261 }
262}
263
264impl SameDeclarations for MethodDef {
265 fn same_declarations(&self, other: &Self) -> bool {
266 let MethodDef {
267 name,
268 is_static,
269 is_abstract,
270 visibility,
271 params,
272 return_type,
273 start_line: _,
274 name_char: _,
275 } = self;
276 *name == other.name
277 && *is_static == other.is_static
278 && *is_abstract == other.is_abstract
279 && *visibility == other.visibility
280 && *params == other.params
281 && *return_type == other.return_type
282 }
283}
284
285impl SameDeclarations for PropertyDef {
286 fn same_declarations(&self, other: &Self) -> bool {
287 let PropertyDef {
288 name,
289 is_static,
290 is_readonly,
291 type_hint,
292 visibility,
293 start_line: _,
294 name_char: _,
295 } = self;
296 *name == other.name
297 && *is_static == other.is_static
298 && *is_readonly == other.is_readonly
299 && *type_hint == other.type_hint
300 && *visibility == other.visibility
301 }
302}
303
304impl SameDeclarations for DocMethodEntry {
305 fn same_declarations(&self, other: &Self) -> bool {
306 let DocMethodEntry {
307 name,
308 is_static,
309 return_type,
310 start_line: _,
311 params,
312 } = self;
313 *name == other.name
314 && *is_static == other.is_static
315 && *return_type == other.return_type
316 && *params == other.params
317 }
318}
319
320impl FileIndex {
321 pub fn extract(doc: &ParsedDoc) -> Self {
323 let source = doc.source();
324 let view = doc.view();
325 let mut index = FileIndex::default();
326 collect_stmts(source, &view, &doc.program().stmts, None, &mut index);
327 index.symbols = crate::navigation::symbols::workspace_symbol_rows(doc);
328 index
329 }
330}
331
332fn fqn(namespace: Option<&str>, name: &str) -> Box<str> {
333 match namespace {
334 Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, name).into(),
335 _ => name.into(),
336 }
337}
338
339fn collect_stmts(
340 source: &str,
341 view: &crate::document::ast::SourceView<'_>,
342 stmts: &[Stmt<'_, '_>],
343 namespace: Option<&str>,
344 index: &mut FileIndex,
345) {
346 use crate::document::ast::str_offset;
347
348 let name_char = |name: &str| -> u32 {
349 str_offset(source, name)
350 .map(|off| view.position_of(off).character)
351 .unwrap_or(0)
352 };
353
354 let mut cur_ns: Option<Box<str>> = namespace.map(|s| s.into());
356
357 for stmt in stmts {
358 match &stmt.kind {
359 StmtKind::Namespace(ns) => {
360 let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into());
361
362 match &ns.body {
363 NamespaceBody::Braced(inner) => {
364 let ns_str = ns_name.as_deref();
366 if index.namespace.is_none() {
368 index.namespace = ns_name.clone();
369 }
370 collect_stmts(source, view, &inner.stmts, ns_str, index);
371 }
372 NamespaceBody::Simple => {
373 if index.namespace.is_none() {
375 index.namespace = ns_name.clone();
376 }
377 cur_ns = ns_name;
378 }
379 }
380 }
381
382 StmtKind::Function(f) => {
383 let start_line = view.position_of(stmt.span.start).line;
384 let ns = cur_ns.as_deref();
385 let f_name = f.name.or_error();
386 index.functions.push(FunctionDef {
387 name: Box::from(f_name),
388 fqn: fqn(ns, f_name),
389 params: extract_params(&f.params),
390 return_type: f.return_type.as_ref().map(|t| format_type_hint(t).into()),
391 start_line,
392 name_char: name_char(f_name),
393 });
394 }
395
396 StmtKind::Class(c) => {
397 let Some(class_name) = c.name else { continue };
398 let class_name_str = class_name.or_error();
399 let start_line = view.position_of(stmt.span.start).line;
400 let ns = cur_ns.as_deref();
401
402 let mut class_def = ClassDef {
403 name: Box::from(class_name_str),
404 fqn: fqn(ns, class_name_str),
405 kind: ClassKind::Class,
406 is_abstract: c.modifiers.is_abstract,
407 is_readonly: c.modifiers.is_readonly,
408 parent: c
409 .extends
410 .as_ref()
411 .map(|e| Arc::from(e.to_string_repr().as_ref())),
412 implements: c
413 .implements
414 .iter()
415 .map(|i| Arc::from(i.to_string_repr().as_ref()))
416 .collect(),
417 traits: Vec::new(),
418 methods: Vec::new(),
419 properties: Vec::new(),
420 constants: Vec::new(),
421 cases: Vec::new(),
422 start_line,
423 name_char: name_char(class_name_str),
424 doc_methods: Vec::new(),
425 mixins: Vec::new(),
426 trait_method_aliases: Vec::new(),
427 };
428
429 for member in c.body.members.iter() {
430 match &member.kind {
431 ClassMemberKind::Method(m) => {
432 let mstart = view.position_of(member.span.start).line;
433 let vis = method_visibility(m.visibility);
434 let method_params = extract_params(&m.params);
435 for ast_param in m.params.iter() {
437 if ast_param.visibility.is_some() {
438 let pvis = method_visibility(ast_param.visibility);
439 let pstart = view.position_of(ast_param.span.start).line;
440 let p_name = ast_param.name.or_error();
441 class_def.properties.push(PropertyDef {
442 name: Box::from(p_name),
443 is_static: false,
444 is_readonly: ast_param.is_readonly,
445 type_hint: ast_param
446 .type_hint
447 .as_ref()
448 .map(|t| format_type_hint(t).into()),
449 visibility: pvis,
450 start_line: pstart,
451 name_char: name_char(p_name),
452 });
453 }
454 }
455 let m_name = m.name.or_error();
456 class_def.methods.push(MethodDef {
457 name: Box::from(m_name),
458 is_static: m.is_static,
459 is_abstract: m.is_abstract,
460 visibility: vis,
461 params: method_params,
462 return_type: m
463 .return_type
464 .as_ref()
465 .map(|t| format_type_hint(t).into()),
466 start_line: mstart,
467 name_char: name_char(m_name),
468 });
469 }
470 ClassMemberKind::Property(p) => {
471 let vis = method_visibility(p.visibility);
472 let pstart = view.position_of(member.span.start).line;
473 let p_name = p.name.or_error();
474 class_def.properties.push(PropertyDef {
475 name: Box::from(p_name),
476 is_static: p.is_static,
477 is_readonly: p.is_readonly,
478 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
479 visibility: vis,
480 start_line: pstart,
481 name_char: name_char(p_name),
482 });
483 }
484 ClassMemberKind::ClassConst(cc) => {
485 class_def.constants.push(Box::from(cc.name.or_error()));
486 }
487 ClassMemberKind::TraitUse(tu) => {
488 for t in tu.traits.iter() {
489 class_def
490 .traits
491 .push(Arc::from(t.to_string_repr().as_ref()));
492 }
493 for adaptation in tu.adaptations.iter() {
494 if let TraitAdaptationKind::Alias {
495 method,
496 new_name: Some(new_name),
497 trait_name,
498 ..
499 } = &adaptation.kind
500 {
501 class_def.trait_method_aliases.push(TraitMethodAlias {
502 alias: Box::from(new_name.to_string_repr().as_ref()),
503 original: Box::from(method.to_string_repr().as_ref()),
504 trait_name: trait_name
505 .as_ref()
506 .map(|n| Box::from(n.to_string_repr().as_ref())),
507 });
508 }
509 }
510 }
511 }
512 }
513 if let Some(doc) = &c.doc_comment {
517 let db = parse_docblock(doc.text);
518 for dm in &db.methods {
519 let line = doc_method_tag_line(view, doc, &dm.name);
520 let ret = if dm.return_type.is_empty() {
521 None
522 } else {
523 Some(Box::from(dm.return_type.as_str()))
524 };
525 class_def.doc_methods.push(DocMethodEntry {
526 name: Box::from(dm.name.as_str()),
527 is_static: dm.is_static,
528 return_type: ret,
529 start_line: line,
530 params: dm
531 .params
532 .iter()
533 .map(|p| DocMethodParam {
534 name: Box::from(p.name.as_str()),
535 type_hint: if p.type_hint.is_empty() {
536 None
537 } else {
538 Some(Box::from(p.type_hint.as_str()))
539 },
540 is_variadic: p.is_variadic,
541 is_byref: p.is_byref,
542 is_optional: p.is_optional,
543 })
544 .collect(),
545 });
546 }
547 for mixin in &db.mixins {
548 class_def.mixins.push(Arc::from(mixin.as_str()));
549 }
550 }
551 index.classes.push(class_def);
552 }
553
554 StmtKind::Interface(i) => {
555 let start_line = view.position_of(stmt.span.start).line;
556 let ns = cur_ns.as_deref();
557
558 let i_name = i.name.or_error();
559 let mut iface_def = ClassDef {
560 name: Box::from(i_name),
561 fqn: fqn(ns, i_name),
562 kind: ClassKind::Interface,
563 is_abstract: true,
564 is_readonly: false,
565 parent: None,
566 implements: i
567 .extends
568 .iter()
569 .map(|e| Arc::from(e.to_string_repr().as_ref()))
570 .collect(),
571 traits: Vec::new(),
572 methods: Vec::new(),
573 properties: Vec::new(),
574 constants: Vec::new(),
575 cases: Vec::new(),
576 start_line,
577 name_char: name_char(i_name),
578 doc_methods: Vec::new(),
579 mixins: Vec::new(),
580 trait_method_aliases: Vec::new(),
581 };
582
583 for member in i.body.members.iter() {
584 match &member.kind {
585 ClassMemberKind::Method(m) => {
586 let mstart = view.position_of(member.span.start).line;
587 let m_name = m.name.or_error();
588 iface_def.methods.push(MethodDef {
589 name: Box::from(m_name),
590 is_static: m.is_static,
591 is_abstract: true,
592 visibility: Visibility::Public,
593 params: extract_params(&m.params),
594 return_type: m
595 .return_type
596 .as_ref()
597 .map(|t| format_type_hint(t).into()),
598 start_line: mstart,
599 name_char: name_char(m_name),
600 });
601 }
602 ClassMemberKind::ClassConst(cc) => {
603 iface_def.constants.push(Box::from(cc.name.or_error()));
604 }
605 _ => {}
606 }
607 }
608 index.classes.push(iface_def);
609 }
610
611 StmtKind::Trait(t) => {
612 let start_line = view.position_of(stmt.span.start).line;
613 let ns = cur_ns.as_deref();
614
615 let t_name = t.name.or_error();
616 let mut trait_def = ClassDef {
617 name: Box::from(t_name),
618 fqn: fqn(ns, t_name),
619 kind: ClassKind::Trait,
620 is_abstract: false,
621 is_readonly: false,
622 parent: None,
623 implements: Vec::new(),
624 traits: Vec::new(),
625 methods: Vec::new(),
626 properties: Vec::new(),
627 constants: Vec::new(),
628 cases: Vec::new(),
629 start_line,
630 name_char: name_char(t_name),
631 doc_methods: Vec::new(),
632 mixins: Vec::new(),
633 trait_method_aliases: Vec::new(),
634 };
635
636 for member in t.body.members.iter() {
637 match &member.kind {
638 ClassMemberKind::Method(m) => {
639 let mstart = view.position_of(member.span.start).line;
640 let vis = method_visibility(m.visibility);
641 let m_name = m.name.or_error();
642 trait_def.methods.push(MethodDef {
643 name: Box::from(m_name),
644 is_static: m.is_static,
645 is_abstract: m.is_abstract,
646 visibility: vis,
647 params: extract_params(&m.params),
648 return_type: m
649 .return_type
650 .as_ref()
651 .map(|t| format_type_hint(t).into()),
652 start_line: mstart,
653 name_char: name_char(m_name),
654 });
655 }
656 ClassMemberKind::Property(p) => {
657 let vis = method_visibility(p.visibility);
658 let pstart = view.position_of(member.span.start).line;
659 let p_name = p.name.or_error();
660 trait_def.properties.push(PropertyDef {
661 name: Box::from(p_name),
662 is_static: p.is_static,
663 is_readonly: p.is_readonly,
664 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
665 visibility: vis,
666 start_line: pstart,
667 name_char: name_char(p_name),
668 });
669 }
670 ClassMemberKind::ClassConst(cc) => {
671 trait_def.constants.push(Box::from(cc.name.or_error()));
672 }
673 ClassMemberKind::TraitUse(tu) => {
674 for tr in tu.traits.iter() {
675 trait_def
676 .traits
677 .push(Arc::from(tr.to_string_repr().as_ref()));
678 }
679 for adaptation in tu.adaptations.iter() {
680 if let TraitAdaptationKind::Alias {
681 method,
682 new_name: Some(new_name),
683 trait_name,
684 ..
685 } = &adaptation.kind
686 {
687 trait_def.trait_method_aliases.push(TraitMethodAlias {
688 alias: Box::from(new_name.to_string_repr().as_ref()),
689 original: Box::from(method.to_string_repr().as_ref()),
690 trait_name: trait_name
691 .as_ref()
692 .map(|n| Box::from(n.to_string_repr().as_ref())),
693 });
694 }
695 }
696 }
697 }
698 }
699 index.classes.push(trait_def);
700 }
701
702 StmtKind::Enum(e) => {
703 let start_line = view.position_of(stmt.span.start).line;
704 let ns = cur_ns.as_deref();
705
706 let e_name = e.name.or_error();
707 let mut enum_def = ClassDef {
708 name: Box::from(e_name),
709 fqn: fqn(ns, e_name),
710 kind: ClassKind::Enum,
711 is_abstract: false,
712 is_readonly: false,
713 parent: None,
714 implements: e
715 .implements
716 .iter()
717 .map(|i| Arc::from(i.to_string_repr().as_ref()))
718 .collect(),
719 traits: Vec::new(),
720 methods: Vec::new(),
721 properties: Vec::new(),
722 constants: Vec::new(),
723 cases: Vec::new(),
724 start_line,
725 name_char: name_char(e_name),
726 doc_methods: Vec::new(),
727 mixins: Vec::new(),
728 trait_method_aliases: Vec::new(),
729 };
730
731 for member in e.body.members.iter() {
732 match &member.kind {
733 EnumMemberKind::Case(c) => {
734 enum_def.cases.push(Box::from(c.name.or_error()));
735 }
736 EnumMemberKind::Method(m) => {
737 let mstart = view.position_of(member.span.start).line;
738 let vis = method_visibility(m.visibility);
739 let m_name = m.name.or_error();
740 enum_def.methods.push(MethodDef {
741 name: Box::from(m_name),
742 is_static: m.is_static,
743 is_abstract: m.is_abstract,
744 visibility: vis,
745 params: extract_params(&m.params),
746 return_type: m
747 .return_type
748 .as_ref()
749 .map(|t| format_type_hint(t).into()),
750 start_line: mstart,
751 name_char: name_char(m_name),
752 });
753 }
754 EnumMemberKind::ClassConst(cc) => {
755 enum_def.constants.push(Box::from(cc.name.or_error()));
756 }
757 _ => {}
758 }
759 }
760 index.classes.push(enum_def);
761 }
762
763 StmtKind::Const(consts) => {
764 for c in consts.iter() {
765 index.constants.push(Box::from(c.name.or_error()));
766 }
767 }
768 _ => {}
769 }
770 }
771}
772
773fn extract_params<'a, 'b>(params: &[php_ast::Param<'a, 'b>]) -> Vec<ParamDef> {
774 params
775 .iter()
776 .map(|p| ParamDef {
777 name: Box::from(p.name.or_error()),
778 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
779 has_default: p.default.is_some(),
780 variadic: p.variadic,
781 })
782 .collect()
783}
784
785fn method_visibility(vis: Option<php_ast::Visibility>) -> Visibility {
786 match vis {
787 Some(php_ast::Visibility::Protected) => Visibility::Protected,
788 Some(php_ast::Visibility::Private) => Visibility::Private,
789 _ => Visibility::Public,
790 }
791}
792
793fn doc_method_tag_line(
796 view: &crate::document::ast::SourceView<'_>,
797 doc_comment: &php_ast::Comment<'_>,
798 method_name: &str,
799) -> u32 {
800 let text = doc_comment.text;
801 let base = doc_comment.span.start as usize;
802 let mut offset = 0usize;
803 while let Some(tag_pos) = text[offset..].find("@method") {
804 let segment_start = offset + tag_pos;
805 let segment = &text[segment_start..];
806 let line_len = segment.find('\n').unwrap_or(segment.len());
807 let needle = format!("{}(", method_name);
811 if segment[..line_len].contains(needle.as_str()) {
812 return view.position_of((base + segment_start) as u32).line;
813 }
814 offset = segment_start + "@method".len();
815 }
816 view.position_of(doc_comment.span.start).line
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn extracts_class_and_method() {
825 let src = "<?php\nclass Greeter {\n public function greet(string $name): string {}\n}";
826 let doc = ParsedDoc::parse(src.to_string());
827 let idx = FileIndex::extract(&doc);
828 assert_eq!(idx.classes.len(), 1);
829 let cls = &idx.classes[0];
830 assert_eq!(&*cls.name, "Greeter");
831 assert_eq!(cls.kind, ClassKind::Class);
832 assert_eq!(cls.start_line, 1);
833 assert_eq!(cls.methods.len(), 1);
834 let method = &cls.methods[0];
835 assert_eq!(&*method.name, "greet");
836 assert_eq!(method.return_type.as_deref(), Some("string"));
837 assert_eq!(method.params.len(), 1);
838 assert_eq!(&*method.params[0].name, "name");
839 assert_eq!(method.params[0].type_hint.as_deref(), Some("string"));
840 }
841
842 #[test]
843 fn extracts_function() {
844 let src = "<?php\nfunction add(int $a, int $b): int {}";
845 let doc = ParsedDoc::parse(src.to_string());
846 let idx = FileIndex::extract(&doc);
847 assert_eq!(idx.functions.len(), 1);
848 let f = &idx.functions[0];
849 assert_eq!(&*f.name, "add");
850 assert_eq!(f.return_type.as_deref(), Some("int"));
851 assert_eq!(f.params.len(), 2);
852 assert_eq!(&*f.params[0].name, "a");
853 assert_eq!(f.params[0].type_hint.as_deref(), Some("int"));
854 assert_eq!(&*f.params[1].name, "b");
855 assert_eq!(f.params[1].type_hint.as_deref(), Some("int"));
856 }
857
858 #[test]
859 fn extracts_namespace() {
860 let src = "<?php\nnamespace App\\Services;\nclass Mailer {}";
861 let doc = ParsedDoc::parse(src.to_string());
862 let idx = FileIndex::extract(&doc);
863 assert_eq!(idx.namespace.as_deref(), Some("App\\Services"));
864 assert_eq!(&*idx.classes[0].fqn, "App\\Services\\Mailer");
865 }
866
867 #[test]
868 fn extracts_braced_namespace() {
869 let src = "<?php\nnamespace App\\Models {\n class User {}\n}";
870 let doc = ParsedDoc::parse(src.to_string());
871 let idx = FileIndex::extract(&doc);
872 assert_eq!(idx.namespace.as_deref(), Some("App\\Models"));
873 assert_eq!(&*idx.classes[0].fqn, "App\\Models\\User");
874 }
875
876 #[test]
877 fn extracts_interface() {
878 let src = "<?php\ninterface Countable {\n public function count(): int;\n}";
879 let doc = ParsedDoc::parse(src.to_string());
880 let idx = FileIndex::extract(&doc);
881 assert_eq!(idx.classes.len(), 1);
882 assert_eq!(idx.classes[0].kind, ClassKind::Interface);
883 assert_eq!(&*idx.classes[0].methods[0].name, "count");
884 assert!(idx.classes[0].methods[0].is_abstract);
885 }
886
887 #[test]
888 fn extracts_trait() {
889 let src = "<?php\ntrait Loggable {\n public function log(): void {}\n}";
890 let doc = ParsedDoc::parse(src.to_string());
891 let idx = FileIndex::extract(&doc);
892 assert_eq!(idx.classes[0].kind, ClassKind::Trait);
893 assert_eq!(&*idx.classes[0].methods[0].name, "log");
894 }
895
896 #[test]
897 fn extracts_enum_cases() {
898 let src = "<?php\nenum Status { case Active; case Inactive; }";
899 let doc = ParsedDoc::parse(src.to_string());
900 let idx = FileIndex::extract(&doc);
901 assert_eq!(idx.classes[0].kind, ClassKind::Enum);
902 assert!(idx.classes[0].cases.iter().any(|c| c.as_ref() == "Active"));
903 assert!(
904 idx.classes[0]
905 .cases
906 .iter()
907 .any(|c| c.as_ref() == "Inactive")
908 );
909 }
910
911 #[test]
912 fn readonly_class_is_marked() {
913 let src = "<?php\nreadonly class ValueObject { public function __construct(public string $id) {} }";
914 let doc = ParsedDoc::parse(src.to_string());
915 let idx = FileIndex::extract(&doc);
916 let cls = &idx.classes[0];
917 assert!(cls.is_readonly);
918 }
919
920 #[test]
921 fn non_readonly_class_is_not_marked() {
922 let src = "<?php\nclass Plain {}";
923 let doc = ParsedDoc::parse(src.to_string());
924 let idx = FileIndex::extract(&doc);
925 assert!(!idx.classes[0].is_readonly);
926 }
927
928 #[test]
929 fn readonly_property_is_marked() {
930 let src = "<?php\nclass Dto { public readonly string $value; }";
931 let doc = ParsedDoc::parse(src.to_string());
932 let idx = FileIndex::extract(&doc);
933 let prop = &idx.classes[0].properties[0];
934 assert!(prop.is_readonly);
935 }
936
937 #[test]
938 fn non_readonly_property_is_not_marked() {
939 let src = "<?php\nclass Dto { public string $value; }";
940 let doc = ParsedDoc::parse(src.to_string());
941 let idx = FileIndex::extract(&doc);
942 let prop = &idx.classes[0].properties[0];
943 assert!(!prop.is_readonly);
944 }
945
946 #[test]
947 fn readonly_promoted_param_is_marked() {
948 let src = "<?php\nclass Dto { public function __construct(public readonly string $id) {} }";
949 let doc = ParsedDoc::parse(src.to_string());
950 let idx = FileIndex::extract(&doc);
951 let prop = idx.classes[0]
952 .properties
953 .iter()
954 .find(|p| p.name.as_ref() == "id")
955 .expect("promoted property 'id' should be indexed");
956 assert!(prop.is_readonly);
957 }
958
959 #[test]
960 fn extracts_class_properties_and_constants() {
961 let src = "<?php\nclass Config {\n public string $host;\n const VERSION = '1.0';\n}";
962 let doc = ParsedDoc::parse(src.to_string());
963 let idx = FileIndex::extract(&doc);
964 let cls = &idx.classes[0];
965 assert_eq!(cls.properties.len(), 1);
966 assert_eq!(&*cls.properties[0].name, "host");
967 assert!(cls.constants.iter().any(|c| c.as_ref() == "VERSION"));
968 }
969
970 #[test]
971 fn extracts_trait_use() {
972 let src = "<?php\ntrait T {}\nclass MyClass { use T; }";
973 let doc = ParsedDoc::parse(src.to_string());
974 let idx = FileIndex::extract(&doc);
975 let cls = idx
976 .classes
977 .iter()
978 .find(|c| c.name.as_ref() == "MyClass")
979 .unwrap();
980 assert!(cls.traits.iter().any(|t| t.as_ref() == "T"));
981 }
982
983 #[test]
984 fn extracts_class_implements_and_extends() {
985 let src = "<?php\nclass Dog extends Animal implements Pet, Movable {}";
986 let doc = ParsedDoc::parse(src.to_string());
987 let idx = FileIndex::extract(&doc);
988 let cls = &idx.classes[0];
989 assert_eq!(cls.parent.as_deref(), Some("Animal"));
990 assert!(cls.implements.iter().any(|i| i.as_ref() == "Pet"));
991 assert!(cls.implements.iter().any(|i| i.as_ref() == "Movable"));
992 }
993
994 #[test]
995 fn constructor_promoted_params_become_properties() {
996 let src = "<?php\nclass User {\n public function __construct(public string $name) {}\n}";
997 let doc = ParsedDoc::parse(src.to_string());
998 let idx = FileIndex::extract(&doc);
999 let cls = &idx.classes[0];
1000 assert!(
1002 cls.properties.iter().any(|p| p.name.as_ref() == "name"),
1003 "expected promoted property 'name', got: {:?}",
1004 cls.properties.iter().map(|p| &p.name).collect::<Vec<_>>()
1005 );
1006 }
1007
1008 #[test]
1009 fn extracts_doc_methods_from_class_docblock() {
1010 let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
1011 let doc = ParsedDoc::parse(src.to_string());
1012 let idx = FileIndex::extract(&doc);
1013 let cls = &idx.classes[0];
1014 assert_eq!(cls.doc_methods.len(), 2, "expected 2 @method entries");
1015
1016 let find = cls.doc_methods.iter().find(|m| m.name.as_ref() == "find");
1017 assert!(find.is_some(), "expected @method find");
1018 let find = find.unwrap();
1019 assert!(!find.is_static);
1020 assert_eq!(find.return_type.as_deref(), Some("User"));
1021 assert_eq!(find.start_line, 2); let where_m = cls.doc_methods.iter().find(|m| m.name.as_ref() == "where");
1024 assert!(where_m.is_some(), "expected @method where");
1025 let where_m = where_m.unwrap();
1026 assert!(where_m.is_static);
1027 assert_eq!(where_m.return_type.as_deref(), Some("Builder"));
1028 assert_eq!(where_m.start_line, 3); }
1030
1031 #[test]
1032 fn doc_method_tag_line_no_substring_collision() {
1033 let src = "<?php\n/**\n * @method void log(string $find)\n * @method Model find()\n */\nclass Builder {}";
1035 let doc = ParsedDoc::parse(src.to_string());
1036 let idx = FileIndex::extract(&doc);
1037 let cls = &idx.classes[0];
1038 let find = cls.doc_methods.iter().find(|m| m.name.as_ref() == "find");
1039 assert!(find.is_some(), "expected @method find");
1040 assert_eq!(find.unwrap().start_line, 3); }
1042
1043 #[test]
1044 fn class_without_docblock_has_no_doc_methods() {
1045 let src = "<?php\nclass Plain {\n public function foo(): void {}\n}";
1046 let doc = ParsedDoc::parse(src.to_string());
1047 let idx = FileIndex::extract(&doc);
1048 assert!(idx.classes[0].doc_methods.is_empty());
1049 }
1050
1051 #[test]
1052 fn extracts_mixins_from_class_docblock() {
1053 let src = "<?php\n/**\n * @mixin Macroable\n * @mixin HasEvents\n */\nclass Builder {}";
1054 let doc = ParsedDoc::parse(src.to_string());
1055 let idx = FileIndex::extract(&doc);
1056 let cls = &idx.classes[0];
1057 assert_eq!(cls.mixins.len(), 2, "expected 2 @mixin entries");
1058 assert!(cls.mixins.iter().any(|m| m.as_ref() == "Macroable"));
1059 assert!(cls.mixins.iter().any(|m| m.as_ref() == "HasEvents"));
1060 }
1061
1062 #[test]
1063 fn class_without_docblock_has_no_mixins() {
1064 let src = "<?php\nclass Plain {}";
1065 let doc = ParsedDoc::parse(src.to_string());
1066 let idx = FileIndex::extract(&doc);
1067 assert!(idx.classes[0].mixins.is_empty());
1068 }
1069}