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