1use std::sync::Arc;
12
13use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
14
15use crate::ast::{ParsedDoc, format_type_hint};
16use crate::docblock::parse_docblock;
17
18#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
21pub struct FileIndex {
22 pub namespace: Option<Box<str>>,
23 pub functions: Vec<FunctionDef>,
24 pub classes: Vec<ClassDef>,
25 pub constants: Vec<Box<str>>,
26}
27
28#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29pub struct FunctionDef {
30 pub name: Box<str>,
31 pub fqn: Box<str>,
33 pub params: Vec<ParamDef>,
34 pub return_type: Option<Box<str>>,
35 pub doc: Option<Box<str>>,
37 pub start_line: u32,
38 pub name_char: u32,
40}
41
42#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
43pub struct ParamDef {
44 pub name: Box<str>,
45 pub type_hint: Option<Box<str>>,
46 pub has_default: bool,
47 pub variadic: bool,
48}
49
50#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
51pub struct ClassDef {
52 pub name: Box<str>,
53 pub fqn: Box<str>,
55 pub kind: ClassKind,
56 pub is_abstract: bool,
57 pub parent: Option<Arc<str>>,
59 pub implements: Vec<Arc<str>>,
60 pub traits: Vec<Arc<str>>,
61 pub methods: Vec<MethodDef>,
62 pub properties: Vec<PropertyDef>,
63 pub constants: Vec<Box<str>>,
64 pub cases: Vec<Box<str>>,
66 pub start_line: u32,
67 pub name_char: u32,
69 pub doc_methods: Vec<DocMethodEntry>,
71 pub mixins: Vec<Arc<str>>,
73}
74
75#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
79pub struct DocMethodEntry {
80 pub name: Box<str>,
81 pub is_static: bool,
82 pub return_type: Option<Box<str>>,
84 pub start_line: u32,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89pub enum ClassKind {
90 Class,
91 Interface,
92 Trait,
93 Enum,
94}
95
96#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
97pub struct MethodDef {
98 pub name: Box<str>,
99 pub is_static: bool,
100 pub is_abstract: bool,
101 pub visibility: Visibility,
102 pub params: Vec<ParamDef>,
103 pub return_type: Option<Box<str>>,
104 pub doc: Option<Box<str>>,
105 pub start_line: u32,
106 pub name_char: u32,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111pub enum Visibility {
112 Public,
113 Protected,
114 Private,
115}
116
117#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
118pub struct PropertyDef {
119 pub name: Box<str>,
120 pub is_static: bool,
121 pub type_hint: Option<Box<str>>,
122 pub visibility: Visibility,
123 pub start_line: u32,
124 pub name_char: u32,
126}
127
128impl FileIndex {
131 pub fn extract(doc: &ParsedDoc) -> Self {
133 let source = doc.source();
134 let view = doc.view();
135 let mut index = FileIndex::default();
136 collect_stmts(source, &view, &doc.program().stmts, None, &mut index);
137 index
138 }
139}
140
141fn fqn(namespace: Option<&str>, name: &str) -> Box<str> {
144 match namespace {
145 Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, name).into(),
146 _ => name.into(),
147 }
148}
149
150fn collect_stmts(
151 source: &str,
152 view: &crate::ast::SourceView<'_>,
153 stmts: &[Stmt<'_, '_>],
154 namespace: Option<&str>,
155 index: &mut FileIndex,
156) {
157 use crate::ast::str_offset;
158
159 let name_char = |name: &str| -> u32 {
160 str_offset(source, name)
161 .map(|off| view.position_of(off).character)
162 .unwrap_or(0)
163 };
164
165 let mut cur_ns: Option<Box<str>> = namespace.map(|s| s.into());
167
168 for stmt in stmts {
169 match &stmt.kind {
170 StmtKind::Namespace(ns) => {
172 let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into());
173
174 match &ns.body {
175 NamespaceBody::Braced(inner) => {
176 let ns_str = ns_name.as_deref();
178 if index.namespace.is_none() {
180 index.namespace = ns_name.clone();
181 }
182 collect_stmts(source, view, &inner.stmts, ns_str, index);
183 }
184 NamespaceBody::Simple => {
185 if index.namespace.is_none() {
187 index.namespace = ns_name.clone();
188 }
189 cur_ns = ns_name;
190 }
191 }
192 }
193
194 StmtKind::Function(f) => {
196 let doc_text = f.doc_comment.as_ref().map(|c| c.text.into());
197 let start_line = view.position_of(stmt.span.start).line;
198 let ns = cur_ns.as_deref();
199 let f_name = f.name.or_error();
200 index.functions.push(FunctionDef {
201 name: Box::from(f_name),
202 fqn: fqn(ns, f_name),
203 params: extract_params(&f.params),
204 return_type: f.return_type.as_ref().map(|t| format_type_hint(t).into()),
205 doc: doc_text,
206 start_line,
207 name_char: name_char(f_name),
208 });
209 }
210
211 StmtKind::Class(c) => {
213 let Some(class_name) = c.name else { continue };
214 let class_name_str = class_name.or_error();
215 let start_line = view.position_of(stmt.span.start).line;
216 let ns = cur_ns.as_deref();
217
218 let mut class_def = ClassDef {
219 name: Box::from(class_name_str),
220 fqn: fqn(ns, class_name_str),
221 kind: ClassKind::Class,
222 is_abstract: c.modifiers.is_abstract,
223 parent: c
224 .extends
225 .as_ref()
226 .map(|e| Arc::from(e.to_string_repr().as_ref())),
227 implements: c
228 .implements
229 .iter()
230 .map(|i| Arc::from(i.to_string_repr().as_ref()))
231 .collect(),
232 traits: Vec::new(),
233 methods: Vec::new(),
234 properties: Vec::new(),
235 constants: Vec::new(),
236 cases: Vec::new(),
237 start_line,
238 name_char: name_char(class_name_str),
239 doc_methods: Vec::new(),
240 mixins: Vec::new(),
241 };
242
243 for member in c.body.members.iter() {
244 match &member.kind {
245 ClassMemberKind::Method(m) => {
246 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
247 let mstart = view.position_of(member.span.start).line;
248 let vis = method_visibility(m.visibility);
249 let method_params = extract_params(&m.params);
250 for ast_param in m.params.iter() {
252 if ast_param.visibility.is_some() {
253 let pvis = method_visibility(ast_param.visibility);
254 let pstart = view.position_of(ast_param.span.start).line;
255 let p_name = ast_param.name.or_error();
256 class_def.properties.push(PropertyDef {
257 name: Box::from(p_name),
258 is_static: false,
259 type_hint: ast_param
260 .type_hint
261 .as_ref()
262 .map(|t| format_type_hint(t).into()),
263 visibility: pvis,
264 start_line: pstart,
265 name_char: name_char(p_name),
266 });
267 }
268 }
269 let m_name = m.name.or_error();
270 class_def.methods.push(MethodDef {
271 name: Box::from(m_name),
272 is_static: m.is_static,
273 is_abstract: m.is_abstract,
274 visibility: vis,
275 params: method_params,
276 return_type: m
277 .return_type
278 .as_ref()
279 .map(|t| format_type_hint(t).into()),
280 doc: mdoc,
281 start_line: mstart,
282 name_char: name_char(m_name),
283 });
284 }
285 ClassMemberKind::Property(p) => {
286 let vis = method_visibility(p.visibility);
287 let pstart = view.position_of(member.span.start).line;
288 let p_name = p.name.or_error();
289 class_def.properties.push(PropertyDef {
290 name: Box::from(p_name),
291 is_static: p.is_static,
292 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
293 visibility: vis,
294 start_line: pstart,
295 name_char: name_char(p_name),
296 });
297 }
298 ClassMemberKind::ClassConst(cc) => {
299 class_def.constants.push(Box::from(cc.name.or_error()));
300 }
301 ClassMemberKind::TraitUse(tu) => {
302 for t in tu.traits.iter() {
303 class_def
304 .traits
305 .push(Arc::from(t.to_string_repr().as_ref()));
306 }
307 }
308 }
309 }
310 if let Some(doc) = &c.doc_comment {
314 let db = parse_docblock(doc.text);
315 for dm in &db.methods {
316 let line = doc_method_tag_line(view, doc, &dm.name);
317 let ret = if dm.return_type.is_empty() {
318 None
319 } else {
320 Some(Box::from(dm.return_type.as_str()))
321 };
322 class_def.doc_methods.push(DocMethodEntry {
323 name: Box::from(dm.name.as_str()),
324 is_static: dm.is_static,
325 return_type: ret,
326 start_line: line,
327 });
328 }
329 for mixin in &db.mixins {
330 class_def.mixins.push(Arc::from(mixin.as_str()));
331 }
332 }
333 index.classes.push(class_def);
334 }
335
336 StmtKind::Interface(i) => {
338 let start_line = view.position_of(stmt.span.start).line;
339 let ns = cur_ns.as_deref();
340
341 let i_name = i.name.or_error();
342 let mut iface_def = ClassDef {
343 name: Box::from(i_name),
344 fqn: fqn(ns, i_name),
345 kind: ClassKind::Interface,
346 is_abstract: true,
347 parent: None,
348 implements: i
349 .extends
350 .iter()
351 .map(|e| Arc::from(e.to_string_repr().as_ref()))
352 .collect(),
353 traits: Vec::new(),
354 methods: Vec::new(),
355 properties: Vec::new(),
356 constants: Vec::new(),
357 cases: Vec::new(),
358 start_line,
359 name_char: name_char(i_name),
360 doc_methods: Vec::new(),
361 mixins: Vec::new(),
362 };
363
364 for member in i.body.members.iter() {
365 match &member.kind {
366 ClassMemberKind::Method(m) => {
367 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
368 let mstart = view.position_of(member.span.start).line;
369 let m_name = m.name.or_error();
370 iface_def.methods.push(MethodDef {
371 name: Box::from(m_name),
372 is_static: m.is_static,
373 is_abstract: true,
374 visibility: Visibility::Public,
375 params: extract_params(&m.params),
376 return_type: m
377 .return_type
378 .as_ref()
379 .map(|t| format_type_hint(t).into()),
380 doc: mdoc,
381 start_line: mstart,
382 name_char: name_char(m_name),
383 });
384 }
385 ClassMemberKind::ClassConst(cc) => {
386 iface_def.constants.push(Box::from(cc.name.or_error()));
387 }
388 _ => {}
389 }
390 }
391 index.classes.push(iface_def);
392 }
393
394 StmtKind::Trait(t) => {
396 let start_line = view.position_of(stmt.span.start).line;
397 let ns = cur_ns.as_deref();
398
399 let t_name = t.name.or_error();
400 let mut trait_def = ClassDef {
401 name: Box::from(t_name),
402 fqn: fqn(ns, t_name),
403 kind: ClassKind::Trait,
404 is_abstract: false,
405 parent: None,
406 implements: Vec::new(),
407 traits: Vec::new(),
408 methods: Vec::new(),
409 properties: Vec::new(),
410 constants: Vec::new(),
411 cases: Vec::new(),
412 start_line,
413 name_char: name_char(t_name),
414 doc_methods: Vec::new(),
415 mixins: Vec::new(),
416 };
417
418 for member in t.body.members.iter() {
419 match &member.kind {
420 ClassMemberKind::Method(m) => {
421 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
422 let mstart = view.position_of(member.span.start).line;
423 let vis = method_visibility(m.visibility);
424 let m_name = m.name.or_error();
425 trait_def.methods.push(MethodDef {
426 name: Box::from(m_name),
427 is_static: m.is_static,
428 is_abstract: m.is_abstract,
429 visibility: vis,
430 params: extract_params(&m.params),
431 return_type: m
432 .return_type
433 .as_ref()
434 .map(|t| format_type_hint(t).into()),
435 doc: mdoc,
436 start_line: mstart,
437 name_char: name_char(m_name),
438 });
439 }
440 ClassMemberKind::Property(p) => {
441 let vis = method_visibility(p.visibility);
442 let pstart = view.position_of(member.span.start).line;
443 let p_name = p.name.or_error();
444 trait_def.properties.push(PropertyDef {
445 name: Box::from(p_name),
446 is_static: p.is_static,
447 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
448 visibility: vis,
449 start_line: pstart,
450 name_char: name_char(p_name),
451 });
452 }
453 ClassMemberKind::ClassConst(cc) => {
454 trait_def.constants.push(Box::from(cc.name.or_error()));
455 }
456 ClassMemberKind::TraitUse(tu) => {
457 for tr in tu.traits.iter() {
458 trait_def
459 .traits
460 .push(Arc::from(tr.to_string_repr().as_ref()));
461 }
462 }
463 }
464 }
465 index.classes.push(trait_def);
466 }
467
468 StmtKind::Enum(e) => {
470 let start_line = view.position_of(stmt.span.start).line;
471 let ns = cur_ns.as_deref();
472
473 let e_name = e.name.or_error();
474 let mut enum_def = ClassDef {
475 name: Box::from(e_name),
476 fqn: fqn(ns, e_name),
477 kind: ClassKind::Enum,
478 is_abstract: false,
479 parent: None,
480 implements: e
481 .implements
482 .iter()
483 .map(|i| Arc::from(i.to_string_repr().as_ref()))
484 .collect(),
485 traits: Vec::new(),
486 methods: Vec::new(),
487 properties: Vec::new(),
488 constants: Vec::new(),
489 cases: Vec::new(),
490 start_line,
491 name_char: name_char(e_name),
492 doc_methods: Vec::new(),
493 mixins: Vec::new(),
494 };
495
496 for member in e.body.members.iter() {
497 match &member.kind {
498 EnumMemberKind::Case(c) => {
499 enum_def.cases.push(Box::from(c.name.or_error()));
500 }
501 EnumMemberKind::Method(m) => {
502 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
503 let mstart = view.position_of(member.span.start).line;
504 let vis = method_visibility(m.visibility);
505 let m_name = m.name.or_error();
506 enum_def.methods.push(MethodDef {
507 name: Box::from(m_name),
508 is_static: m.is_static,
509 is_abstract: m.is_abstract,
510 visibility: vis,
511 params: extract_params(&m.params),
512 return_type: m
513 .return_type
514 .as_ref()
515 .map(|t| format_type_hint(t).into()),
516 doc: mdoc,
517 start_line: mstart,
518 name_char: name_char(m_name),
519 });
520 }
521 EnumMemberKind::ClassConst(cc) => {
522 enum_def.constants.push(Box::from(cc.name.or_error()));
523 }
524 _ => {}
525 }
526 }
527 index.classes.push(enum_def);
528 }
529
530 StmtKind::Const(consts) => {
532 for c in consts.iter() {
533 index.constants.push(Box::from(c.name.or_error()));
534 }
535 }
536
537 _ => {}
538 }
539 }
540}
541
542fn extract_params<'a, 'b>(params: &[php_ast::Param<'a, 'b>]) -> Vec<ParamDef> {
543 params
544 .iter()
545 .map(|p| ParamDef {
546 name: Box::from(p.name.or_error()),
547 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
548 has_default: p.default.is_some(),
549 variadic: p.variadic,
550 })
551 .collect()
552}
553
554fn method_visibility(vis: Option<php_ast::Visibility>) -> Visibility {
555 match vis {
556 Some(php_ast::Visibility::Protected) => Visibility::Protected,
557 Some(php_ast::Visibility::Private) => Visibility::Private,
558 _ => Visibility::Public,
559 }
560}
561
562fn doc_method_tag_line(
565 view: &crate::ast::SourceView<'_>,
566 doc_comment: &php_ast::Comment<'_>,
567 method_name: &str,
568) -> u32 {
569 let text = doc_comment.text;
570 let base = doc_comment.span.start as usize;
571 let mut offset = 0usize;
572 while let Some(tag_pos) = text[offset..].find("@method") {
573 let segment_start = offset + tag_pos;
574 let segment = &text[segment_start..];
575 let line_len = segment.find('\n').unwrap_or(segment.len());
576 let needle = format!("{}(", method_name);
580 if segment[..line_len].contains(needle.as_str()) {
581 return view.position_of((base + segment_start) as u32).line;
582 }
583 offset = segment_start + "@method".len();
584 }
585 view.position_of(doc_comment.span.start).line
586}
587
588#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn extracts_class_and_method() {
596 let src = "<?php\nclass Greeter {\n public function greet(string $name): string {}\n}";
597 let doc = ParsedDoc::parse(src.to_string());
598 let idx = FileIndex::extract(&doc);
599 assert_eq!(idx.classes.len(), 1);
600 let cls = &idx.classes[0];
601 assert_eq!(cls.name, "Greeter".into());
602 assert_eq!(cls.kind, ClassKind::Class);
603 assert_eq!(cls.start_line, 1);
604 assert_eq!(cls.methods.len(), 1);
605 let method = &cls.methods[0];
606 assert_eq!(method.name, "greet".into());
607 assert_eq!(method.return_type.as_deref(), Some("string"));
608 assert_eq!(method.params.len(), 1);
609 assert_eq!(method.params[0].name, "name".into());
610 assert_eq!(method.params[0].type_hint.as_deref(), Some("string"));
611 }
612
613 #[test]
614 fn extracts_function() {
615 let src = "<?php\nfunction add(int $a, int $b): int {}";
616 let doc = ParsedDoc::parse(src.to_string());
617 let idx = FileIndex::extract(&doc);
618 assert_eq!(idx.functions.len(), 1);
619 let f = &idx.functions[0];
620 assert_eq!(f.name, "add".into());
621 assert_eq!(f.return_type.as_deref(), Some("int"));
622 assert_eq!(f.params.len(), 2);
623 }
624
625 #[test]
626 fn extracts_namespace() {
627 let src = "<?php\nnamespace App\\Services;\nclass Mailer {}";
628 let doc = ParsedDoc::parse(src.to_string());
629 let idx = FileIndex::extract(&doc);
630 assert_eq!(idx.namespace.as_deref(), Some("App\\Services"));
631 assert_eq!(idx.classes[0].fqn, "App\\Services\\Mailer".into());
632 }
633
634 #[test]
635 fn extracts_braced_namespace() {
636 let src = "<?php\nnamespace App\\Models {\n class User {}\n}";
637 let doc = ParsedDoc::parse(src.to_string());
638 let idx = FileIndex::extract(&doc);
639 assert_eq!(idx.namespace.as_deref(), Some("App\\Models"));
640 assert_eq!(idx.classes[0].fqn, "App\\Models\\User".into());
641 }
642
643 #[test]
644 fn extracts_interface() {
645 let src = "<?php\ninterface Countable {\n public function count(): int;\n}";
646 let doc = ParsedDoc::parse(src.to_string());
647 let idx = FileIndex::extract(&doc);
648 assert_eq!(idx.classes.len(), 1);
649 assert_eq!(idx.classes[0].kind, ClassKind::Interface);
650 assert_eq!(idx.classes[0].methods[0].name, "count".into());
651 assert!(idx.classes[0].methods[0].is_abstract);
652 }
653
654 #[test]
655 fn extracts_trait() {
656 let src = "<?php\ntrait Loggable {\n public function log(): void {}\n}";
657 let doc = ParsedDoc::parse(src.to_string());
658 let idx = FileIndex::extract(&doc);
659 assert_eq!(idx.classes[0].kind, ClassKind::Trait);
660 assert_eq!(idx.classes[0].methods[0].name, "log".into());
661 }
662
663 #[test]
664 fn extracts_enum_cases() {
665 let src = "<?php\nenum Status { case Active; case Inactive; }";
666 let doc = ParsedDoc::parse(src.to_string());
667 let idx = FileIndex::extract(&doc);
668 assert_eq!(idx.classes[0].kind, ClassKind::Enum);
669 assert!(idx.classes[0].cases.iter().any(|c| c.as_ref() == "Active"));
670 assert!(
671 idx.classes[0]
672 .cases
673 .iter()
674 .any(|c| c.as_ref() == "Inactive")
675 );
676 }
677
678 #[test]
679 fn extracts_class_properties_and_constants() {
680 let src = "<?php\nclass Config {\n public string $host;\n const VERSION = '1.0';\n}";
681 let doc = ParsedDoc::parse(src.to_string());
682 let idx = FileIndex::extract(&doc);
683 let cls = &idx.classes[0];
684 assert_eq!(cls.properties.len(), 1);
685 assert_eq!(cls.properties[0].name, "host".into());
686 assert!(cls.constants.iter().any(|c| c.as_ref() == "VERSION"));
687 }
688
689 #[test]
690 fn extracts_trait_use() {
691 let src = "<?php\ntrait T {}\nclass MyClass { use T; }";
692 let doc = ParsedDoc::parse(src.to_string());
693 let idx = FileIndex::extract(&doc);
694 let cls = idx
695 .classes
696 .iter()
697 .find(|c| c.name.as_ref() == "MyClass")
698 .unwrap();
699 assert!(cls.traits.iter().any(|t| t.as_ref() == "T"));
700 }
701
702 #[test]
703 fn extracts_class_implements_and_extends() {
704 let src = "<?php\nclass Dog extends Animal implements Pet, Movable {}";
705 let doc = ParsedDoc::parse(src.to_string());
706 let idx = FileIndex::extract(&doc);
707 let cls = &idx.classes[0];
708 assert_eq!(cls.parent.as_deref(), Some("Animal"));
709 assert!(cls.implements.iter().any(|i| i.as_ref() == "Pet"));
710 assert!(cls.implements.iter().any(|i| i.as_ref() == "Movable"));
711 }
712
713 #[test]
714 fn constructor_promoted_params_become_properties() {
715 let src = "<?php\nclass User {\n public function __construct(public string $name) {}\n}";
716 let doc = ParsedDoc::parse(src.to_string());
717 let idx = FileIndex::extract(&doc);
718 let cls = &idx.classes[0];
719 assert!(
721 cls.properties.iter().any(|p| p.name.as_ref() == "name"),
722 "expected promoted property 'name', got: {:?}",
723 cls.properties.iter().map(|p| &p.name).collect::<Vec<_>>()
724 );
725 }
726
727 #[test]
728 fn extracts_doc_methods_from_class_docblock() {
729 let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
730 let doc = ParsedDoc::parse(src.to_string());
731 let idx = FileIndex::extract(&doc);
732 let cls = &idx.classes[0];
733 assert_eq!(cls.doc_methods.len(), 2, "expected 2 @method entries");
734
735 let find = cls.doc_methods.iter().find(|m| m.name.as_ref() == "find");
736 assert!(find.is_some(), "expected @method find");
737 let find = find.unwrap();
738 assert!(!find.is_static);
739 assert_eq!(find.return_type.as_deref(), Some("User"));
740 assert_eq!(find.start_line, 2); let where_m = cls.doc_methods.iter().find(|m| m.name.as_ref() == "where");
743 assert!(where_m.is_some(), "expected @method where");
744 let where_m = where_m.unwrap();
745 assert!(where_m.is_static);
746 assert_eq!(where_m.return_type.as_deref(), Some("Builder"));
747 assert_eq!(where_m.start_line, 3); }
749
750 #[test]
751 fn doc_method_tag_line_no_substring_collision() {
752 let src = "<?php\n/**\n * @method void log(string $find)\n * @method Model find()\n */\nclass Builder {}";
754 let doc = ParsedDoc::parse(src.to_string());
755 let idx = FileIndex::extract(&doc);
756 let cls = &idx.classes[0];
757 let find = cls.doc_methods.iter().find(|m| m.name.as_ref() == "find");
758 assert!(find.is_some(), "expected @method find");
759 assert_eq!(find.unwrap().start_line, 3); }
761
762 #[test]
763 fn class_without_docblock_has_no_doc_methods() {
764 let src = "<?php\nclass Plain {\n public function foo(): void {}\n}";
765 let doc = ParsedDoc::parse(src.to_string());
766 let idx = FileIndex::extract(&doc);
767 assert!(idx.classes[0].doc_methods.is_empty());
768 }
769
770 #[test]
771 fn extracts_mixins_from_class_docblock() {
772 let src = "<?php\n/**\n * @mixin Macroable\n * @mixin HasEvents\n */\nclass Builder {}";
773 let doc = ParsedDoc::parse(src.to_string());
774 let idx = FileIndex::extract(&doc);
775 let cls = &idx.classes[0];
776 assert_eq!(cls.mixins.len(), 2, "expected 2 @mixin entries");
777 assert!(cls.mixins.iter().any(|m| m.as_ref() == "Macroable"));
778 assert!(cls.mixins.iter().any(|m| m.as_ref() == "HasEvents"));
779 }
780
781 #[test]
782 fn class_without_docblock_has_no_mixins() {
783 let src = "<?php\nclass Plain {}";
784 let doc = ParsedDoc::parse(src.to_string());
785 let idx = FileIndex::extract(&doc);
786 assert!(idx.classes[0].mixins.is_empty());
787 }
788}