1use std::sync::Arc;
12
13use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
14
15use crate::ast::{ParsedDoc, format_type_hint};
16
17#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
20pub struct FileIndex {
21 pub namespace: Option<Box<str>>,
22 pub functions: Vec<FunctionDef>,
23 pub classes: Vec<ClassDef>,
24 pub constants: Vec<Box<str>>,
25}
26
27#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
28pub struct FunctionDef {
29 pub name: Box<str>,
30 pub fqn: Box<str>,
32 pub params: Vec<ParamDef>,
33 pub return_type: Option<Box<str>>,
34 pub doc: Option<Box<str>>,
36 pub start_line: u32,
37 pub name_char: u32,
39}
40
41#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
42pub struct ParamDef {
43 pub name: Box<str>,
44 pub type_hint: Option<Box<str>>,
45 pub has_default: bool,
46 pub variadic: bool,
47}
48
49#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
50pub struct ClassDef {
51 pub name: Box<str>,
52 pub fqn: Box<str>,
54 pub kind: ClassKind,
55 pub is_abstract: bool,
56 pub parent: Option<Arc<str>>,
58 pub implements: Vec<Arc<str>>,
59 pub traits: Vec<Arc<str>>,
60 pub methods: Vec<MethodDef>,
61 pub properties: Vec<PropertyDef>,
62 pub constants: Vec<Box<str>>,
63 pub cases: Vec<Box<str>>,
65 pub start_line: u32,
66 pub name_char: u32,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
71pub enum ClassKind {
72 Class,
73 Interface,
74 Trait,
75 Enum,
76}
77
78#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
79pub struct MethodDef {
80 pub name: Box<str>,
81 pub is_static: bool,
82 pub is_abstract: bool,
83 pub visibility: Visibility,
84 pub params: Vec<ParamDef>,
85 pub return_type: Option<Box<str>>,
86 pub doc: Option<Box<str>>,
87 pub start_line: u32,
88 pub name_char: u32,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93pub enum Visibility {
94 Public,
95 Protected,
96 Private,
97}
98
99#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
100pub struct PropertyDef {
101 pub name: Box<str>,
102 pub is_static: bool,
103 pub type_hint: Option<Box<str>>,
104 pub visibility: Visibility,
105 pub start_line: u32,
106 pub name_char: u32,
108}
109
110impl FileIndex {
113 pub fn extract(doc: &ParsedDoc) -> Self {
115 let source = doc.source();
116 let view = doc.view();
117 let mut index = FileIndex::default();
118 collect_stmts(source, &view, &doc.program().stmts, None, &mut index);
119 index
120 }
121}
122
123fn fqn(namespace: Option<&str>, name: &str) -> Box<str> {
126 match namespace {
127 Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, name).into(),
128 _ => name.into(),
129 }
130}
131
132fn collect_stmts(
133 source: &str,
134 view: &crate::ast::SourceView<'_>,
135 stmts: &[Stmt<'_, '_>],
136 namespace: Option<&str>,
137 index: &mut FileIndex,
138) {
139 use crate::ast::str_offset;
140
141 let name_char = |name: &str| -> u32 {
142 str_offset(source, name)
143 .map(|off| view.position_of(off).character)
144 .unwrap_or(0)
145 };
146
147 let mut cur_ns: Option<Box<str>> = namespace.map(|s| s.into());
149
150 for stmt in stmts {
151 match &stmt.kind {
152 StmtKind::Namespace(ns) => {
154 let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into());
155
156 match &ns.body {
157 NamespaceBody::Braced(inner) => {
158 let ns_str = ns_name.as_deref();
160 if index.namespace.is_none() {
162 index.namespace = ns_name.clone();
163 }
164 collect_stmts(source, view, &inner.stmts, ns_str, index);
165 }
166 NamespaceBody::Simple => {
167 if index.namespace.is_none() {
169 index.namespace = ns_name.clone();
170 }
171 cur_ns = ns_name;
172 }
173 }
174 }
175
176 StmtKind::Function(f) => {
178 let doc_text = f.doc_comment.as_ref().map(|c| c.text.into());
179 let start_line = view.position_of(stmt.span.start).line;
180 let ns = cur_ns.as_deref();
181 let f_name = f.name.or_error();
182 index.functions.push(FunctionDef {
183 name: Box::from(f_name),
184 fqn: fqn(ns, f_name),
185 params: extract_params(&f.params),
186 return_type: f.return_type.as_ref().map(|t| format_type_hint(t).into()),
187 doc: doc_text,
188 start_line,
189 name_char: name_char(f_name),
190 });
191 }
192
193 StmtKind::Class(c) => {
195 let Some(class_name) = c.name else { continue };
196 let class_name_str = class_name.or_error();
197 let start_line = view.position_of(stmt.span.start).line;
198 let ns = cur_ns.as_deref();
199
200 let mut class_def = ClassDef {
201 name: Box::from(class_name_str),
202 fqn: fqn(ns, class_name_str),
203 kind: ClassKind::Class,
204 is_abstract: c.modifiers.is_abstract,
205 parent: c
206 .extends
207 .as_ref()
208 .map(|e| Arc::from(e.to_string_repr().as_ref())),
209 implements: c
210 .implements
211 .iter()
212 .map(|i| Arc::from(i.to_string_repr().as_ref()))
213 .collect(),
214 traits: Vec::new(),
215 methods: Vec::new(),
216 properties: Vec::new(),
217 constants: Vec::new(),
218 cases: Vec::new(),
219 start_line,
220 name_char: name_char(class_name_str),
221 };
222
223 for member in c.body.members.iter() {
224 match &member.kind {
225 ClassMemberKind::Method(m) => {
226 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
227 let mstart = view.position_of(member.span.start).line;
228 let vis = method_visibility(m.visibility);
229 let method_params = extract_params(&m.params);
230 for ast_param in m.params.iter() {
232 if ast_param.visibility.is_some() {
233 let pvis = method_visibility(ast_param.visibility);
234 let pstart = view.position_of(ast_param.span.start).line;
235 let p_name = ast_param.name.or_error();
236 class_def.properties.push(PropertyDef {
237 name: Box::from(p_name),
238 is_static: false,
239 type_hint: ast_param
240 .type_hint
241 .as_ref()
242 .map(|t| format_type_hint(t).into()),
243 visibility: pvis,
244 start_line: pstart,
245 name_char: name_char(p_name),
246 });
247 }
248 }
249 let m_name = m.name.or_error();
250 class_def.methods.push(MethodDef {
251 name: Box::from(m_name),
252 is_static: m.is_static,
253 is_abstract: m.is_abstract,
254 visibility: vis,
255 params: method_params,
256 return_type: m
257 .return_type
258 .as_ref()
259 .map(|t| format_type_hint(t).into()),
260 doc: mdoc,
261 start_line: mstart,
262 name_char: name_char(m_name),
263 });
264 }
265 ClassMemberKind::Property(p) => {
266 let vis = method_visibility(p.visibility);
267 let pstart = view.position_of(member.span.start).line;
268 let p_name = p.name.or_error();
269 class_def.properties.push(PropertyDef {
270 name: Box::from(p_name),
271 is_static: p.is_static,
272 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
273 visibility: vis,
274 start_line: pstart,
275 name_char: name_char(p_name),
276 });
277 }
278 ClassMemberKind::ClassConst(cc) => {
279 class_def.constants.push(Box::from(cc.name.or_error()));
280 }
281 ClassMemberKind::TraitUse(tu) => {
282 for t in tu.traits.iter() {
283 class_def
284 .traits
285 .push(Arc::from(t.to_string_repr().as_ref()));
286 }
287 }
288 }
289 }
290 index.classes.push(class_def);
291 }
292
293 StmtKind::Interface(i) => {
295 let start_line = view.position_of(stmt.span.start).line;
296 let ns = cur_ns.as_deref();
297
298 let i_name = i.name.or_error();
299 let mut iface_def = ClassDef {
300 name: Box::from(i_name),
301 fqn: fqn(ns, i_name),
302 kind: ClassKind::Interface,
303 is_abstract: true,
304 parent: None,
305 implements: i
306 .extends
307 .iter()
308 .map(|e| Arc::from(e.to_string_repr().as_ref()))
309 .collect(),
310 traits: Vec::new(),
311 methods: Vec::new(),
312 properties: Vec::new(),
313 constants: Vec::new(),
314 cases: Vec::new(),
315 start_line,
316 name_char: name_char(i_name),
317 };
318
319 for member in i.body.members.iter() {
320 match &member.kind {
321 ClassMemberKind::Method(m) => {
322 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
323 let mstart = view.position_of(member.span.start).line;
324 let m_name = m.name.or_error();
325 iface_def.methods.push(MethodDef {
326 name: Box::from(m_name),
327 is_static: m.is_static,
328 is_abstract: true,
329 visibility: Visibility::Public,
330 params: extract_params(&m.params),
331 return_type: m
332 .return_type
333 .as_ref()
334 .map(|t| format_type_hint(t).into()),
335 doc: mdoc,
336 start_line: mstart,
337 name_char: name_char(m_name),
338 });
339 }
340 ClassMemberKind::ClassConst(cc) => {
341 iface_def.constants.push(Box::from(cc.name.or_error()));
342 }
343 _ => {}
344 }
345 }
346 index.classes.push(iface_def);
347 }
348
349 StmtKind::Trait(t) => {
351 let start_line = view.position_of(stmt.span.start).line;
352 let ns = cur_ns.as_deref();
353
354 let t_name = t.name.or_error();
355 let mut trait_def = ClassDef {
356 name: Box::from(t_name),
357 fqn: fqn(ns, t_name),
358 kind: ClassKind::Trait,
359 is_abstract: false,
360 parent: None,
361 implements: Vec::new(),
362 traits: Vec::new(),
363 methods: Vec::new(),
364 properties: Vec::new(),
365 constants: Vec::new(),
366 cases: Vec::new(),
367 start_line,
368 name_char: name_char(t_name),
369 };
370
371 for member in t.body.members.iter() {
372 match &member.kind {
373 ClassMemberKind::Method(m) => {
374 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
375 let mstart = view.position_of(member.span.start).line;
376 let vis = method_visibility(m.visibility);
377 let m_name = m.name.or_error();
378 trait_def.methods.push(MethodDef {
379 name: Box::from(m_name),
380 is_static: m.is_static,
381 is_abstract: m.is_abstract,
382 visibility: vis,
383 params: extract_params(&m.params),
384 return_type: m
385 .return_type
386 .as_ref()
387 .map(|t| format_type_hint(t).into()),
388 doc: mdoc,
389 start_line: mstart,
390 name_char: name_char(m_name),
391 });
392 }
393 ClassMemberKind::Property(p) => {
394 let vis = method_visibility(p.visibility);
395 let pstart = view.position_of(member.span.start).line;
396 let p_name = p.name.or_error();
397 trait_def.properties.push(PropertyDef {
398 name: Box::from(p_name),
399 is_static: p.is_static,
400 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
401 visibility: vis,
402 start_line: pstart,
403 name_char: name_char(p_name),
404 });
405 }
406 ClassMemberKind::ClassConst(cc) => {
407 trait_def.constants.push(Box::from(cc.name.or_error()));
408 }
409 ClassMemberKind::TraitUse(tu) => {
410 for tr in tu.traits.iter() {
411 trait_def
412 .traits
413 .push(Arc::from(tr.to_string_repr().as_ref()));
414 }
415 }
416 }
417 }
418 index.classes.push(trait_def);
419 }
420
421 StmtKind::Enum(e) => {
423 let start_line = view.position_of(stmt.span.start).line;
424 let ns = cur_ns.as_deref();
425
426 let e_name = e.name.or_error();
427 let mut enum_def = ClassDef {
428 name: Box::from(e_name),
429 fqn: fqn(ns, e_name),
430 kind: ClassKind::Enum,
431 is_abstract: false,
432 parent: None,
433 implements: e
434 .implements
435 .iter()
436 .map(|i| Arc::from(i.to_string_repr().as_ref()))
437 .collect(),
438 traits: Vec::new(),
439 methods: Vec::new(),
440 properties: Vec::new(),
441 constants: Vec::new(),
442 cases: Vec::new(),
443 start_line,
444 name_char: name_char(e_name),
445 };
446
447 for member in e.body.members.iter() {
448 match &member.kind {
449 EnumMemberKind::Case(c) => {
450 enum_def.cases.push(Box::from(c.name.or_error()));
451 }
452 EnumMemberKind::Method(m) => {
453 let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
454 let mstart = view.position_of(member.span.start).line;
455 let vis = method_visibility(m.visibility);
456 let m_name = m.name.or_error();
457 enum_def.methods.push(MethodDef {
458 name: Box::from(m_name),
459 is_static: m.is_static,
460 is_abstract: m.is_abstract,
461 visibility: vis,
462 params: extract_params(&m.params),
463 return_type: m
464 .return_type
465 .as_ref()
466 .map(|t| format_type_hint(t).into()),
467 doc: mdoc,
468 start_line: mstart,
469 name_char: name_char(m_name),
470 });
471 }
472 EnumMemberKind::ClassConst(cc) => {
473 enum_def.constants.push(Box::from(cc.name.or_error()));
474 }
475 _ => {}
476 }
477 }
478 index.classes.push(enum_def);
479 }
480
481 StmtKind::Const(consts) => {
483 for c in consts.iter() {
484 index.constants.push(Box::from(c.name.or_error()));
485 }
486 }
487
488 _ => {}
489 }
490 }
491}
492
493fn extract_params<'a, 'b>(params: &[php_ast::Param<'a, 'b>]) -> Vec<ParamDef> {
494 params
495 .iter()
496 .map(|p| ParamDef {
497 name: Box::from(p.name.or_error()),
498 type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
499 has_default: p.default.is_some(),
500 variadic: p.variadic,
501 })
502 .collect()
503}
504
505fn method_visibility(vis: Option<php_ast::Visibility>) -> Visibility {
506 match vis {
507 Some(php_ast::Visibility::Protected) => Visibility::Protected,
508 Some(php_ast::Visibility::Private) => Visibility::Private,
509 _ => Visibility::Public,
510 }
511}
512
513#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
520 fn extracts_class_and_method() {
521 let src = "<?php\nclass Greeter {\n public function greet(string $name): string {}\n}";
522 let doc = ParsedDoc::parse(src.to_string());
523 let idx = FileIndex::extract(&doc);
524 assert_eq!(idx.classes.len(), 1);
525 let cls = &idx.classes[0];
526 assert_eq!(cls.name, "Greeter".into());
527 assert_eq!(cls.kind, ClassKind::Class);
528 assert_eq!(cls.start_line, 1);
529 assert_eq!(cls.methods.len(), 1);
530 let method = &cls.methods[0];
531 assert_eq!(method.name, "greet".into());
532 assert_eq!(method.return_type.as_deref(), Some("string"));
533 assert_eq!(method.params.len(), 1);
534 assert_eq!(method.params[0].name, "name".into());
535 assert_eq!(method.params[0].type_hint.as_deref(), Some("string"));
536 }
537
538 #[test]
539 fn extracts_function() {
540 let src = "<?php\nfunction add(int $a, int $b): int {}";
541 let doc = ParsedDoc::parse(src.to_string());
542 let idx = FileIndex::extract(&doc);
543 assert_eq!(idx.functions.len(), 1);
544 let f = &idx.functions[0];
545 assert_eq!(f.name, "add".into());
546 assert_eq!(f.return_type.as_deref(), Some("int"));
547 assert_eq!(f.params.len(), 2);
548 }
549
550 #[test]
551 fn extracts_namespace() {
552 let src = "<?php\nnamespace App\\Services;\nclass Mailer {}";
553 let doc = ParsedDoc::parse(src.to_string());
554 let idx = FileIndex::extract(&doc);
555 assert_eq!(idx.namespace.as_deref(), Some("App\\Services"));
556 assert_eq!(idx.classes[0].fqn, "App\\Services\\Mailer".into());
557 }
558
559 #[test]
560 fn extracts_braced_namespace() {
561 let src = "<?php\nnamespace App\\Models {\n class User {}\n}";
562 let doc = ParsedDoc::parse(src.to_string());
563 let idx = FileIndex::extract(&doc);
564 assert_eq!(idx.namespace.as_deref(), Some("App\\Models"));
565 assert_eq!(idx.classes[0].fqn, "App\\Models\\User".into());
566 }
567
568 #[test]
569 fn extracts_interface() {
570 let src = "<?php\ninterface Countable {\n public function count(): int;\n}";
571 let doc = ParsedDoc::parse(src.to_string());
572 let idx = FileIndex::extract(&doc);
573 assert_eq!(idx.classes.len(), 1);
574 assert_eq!(idx.classes[0].kind, ClassKind::Interface);
575 assert_eq!(idx.classes[0].methods[0].name, "count".into());
576 assert!(idx.classes[0].methods[0].is_abstract);
577 }
578
579 #[test]
580 fn extracts_trait() {
581 let src = "<?php\ntrait Loggable {\n public function log(): void {}\n}";
582 let doc = ParsedDoc::parse(src.to_string());
583 let idx = FileIndex::extract(&doc);
584 assert_eq!(idx.classes[0].kind, ClassKind::Trait);
585 assert_eq!(idx.classes[0].methods[0].name, "log".into());
586 }
587
588 #[test]
589 fn extracts_enum_cases() {
590 let src = "<?php\nenum Status { case Active; case Inactive; }";
591 let doc = ParsedDoc::parse(src.to_string());
592 let idx = FileIndex::extract(&doc);
593 assert_eq!(idx.classes[0].kind, ClassKind::Enum);
594 assert!(idx.classes[0].cases.iter().any(|c| c.as_ref() == "Active"));
595 assert!(
596 idx.classes[0]
597 .cases
598 .iter()
599 .any(|c| c.as_ref() == "Inactive")
600 );
601 }
602
603 #[test]
604 fn extracts_class_properties_and_constants() {
605 let src = "<?php\nclass Config {\n public string $host;\n const VERSION = '1.0';\n}";
606 let doc = ParsedDoc::parse(src.to_string());
607 let idx = FileIndex::extract(&doc);
608 let cls = &idx.classes[0];
609 assert_eq!(cls.properties.len(), 1);
610 assert_eq!(cls.properties[0].name, "host".into());
611 assert!(cls.constants.iter().any(|c| c.as_ref() == "VERSION"));
612 }
613
614 #[test]
615 fn extracts_trait_use() {
616 let src = "<?php\ntrait T {}\nclass MyClass { use T; }";
617 let doc = ParsedDoc::parse(src.to_string());
618 let idx = FileIndex::extract(&doc);
619 let cls = idx
620 .classes
621 .iter()
622 .find(|c| c.name.as_ref() == "MyClass")
623 .unwrap();
624 assert!(cls.traits.iter().any(|t| t.as_ref() == "T"));
625 }
626
627 #[test]
628 fn extracts_class_implements_and_extends() {
629 let src = "<?php\nclass Dog extends Animal implements Pet, Movable {}";
630 let doc = ParsedDoc::parse(src.to_string());
631 let idx = FileIndex::extract(&doc);
632 let cls = &idx.classes[0];
633 assert_eq!(cls.parent.as_deref(), Some("Animal"));
634 assert!(cls.implements.iter().any(|i| i.as_ref() == "Pet"));
635 assert!(cls.implements.iter().any(|i| i.as_ref() == "Movable"));
636 }
637
638 #[test]
639 fn constructor_promoted_params_become_properties() {
640 let src = "<?php\nclass User {\n public function __construct(public string $name) {}\n}";
641 let doc = ParsedDoc::parse(src.to_string());
642 let idx = FileIndex::extract(&doc);
643 let cls = &idx.classes[0];
644 assert!(
646 cls.properties.iter().any(|p| p.name.as_ref() == "name"),
647 "expected promoted property 'name', got: {:?}",
648 cls.properties.iter().map(|p| &p.name).collect::<Vec<_>>()
649 );
650 }
651}