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