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