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