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