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