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