1use anyhow::{Context, Result};
16use streaming_iterator::StreamingIterator;
17use tree_sitter::{Parser, Query, QueryCursor};
18use std::path::{Path, PathBuf};
19use crate::models::{Language, SearchResult, Span, SymbolKind};
20
21pub fn parse(path: &str, source: &str) -> Result<Vec<SearchResult>> {
23 let mut parser = Parser::new();
24 let language = tree_sitter_php::LANGUAGE_PHP;
25
26 parser
27 .set_language(&language.into())
28 .context("Failed to set PHP language")?;
29
30 let tree = parser
31 .parse(source, None)
32 .context("Failed to parse PHP source")?;
33
34 let root_node = tree.root_node();
35
36 let mut symbols = Vec::new();
37
38 symbols.extend(extract_functions(source, &root_node, &language.into())?);
40 symbols.extend(extract_classes(source, &root_node, &language.into())?);
41 symbols.extend(extract_interfaces(source, &root_node, &language.into())?);
42 symbols.extend(extract_traits(source, &root_node, &language.into())?);
43 symbols.extend(extract_attributes(source, &root_node, &language.into())?);
44 symbols.extend(extract_methods(source, &root_node, &language.into())?);
45 symbols.extend(extract_properties(source, &root_node, &language.into())?);
46 symbols.extend(extract_local_variables(source, &root_node, &language.into())?);
47 symbols.extend(extract_constants(source, &root_node, &language.into())?);
48 symbols.extend(extract_namespaces(source, &root_node, &language.into())?);
49 symbols.extend(extract_enums(source, &root_node, &language.into())?);
50
51 for symbol in &mut symbols {
53 symbol.path = path.to_string();
54 symbol.lang = Language::PHP;
55 }
56
57 Ok(symbols)
58}
59
60fn extract_functions(
62 source: &str,
63 root: &tree_sitter::Node,
64 language: &tree_sitter::Language,
65) -> Result<Vec<SearchResult>> {
66 let query_str = r#"
67 (function_definition
68 name: (name) @name) @function
69 "#;
70
71 let query = Query::new(language, query_str)
72 .context("Failed to create function query")?;
73
74 extract_symbols(source, root, &query, SymbolKind::Function, None)
75}
76
77fn extract_classes(
79 source: &str,
80 root: &tree_sitter::Node,
81 language: &tree_sitter::Language,
82) -> Result<Vec<SearchResult>> {
83 let query_str = r#"
84 (class_declaration
85 name: (name) @name) @class
86 "#;
87
88 let query = Query::new(language, query_str)
89 .context("Failed to create class query")?;
90
91 extract_symbols(source, root, &query, SymbolKind::Class, None)
92}
93
94fn extract_interfaces(
96 source: &str,
97 root: &tree_sitter::Node,
98 language: &tree_sitter::Language,
99) -> Result<Vec<SearchResult>> {
100 let query_str = r#"
101 (interface_declaration
102 name: (name) @name) @interface
103 "#;
104
105 let query = Query::new(language, query_str)
106 .context("Failed to create interface query")?;
107
108 extract_symbols(source, root, &query, SymbolKind::Interface, None)
109}
110
111fn extract_traits(
113 source: &str,
114 root: &tree_sitter::Node,
115 language: &tree_sitter::Language,
116) -> Result<Vec<SearchResult>> {
117 let query_str = r#"
118 (trait_declaration
119 name: (name) @name) @trait
120 "#;
121
122 let query = Query::new(language, query_str)
123 .context("Failed to create trait query")?;
124
125 extract_symbols(source, root, &query, SymbolKind::Trait, None)
126}
127
128fn extract_attributes(
132 source: &str,
133 root: &tree_sitter::Node,
134 language: &tree_sitter::Language,
135) -> Result<Vec<SearchResult>> {
136 let mut symbols = Vec::new();
137
138 let def_query_str = r#"
140 (class_declaration
141 (attribute_list)
142 name: (name) @name) @attribute_class
143 "#;
144
145 let def_query = Query::new(language, def_query_str)
146 .context("Failed to create attribute definition query")?;
147
148 let mut cursor = QueryCursor::new();
149 let mut matches = cursor.matches(&def_query, *root, source.as_bytes());
150
151 while let Some(match_) = matches.next() {
152 let mut name = None;
153 let mut class_node = None;
154
155 for capture in match_.captures {
156 let capture_name: &str = &def_query.capture_names()[capture.index as usize];
157 match capture_name {
158 "name" => {
159 name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
160 }
161 "attribute_class" => {
162 class_node = Some(capture.node);
163 }
164 _ => {}
165 }
166 }
167
168 if let (Some(name), Some(node)) = (name, class_node) {
170 let class_text = node.utf8_text(source.as_bytes()).unwrap_or("");
171
172 if class_text.contains("#[Attribute") {
174 let span = node_to_span(&node);
175 let preview = extract_preview(source, &span);
176
177 symbols.push(SearchResult::new(
178 String::new(),
179 Language::PHP,
180 SymbolKind::Attribute,
181 Some(name),
182 span,
183 None,
184 preview,
185 ));
186 }
187 }
188 }
189
190 let use_query_str = r#"
192 (attribute_list
193 (attribute_group
194 (attribute
195 (name) @name))) @attr
196 "#;
197
198 let use_query = Query::new(language, use_query_str)
199 .context("Failed to create attribute use query")?;
200
201 symbols.extend(extract_symbols(source, root, &use_query, SymbolKind::Attribute, None)?);
202
203 Ok(symbols)
204}
205
206fn extract_methods(
208 source: &str,
209 root: &tree_sitter::Node,
210 language: &tree_sitter::Language,
211) -> Result<Vec<SearchResult>> {
212 let query_str = r#"
213 (class_declaration
214 name: (name) @class_name
215 body: (declaration_list
216 (method_declaration
217 name: (name) @method_name))) @class
218
219 (trait_declaration
220 name: (name) @trait_name
221 body: (declaration_list
222 (method_declaration
223 name: (name) @method_name))) @trait
224
225 (interface_declaration
226 name: (name) @interface_name
227 body: (declaration_list
228 (method_declaration
229 name: (name) @method_name))) @interface
230 "#;
231
232 let query = Query::new(language, query_str)
233 .context("Failed to create method query")?;
234
235 let mut cursor = QueryCursor::new();
236 let mut matches = cursor.matches(&query, *root, source.as_bytes());
237
238 let mut symbols = Vec::new();
239
240 while let Some(match_) = matches.next() {
241 let mut scope_name = None;
242 let mut scope_type = None;
243 let mut method_name = None;
244 let mut method_node = None;
245
246 for capture in match_.captures {
247 let capture_name: &str = &query.capture_names()[capture.index as usize];
248 match capture_name {
249 "class_name" => {
250 scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
251 scope_type = Some("class");
252 }
253 "trait_name" => {
254 scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
255 scope_type = Some("trait");
256 }
257 "interface_name" => {
258 scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
259 scope_type = Some("interface");
260 }
261 "method_name" => {
262 method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
263 let mut current = capture.node;
265 while let Some(parent) = current.parent() {
266 if parent.kind() == "method_declaration" {
267 method_node = Some(parent);
268 break;
269 }
270 current = parent;
271 }
272 }
273 _ => {}
274 }
275 }
276
277 if let (Some(scope_name), Some(scope_type), Some(method_name), Some(node)) =
278 (scope_name, scope_type, method_name, method_node) {
279 let scope = format!("{} {}", scope_type, scope_name);
280 let span = node_to_span(&node);
281 let preview = extract_preview(source, &span);
282
283 symbols.push(SearchResult::new(
284 String::new(),
285 Language::PHP,
286 SymbolKind::Method,
287 Some(method_name),
288 span,
289 Some(scope),
290 preview,
291 ));
292 }
293 }
294
295 Ok(symbols)
296}
297
298fn extract_properties(
300 source: &str,
301 root: &tree_sitter::Node,
302 language: &tree_sitter::Language,
303) -> Result<Vec<SearchResult>> {
304 let query_str = r#"
305 (class_declaration
306 name: (name) @class_name
307 body: (declaration_list
308 (property_declaration
309 (property_element
310 (variable_name
311 (name) @prop_name))))) @class
312
313 (trait_declaration
314 name: (name) @trait_name
315 body: (declaration_list
316 (property_declaration
317 (property_element
318 (variable_name
319 (name) @prop_name))))) @trait
320 "#;
321
322 let query = Query::new(language, query_str)
323 .context("Failed to create property query")?;
324
325 let mut cursor = QueryCursor::new();
326 let mut matches = cursor.matches(&query, *root, source.as_bytes());
327
328 let mut symbols = Vec::new();
329
330 while let Some(match_) = matches.next() {
331 let mut scope_name = None;
332 let mut scope_type = None;
333 let mut prop_name = None;
334 let mut prop_node = None;
335
336 for capture in match_.captures {
337 let capture_name: &str = &query.capture_names()[capture.index as usize];
338 match capture_name {
339 "class_name" => {
340 scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
341 scope_type = Some("class");
342 }
343 "trait_name" => {
344 scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
345 scope_type = Some("trait");
346 }
347 "prop_name" => {
348 prop_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
349 let mut current = capture.node;
351 while let Some(parent) = current.parent() {
352 if parent.kind() == "property_declaration" {
353 prop_node = Some(parent);
354 break;
355 }
356 current = parent;
357 }
358 }
359 _ => {}
360 }
361 }
362
363 if let (Some(scope_name), Some(scope_type), Some(prop_name), Some(node)) =
364 (scope_name, scope_type, prop_name, prop_node) {
365 let scope = format!("{} {}", scope_type, scope_name);
366 let span = node_to_span(&node);
367 let preview = extract_preview(source, &span);
368
369 symbols.push(SearchResult::new(
370 String::new(),
371 Language::PHP,
372 SymbolKind::Variable,
373 Some(prop_name),
374 span,
375 Some(scope),
376 preview,
377 ));
378 }
379 }
380
381 Ok(symbols)
382}
383
384fn extract_local_variables(
386 source: &str,
387 root: &tree_sitter::Node,
388 language: &tree_sitter::Language,
389) -> Result<Vec<SearchResult>> {
390 let query_str = r#"
391 (assignment_expression
392 left: (variable_name
393 (name) @name)) @assignment
394 "#;
395
396 let query = Query::new(language, query_str)
397 .context("Failed to create local variable query")?;
398
399 let mut cursor = QueryCursor::new();
400 let mut matches = cursor.matches(&query, *root, source.as_bytes());
401
402 let mut symbols = Vec::new();
403
404 while let Some(match_) = matches.next() {
405 let mut name = None;
406 let mut assignment_node = None;
407
408 for capture in match_.captures {
409 let capture_name: &str = &query.capture_names()[capture.index as usize];
410 match capture_name {
411 "name" => {
412 name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
413 }
414 "assignment" => {
415 assignment_node = Some(capture.node);
416 }
417 _ => {}
418 }
419 }
420
421 if let (Some(name), Some(node)) = (name, assignment_node) {
425 let span = node_to_span(&node);
426 let preview = extract_preview(source, &span);
427
428 symbols.push(SearchResult::new(
429 String::new(),
430 Language::PHP,
431 SymbolKind::Variable,
432 Some(name),
433 span,
434 None, preview,
436 ));
437 }
438 }
439
440 Ok(symbols)
441}
442
443fn extract_constants(
445 source: &str,
446 root: &tree_sitter::Node,
447 language: &tree_sitter::Language,
448) -> Result<Vec<SearchResult>> {
449 let query_str = r#"
450 (const_declaration
451 (const_element
452 (name) @name)) @const
453 "#;
454
455 let query = Query::new(language, query_str)
456 .context("Failed to create constant query")?;
457
458 extract_symbols(source, root, &query, SymbolKind::Constant, None)
459}
460
461fn extract_namespaces(
463 source: &str,
464 root: &tree_sitter::Node,
465 language: &tree_sitter::Language,
466) -> Result<Vec<SearchResult>> {
467 let query_str = r#"
468 (namespace_definition
469 name: (namespace_name) @name) @namespace
470 "#;
471
472 let query = Query::new(language, query_str)
473 .context("Failed to create namespace query")?;
474
475 extract_symbols(source, root, &query, SymbolKind::Namespace, None)
476}
477
478fn extract_enums(
480 source: &str,
481 root: &tree_sitter::Node,
482 language: &tree_sitter::Language,
483) -> Result<Vec<SearchResult>> {
484 let query_str = r#"
485 (enum_declaration
486 name: (name) @name) @enum
487 "#;
488
489 let query = Query::new(language, query_str)
490 .context("Failed to create enum query")?;
491
492 extract_symbols(source, root, &query, SymbolKind::Enum, None)
493}
494
495fn extract_symbols(
497 source: &str,
498 root: &tree_sitter::Node,
499 query: &Query,
500 kind: SymbolKind,
501 scope: Option<String>,
502) -> Result<Vec<SearchResult>> {
503 let mut cursor = QueryCursor::new();
504 let mut matches = cursor.matches(query, *root, source.as_bytes());
505
506 let mut symbols = Vec::new();
507
508 while let Some(match_) = matches.next() {
509 let mut name = None;
511 let mut full_node = None;
512
513 for capture in match_.captures {
514 let capture_name: &str = &query.capture_names()[capture.index as usize];
515 if capture_name == "name" {
516 name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
517 } else {
518 full_node = Some(capture.node);
520 }
521 }
522
523 match (name, full_node) {
524 (Some(name), Some(node)) => {
525 let span = node_to_span(&node);
526 let preview = extract_preview(source, &span);
527
528 symbols.push(SearchResult::new(
529 String::new(),
530 Language::PHP,
531 kind.clone(),
532 Some(name),
533 span,
534 scope.clone(),
535 preview,
536 ));
537 }
538 (None, Some(node)) => {
539 log::warn!("PHP parser: Failed to extract name from {:?} capture at line {}",
540 kind,
541 node.start_position().row + 1);
542 }
543 (Some(_), None) => {
544 log::warn!("PHP parser: Failed to extract node for {:?} symbol", kind);
545 }
546 (None, None) => {
547 log::warn!("PHP parser: Failed to extract both name and node for {:?} symbol", kind);
548 }
549 }
550 }
551
552 Ok(symbols)
553}
554
555fn node_to_span(node: &tree_sitter::Node) -> Span {
557 let start = node.start_position();
558 let end = node.end_position();
559
560 Span::new(
561 start.row + 1, start.column,
563 end.row + 1,
564 end.column,
565 )
566}
567
568fn extract_preview(source: &str, span: &Span) -> String {
570 let lines: Vec<&str> = source.lines().collect();
571
572 let start_idx = (span.start_line - 1) as usize; let end_idx = (start_idx + 7).min(lines.len());
575
576 lines[start_idx..end_idx].join("\n")
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_parse_function() {
585 let source = r#"
586 <?php
587 function greet($name) {
588 return "Hello, $name!";
589 }
590 "#;
591
592 let symbols = parse("test.php", source).unwrap();
593 assert_eq!(symbols.len(), 1);
594 assert_eq!(symbols[0].symbol.as_deref(), Some("greet"));
595 assert!(matches!(symbols[0].kind, SymbolKind::Function));
596 }
597
598 #[test]
599 fn test_parse_class() {
600 let source = r#"
601 <?php
602 class User {
603 private $name;
604 private $email;
605
606 public function __construct($name, $email) {
607 $this->name = $name;
608 $this->email = $email;
609 }
610 }
611 "#;
612
613 let symbols = parse("test.php", source).unwrap();
614
615 let class_symbols: Vec<_> = symbols.iter()
617 .filter(|s| matches!(s.kind, SymbolKind::Class))
618 .collect();
619
620 assert_eq!(class_symbols.len(), 1);
621 assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
622 }
623
624 #[test]
625 fn test_parse_class_with_methods() {
626 let source = r#"
627 <?php
628 class Calculator {
629 public function add($a, $b) {
630 return $a + $b;
631 }
632
633 public function subtract($a, $b) {
634 return $a - $b;
635 }
636 }
637 "#;
638
639 let symbols = parse("test.php", source).unwrap();
640
641 assert!(symbols.len() >= 3);
643
644 let method_symbols: Vec<_> = symbols.iter()
645 .filter(|s| matches!(s.kind, SymbolKind::Method))
646 .collect();
647
648 assert_eq!(method_symbols.len(), 2);
649 assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("add")));
650 assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("subtract")));
651
652 for method in method_symbols {
654 }
656 }
657
658 #[test]
659 fn test_parse_interface() {
660 let source = r#"
661 <?php
662 interface Drawable {
663 public function draw();
664 }
665 "#;
666
667 let symbols = parse("test.php", source).unwrap();
668
669 let interface_symbols: Vec<_> = symbols.iter()
670 .filter(|s| matches!(s.kind, SymbolKind::Interface))
671 .collect();
672
673 assert_eq!(interface_symbols.len(), 1);
674 assert_eq!(interface_symbols[0].symbol.as_deref(), Some("Drawable"));
675 }
676
677 #[test]
678 fn test_parse_trait() {
679 let source = r#"
680 <?php
681 trait Loggable {
682 public function log($message) {
683 echo $message;
684 }
685 }
686 "#;
687
688 let symbols = parse("test.php", source).unwrap();
689
690 let trait_symbols: Vec<_> = symbols.iter()
691 .filter(|s| matches!(s.kind, SymbolKind::Trait))
692 .collect();
693
694 assert_eq!(trait_symbols.len(), 1);
695 assert_eq!(trait_symbols[0].symbol.as_deref(), Some("Loggable"));
696 }
697
698 #[test]
699 fn test_parse_namespace() {
700 let source = r#"
701 <?php
702 namespace App\Controllers;
703
704 class HomeController {
705 public function index() {
706 return 'Home';
707 }
708 }
709 "#;
710
711 let symbols = parse("test.php", source).unwrap();
712
713 let namespace_symbols: Vec<_> = symbols.iter()
714 .filter(|s| matches!(s.kind, SymbolKind::Namespace))
715 .collect();
716
717 assert_eq!(namespace_symbols.len(), 1);
718 assert_eq!(namespace_symbols[0].symbol.as_deref(), Some("App\\Controllers"));
719 }
720
721 #[test]
722 fn test_parse_constants() {
723 let source = r#"
724 <?php
725 const MAX_SIZE = 100;
726 const DEFAULT_NAME = 'Anonymous';
727 "#;
728
729 let symbols = parse("test.php", source).unwrap();
730
731 let const_symbols: Vec<_> = symbols.iter()
732 .filter(|s| matches!(s.kind, SymbolKind::Constant))
733 .collect();
734
735 assert_eq!(const_symbols.len(), 2);
736 assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("MAX_SIZE")));
737 assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("DEFAULT_NAME")));
738 }
739
740 #[test]
741 fn test_parse_properties() {
742 let source = r#"
743 <?php
744 class Config {
745 private $debug = false;
746 public $timeout = 30;
747 protected $secret;
748 }
749 "#;
750
751 let symbols = parse("test.php", source).unwrap();
752
753 let prop_symbols: Vec<_> = symbols.iter()
754 .filter(|s| matches!(s.kind, SymbolKind::Variable))
755 .collect();
756
757 assert_eq!(prop_symbols.len(), 3);
758 assert!(prop_symbols.iter().any(|s| s.symbol.as_deref() == Some("debug")));
759 assert!(prop_symbols.iter().any(|s| s.symbol.as_deref() == Some("timeout")));
760 assert!(prop_symbols.iter().any(|s| s.symbol.as_deref() == Some("secret")));
761 }
762
763 #[test]
764 fn test_parse_enum() {
765 let source = r#"
766 <?php
767 enum Status {
768 case Active;
769 case Inactive;
770 case Pending;
771 }
772 "#;
773
774 let symbols = parse("test.php", source).unwrap();
775
776 let enum_symbols: Vec<_> = symbols.iter()
777 .filter(|s| matches!(s.kind, SymbolKind::Enum))
778 .collect();
779
780 assert_eq!(enum_symbols.len(), 1);
781 assert_eq!(enum_symbols[0].symbol.as_deref(), Some("Status"));
782 }
783
784 #[test]
785 fn test_parse_mixed_symbols() {
786 let source = r#"
787 <?php
788 namespace App\Models;
789
790 interface UserInterface {
791 public function getName();
792 }
793
794 trait Timestampable {
795 private $createdAt;
796
797 public function getCreatedAt() {
798 return $this->createdAt;
799 }
800 }
801
802 class User implements UserInterface {
803 use Timestampable;
804
805 private $name;
806 const DEFAULT_ROLE = 'user';
807
808 public function __construct($name) {
809 $this->name = $name;
810 }
811
812 public function getName() {
813 return $this->name;
814 }
815 }
816
817 function createUser($name) {
818 return new User($name);
819 }
820 "#;
821
822 let symbols = parse("test.php", source).unwrap();
823
824 assert!(symbols.len() >= 8);
826
827 let kinds: Vec<&SymbolKind> = symbols.iter().map(|s| &s.kind).collect();
828 assert!(kinds.contains(&&SymbolKind::Namespace));
829 assert!(kinds.contains(&&SymbolKind::Interface));
830 assert!(kinds.contains(&&SymbolKind::Trait));
831 assert!(kinds.contains(&&SymbolKind::Class));
832 assert!(kinds.contains(&&SymbolKind::Method));
833 assert!(kinds.contains(&&SymbolKind::Variable));
834 assert!(kinds.contains(&&SymbolKind::Constant));
835 assert!(kinds.contains(&&SymbolKind::Function));
836 }
837
838 #[test]
839 fn test_local_variables_included() {
840 let source = r#"
841 <?php
842 $global_count = 100;
843
844 function calculate() {
845 $local_count = 50;
846 $result = $local_count + 10;
847 return $result;
848 }
849
850 class Math {
851 private $value = 5;
852
853 public function compute() {
854 $temp = $this->value * 2;
855 return $temp;
856 }
857 }
858 "#;
859
860 let symbols = parse("test.php", source).unwrap();
861
862 let variables: Vec<_> = symbols.iter()
864 .filter(|s| matches!(s.kind, SymbolKind::Variable))
865 .collect();
866
867 assert_eq!(variables.len(), 5);
869
870 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("local_count")));
872 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("result")));
873 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("temp")));
874
875 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("global_count")));
877
878 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("value")));
880
881 let local_vars: Vec<_> = variables.iter()
883 .filter(|v| v.symbol.as_deref() == Some("local_count")
884 || v.symbol.as_deref() == Some("result")
885 || v.symbol.as_deref() == Some("temp"))
886 .collect();
887
888 for var in local_vars {
889 }
891
892 let property = variables.iter()
894 .find(|v| v.symbol.as_deref() == Some("value"))
895 .unwrap();
896 }
898
899 #[test]
900 fn test_parse_attribute_class() {
901 let source = r#"
902 <?php
903 #[Attribute]
904 class Route {
905 public function __construct(
906 public string $path,
907 public array $methods = []
908 ) {}
909 }
910
911 #[Attribute(Attribute::TARGET_METHOD)]
912 class Deprecated {
913 public string $message;
914 }
915 "#;
916
917 let symbols = parse("test.php", source).unwrap();
918
919 let attribute_symbols: Vec<_> = symbols.iter()
920 .filter(|s| matches!(s.kind, SymbolKind::Attribute))
921 .collect();
922
923 assert!(attribute_symbols.len() >= 2);
925 assert!(attribute_symbols.iter().any(|s| s.symbol.as_deref() == Some("Route")));
926 assert!(attribute_symbols.iter().any(|s| s.symbol.as_deref() == Some("Deprecated")));
927 }
928
929 #[test]
930 fn test_parse_attribute_uses() {
931 let source = r#"
932 <?php
933 #[Attribute]
934 class Route {
935 public function __construct(public string $path) {}
936 }
937
938 #[Attribute]
939 class Deprecated {}
940
941 #[Route("/api/users")]
942 class UserController {
943 #[Route("/list")]
944 public function list() {
945 return [];
946 }
947
948 #[Route("/get/{id}")]
949 #[Deprecated]
950 public function get($id) {
951 return null;
952 }
953 }
954
955 #[Route("/api/posts")]
956 class PostController {
957 #[Route("/all")]
958 public function all() {
959 return [];
960 }
961 }
962 "#;
963
964 let symbols = parse("test.php", source).unwrap();
965
966 let attribute_symbols: Vec<_> = symbols.iter()
967 .filter(|s| matches!(s.kind, SymbolKind::Attribute))
968 .collect();
969
970 assert!(attribute_symbols.len() >= 6);
974
975 let route_count = attribute_symbols.iter()
977 .filter(|s| s.symbol.as_deref() == Some("Route"))
978 .count();
979
980 let deprecated_count = attribute_symbols.iter()
981 .filter(|s| s.symbol.as_deref() == Some("Deprecated"))
982 .count();
983
984 assert!(route_count >= 5);
986
987 assert!(deprecated_count >= 2);
989 }
990
991 #[test]
992 fn test_parse_class_implementing_multiple_interfaces() {
993 let source = r#"
994 <?php
995 interface Interface1 {
996 public function method1();
997 }
998
999 interface Interface2 {
1000 public function method2();
1001 }
1002
1003 class SimpleClass {
1004 public $value;
1005 }
1006
1007 // Class implementing multiple interfaces
1008 class MultiInterfaceClass implements Interface1, Interface2 {
1009 public function method1() {
1010 return true;
1011 }
1012
1013 public function method2() {
1014 return false;
1015 }
1016 }
1017
1018 /**
1019 * Complex edge case: Class with large docblock, extends base class, implements multiple interfaces
1020 *
1021 * @property string $name
1022 * @property string $email
1023 * @property-read int $id
1024 * @property-read string $created_at
1025 * @property-read Collection|Role[] $roles
1026 * @property-read Collection|Permission[] $permissions
1027 * @property-read Workflow $workflow
1028 * @property-read Collection|NotificationSetting[] $notificationSettings
1029 * @property-read Collection|Watch[] $watches
1030 *
1031 **/
1032 class ComplexClass extends SimpleClass implements Interface1, Interface2 {
1033 private $data;
1034
1035 public function method1() {
1036 return $this->data;
1037 }
1038
1039 public function method2() {
1040 return !$this->data;
1041 }
1042 }
1043 "#;
1044
1045 let symbols = parse("test.php", source).unwrap();
1046
1047 let class_symbols: Vec<_> = symbols.iter()
1048 .filter(|s| matches!(s.kind, SymbolKind::Class))
1049 .collect();
1050
1051 assert_eq!(class_symbols.len(), 3, "Should find exactly 3 classes");
1056
1057 assert!(class_symbols.iter().any(|c| c.symbol.as_deref() == Some("SimpleClass")),
1058 "Should find SimpleClass");
1059 assert!(class_symbols.iter().any(|c| c.symbol.as_deref() == Some("MultiInterfaceClass")),
1060 "Should find MultiInterfaceClass implementing multiple interfaces");
1061 assert!(class_symbols.iter().any(|c| c.symbol.as_deref() == Some("ComplexClass")),
1062 "Should find ComplexClass with large docblock, extends, and implements multiple interfaces");
1063 }
1064
1065 #[test]
1066 fn test_extract_php_use_dependencies() {
1067 let source = r#"
1068 <?php
1069
1070 use Illuminate\Database\Migrations\Migration;
1071 use Illuminate\Database\Schema\Blueprint;
1072 use Illuminate\Support\Facades\Schema;
1073
1074 return new class extends Migration
1075 {
1076 public function up(): void
1077 {
1078 Schema::create('test', function (Blueprint $table) {
1079 $table->id();
1080 });
1081 }
1082 };
1083 "#;
1084
1085 let deps = PhpDependencyExtractor::extract_dependencies(source).unwrap();
1086
1087 assert_eq!(deps.len(), 3, "Should extract 3 use statements");
1089
1090 assert!(deps.iter().any(|d| d.imported_path.contains("Migration")));
1092 assert!(deps.iter().any(|d| d.imported_path.contains("Blueprint")));
1093 assert!(deps.iter().any(|d| d.imported_path.contains("Schema")));
1094
1095 for dep in &deps {
1097 assert!(matches!(dep.import_type, ImportType::Internal),
1098 "Laravel classes should be classified as Internal");
1099 }
1100 }
1101
1102 #[test]
1103 fn test_dynamic_requires_filtered() {
1104 let source = r#"
1105 <?php
1106 use App\Models\User;
1107 use App\Services\Auth;
1108 require 'config.php';
1109 require_once 'helpers.php';
1110
1111 // Dynamic requires - should be filtered out
1112 require $variable;
1113 require CONSTANT . '/file.php';
1114 require_once $path;
1115 include dirname(__FILE__) . '/dynamic.php';
1116 "#;
1117
1118 let deps = PhpDependencyExtractor::extract_dependencies(source).unwrap();
1119
1120 assert_eq!(deps.len(), 4, "Should extract 4 static imports only");
1123
1124 assert!(deps.iter().any(|d| d.imported_path.contains("User")));
1125 assert!(deps.iter().any(|d| d.imported_path.contains("Auth")));
1126 assert!(deps.iter().any(|d| d.imported_path == "config.php"));
1127 assert!(deps.iter().any(|d| d.imported_path == "helpers.php"));
1128
1129 assert!(!deps.iter().any(|d| d.imported_path.contains("variable")));
1131 assert!(!deps.iter().any(|d| d.imported_path.contains("CONSTANT")));
1132 assert!(!deps.iter().any(|d| d.imported_path.contains("dirname")));
1133 }
1134}
1135
1136use crate::models::ImportType;
1141use crate::parsers::{DependencyExtractor, ImportInfo};
1142
1143pub struct PhpDependencyExtractor;
1145
1146impl DependencyExtractor for PhpDependencyExtractor {
1147 fn extract_dependencies(source: &str) -> Result<Vec<ImportInfo>> {
1148 let mut parser = Parser::new();
1149 let language = tree_sitter_php::LANGUAGE_PHP;
1150
1151 parser
1152 .set_language(&language.into())
1153 .context("Failed to set PHP language")?;
1154
1155 let tree = parser
1156 .parse(source, None)
1157 .context("Failed to parse PHP source")?;
1158
1159 let root_node = tree.root_node();
1160
1161 let mut imports = Vec::new();
1162
1163 imports.extend(extract_php_uses(source, &root_node)?);
1165
1166 imports.extend(extract_php_requires(source, &root_node)?);
1168
1169 Ok(imports)
1170 }
1171}
1172
1173fn extract_php_uses(
1175 source: &str,
1176 root: &tree_sitter::Node,
1177) -> Result<Vec<ImportInfo>> {
1178 let language = tree_sitter_php::LANGUAGE_PHP;
1179
1180 let query_str = r#"
1181 (namespace_use_clause
1182 [
1183 (name) @use_path
1184 (qualified_name) @use_path
1185 ])
1186 "#;
1187
1188 let query = Query::new(&language.into(), query_str)
1189 .context("Failed to create PHP use query")?;
1190
1191 let mut cursor = QueryCursor::new();
1192 let mut matches = cursor.matches(&query, *root, source.as_bytes());
1193
1194 let mut imports = Vec::new();
1195
1196 while let Some(match_) = matches.next() {
1197 for capture in match_.captures {
1198 let capture_name: &str = &query.capture_names()[capture.index as usize];
1199 if capture_name == "use_path" {
1200 let path = capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string();
1201 let import_type = classify_php_use(&path);
1202 let line_number = capture.node.start_position().row + 1;
1203
1204 imports.push(ImportInfo {
1205 imported_path: path,
1206 import_type,
1207 line_number,
1208 imported_symbols: None, });
1210 }
1211 }
1212 }
1213
1214 Ok(imports)
1215}
1216
1217fn extract_php_requires(
1219 source: &str,
1220 root: &tree_sitter::Node,
1221) -> Result<Vec<ImportInfo>> {
1222 let language = tree_sitter_php::LANGUAGE_PHP;
1223
1224 let query_str = r#"
1226 (expression_statement
1227 (require_expression
1228 (string) @require_path)) @require
1229
1230 (expression_statement
1231 (require_once_expression
1232 (string) @require_path)) @require
1233
1234 (expression_statement
1235 (include_expression
1236 (string) @require_path)) @require
1237
1238 (expression_statement
1239 (include_once_expression
1240 (string) @require_path)) @require
1241 "#;
1242
1243 let query = Query::new(&language.into(), query_str)
1244 .context("Failed to create PHP require/include query")?;
1245
1246 let mut cursor = QueryCursor::new();
1247 let mut matches = cursor.matches(&query, *root, source.as_bytes());
1248
1249 let mut imports = Vec::new();
1250
1251 while let Some(match_) = matches.next() {
1252 let mut require_path = None;
1253 let mut require_node = None;
1254
1255 for capture in match_.captures {
1256 let capture_name: &str = &query.capture_names()[capture.index as usize];
1257 match capture_name {
1258 "require_path" => {
1259 let raw_path = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
1260 require_path = Some(raw_path.trim_matches(|c| c == '"' || c == '\'').to_string());
1262 }
1263 "require" => {
1264 require_node = Some(capture.node);
1265 }
1266 _ => {}
1267 }
1268 }
1269
1270 if let (Some(path), Some(node)) = (require_path, require_node) {
1271 let line_number = node.start_position().row + 1;
1273
1274 imports.push(ImportInfo {
1275 imported_path: path,
1276 import_type: ImportType::Internal, line_number,
1278 imported_symbols: None, });
1280 }
1281 }
1282
1283 Ok(imports)
1284}
1285
1286fn classify_php_use(use_path: &str) -> ImportType {
1288 const PHP_STDLIB_NAMESPACES: &[&str] = &[
1290 "Psr\\", "Psr\\Http", "Psr\\Log", "Psr\\Cache", "Psr\\Container",
1292
1293 "Exception", "Error", "DateTime", "DateTimeImmutable", "DateTimeInterface",
1295 "DateInterval", "DatePeriod", "PDO", "PDOStatement", "Closure",
1296 "Generator", "ArrayIterator", "IteratorAggregate", "Traversable",
1297 "Iterator", "Countable", "Serializable", "JsonSerializable",
1298
1299 "SplFileInfo", "SplFileObject", "SplDoublyLinkedList", "SplQueue",
1301 "SplStack", "SplHeap", "SplMinHeap", "SplMaxHeap", "SplPriorityQueue",
1302 "SplFixedArray", "SplObjectStorage",
1303
1304 "SimpleXMLElement", "DOMDocument", "DOMElement", "DOMNode",
1306 "XMLReader", "XMLWriter",
1307 ];
1308
1309 const PHP_VENDOR_NAMESPACES: &[&str] = &[
1311 "Symfony\\",
1313
1314 "Spatie\\", "Stancl\\", "Doctrine\\", "Monolog\\", "PHPUnit\\",
1316 "Carbon\\", "GuzzleHttp\\", "Composer\\", "Predis\\", "League\\",
1317 "Ramsey\\", "Webmozart\\", "Brick\\", "Mockery\\", "Faker\\",
1318 "PhpParser\\", "PHPStan\\", "Psalm\\", "Pest\\", "Filament\\",
1319 "Livewire\\", "Inertia\\", "Socialite\\", "Sanctum\\", "Passport\\",
1320 "Horizon\\", "Telescope\\", "Forge\\", "Vapor\\", "Cashier\\",
1321 "Nova\\", "Spark\\", "Jetstream\\", "Fortify\\", "Breeze\\",
1322 "Vonage\\", "Twilio\\", "Stripe\\", "Pusher\\", "Algolia\\",
1323 "Aws\\", "Google\\", "Microsoft\\", "Facebook\\", "Twitter\\",
1324 "Sentry\\", "Bugsnag\\", "Rollbar\\", "NewRelic\\", "Datadog\\",
1325 "Elasticsearch\\", "Redis\\", "Memcached\\", "MongoDB\\",
1326 "PhpOffice\\", "Dompdf\\", "TCPDF\\", "Mpdf\\", "Intervention\\",
1327 "Barryvdh\\", "Maatwebsite\\", "Rap2hpoutre\\", "Yajra\\",
1328 ];
1329
1330 for stdlib_ns in PHP_STDLIB_NAMESPACES {
1332 if use_path == *stdlib_ns || use_path.starts_with(stdlib_ns) {
1333 return ImportType::Stdlib;
1334 }
1335 }
1336
1337 for vendor_ns in PHP_VENDOR_NAMESPACES {
1339 if use_path.starts_with(vendor_ns) {
1340 return ImportType::External;
1341 }
1342 }
1343
1344 ImportType::Internal
1346}
1347
1348#[derive(Debug, Clone)]
1354pub struct Psr4Mapping {
1355 pub namespace_prefix: String, pub directory: String, pub project_root: String, }
1359
1360pub fn parse_composer_psr4(project_root: &Path) -> Result<Vec<Psr4Mapping>> {
1369 let composer_path = project_root.join("composer.json");
1370
1371 if !composer_path.exists() {
1373 log::debug!("No composer.json found at {:?}", composer_path);
1374 return Ok(Vec::new());
1375 }
1376
1377 let content = std::fs::read_to_string(&composer_path)
1378 .context("Failed to read composer.json")?;
1379
1380 let json: serde_json::Value = serde_json::from_str(&content)
1381 .context("Failed to parse composer.json")?;
1382
1383 let mut mappings = Vec::new();
1384
1385 if let Some(autoload) = json.get("autoload") {
1387 if let Some(psr4) = autoload.get("psr-4") {
1388 if let Some(psr4_obj) = psr4.as_object() {
1389 for (namespace, path) in psr4_obj {
1390 let directories = match path {
1392 serde_json::Value::String(s) => vec![s.clone()],
1393 serde_json::Value::Array(arr) => {
1394 arr.iter()
1395 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1396 .collect()
1397 }
1398 _ => continue,
1399 };
1400
1401 for dir in directories {
1402 mappings.push(Psr4Mapping {
1403 namespace_prefix: namespace.clone(),
1404 directory: dir,
1405 project_root: String::new(), });
1407 }
1408 }
1409 }
1410 }
1411 }
1412
1413 mappings.sort_by(|a, b| b.namespace_prefix.len().cmp(&a.namespace_prefix.len()));
1416
1417 log::debug!("Loaded {} PSR-4 mappings from composer.json", mappings.len());
1418 for mapping in &mappings {
1419 log::trace!(" {} => {}", mapping.namespace_prefix, mapping.directory);
1420 }
1421
1422 Ok(mappings)
1423}
1424
1425pub fn find_all_composer_json(index_root: &Path) -> Result<Vec<PathBuf>> {
1435 use ignore::WalkBuilder;
1436
1437 let mut composer_files = Vec::new();
1438
1439 let walker = WalkBuilder::new(index_root)
1440 .follow_links(false)
1441 .git_ignore(true)
1442 .build();
1443
1444 for entry in walker {
1445 let entry = entry?;
1446 let path = entry.path();
1447
1448 if !path.is_file() || path.file_name() != Some(std::ffi::OsStr::new("composer.json")) {
1450 continue;
1451 }
1452
1453 if path.components().any(|c| c.as_os_str() == "vendor") {
1455 log::trace!("Skipping vendor composer.json: {:?}", path);
1456 continue;
1457 }
1458
1459 composer_files.push(path.to_path_buf());
1460 }
1461
1462 log::debug!("Found {} project composer.json files", composer_files.len());
1463 Ok(composer_files)
1464}
1465
1466pub fn parse_all_composer_psr4(index_root: &Path) -> Result<Vec<Psr4Mapping>> {
1476 let composer_files = find_all_composer_json(index_root)?;
1477
1478 if composer_files.is_empty() {
1479 log::debug!("No composer.json files found in {:?}", index_root);
1480 return Ok(Vec::new());
1481 }
1482
1483 let mut all_mappings = Vec::new();
1484 let composer_count = composer_files.len(); for composer_path in composer_files {
1487 let project_root = composer_path
1488 .parent()
1489 .ok_or_else(|| anyhow::anyhow!("composer.json has no parent directory"))?;
1490
1491 let relative_project_root = project_root
1493 .strip_prefix(index_root)
1494 .unwrap_or(project_root)
1495 .to_string_lossy()
1496 .to_string();
1497
1498 log::debug!("Parsing composer.json at {:?}", composer_path);
1499
1500 let mappings = parse_composer_psr4(project_root)?;
1502
1503 for mut mapping in mappings {
1505 mapping.project_root = relative_project_root.clone();
1506 all_mappings.push(mapping);
1507 }
1508 }
1509
1510 all_mappings.sort_by(|a, b| b.namespace_prefix.len().cmp(&a.namespace_prefix.len()));
1512
1513 log::info!("Loaded {} total PSR-4 mappings from {} projects",
1514 all_mappings.len(), composer_count);
1515
1516 Ok(all_mappings)
1517}
1518
1519pub fn resolve_php_namespace_to_path(
1546 namespace: &str,
1547 psr4_mappings: &[Psr4Mapping],
1548) -> Option<String> {
1549 for mapping in psr4_mappings {
1551 if namespace.starts_with(&mapping.namespace_prefix) {
1552 let relative_namespace = &namespace[mapping.namespace_prefix.len()..];
1554
1555 let relative_path = relative_namespace.replace('\\', "/");
1557
1558 let file_path = if relative_path.is_empty() {
1560 return None;
1563 } else {
1564 let base_path = if mapping.project_root.is_empty() {
1566 format!("{}{}.php", mapping.directory, relative_path)
1568 } else {
1569 format!("{}/{}{}.php", mapping.project_root, mapping.directory, relative_path)
1571 };
1572
1573 base_path.replace("//", "/")
1575 };
1576
1577 log::trace!("Resolved namespace '{}' to path '{}'", namespace, file_path);
1578 return Some(file_path);
1579 }
1580 }
1581
1582 log::trace!("No PSR-4 mapping found for namespace '{}'", namespace);
1584 None
1585}