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