Skip to main content

reflex/parsers/
php.rs

1//! PHP language parser using Tree-sitter
2//!
3//! Extracts symbols from PHP source code:
4//! - Functions
5//! - Classes (regular, abstract, final)
6//! - Interfaces
7//! - Traits
8//! - Methods (with class/trait scope)
9//! - Properties (public, protected, private)
10//! - Local variables ($var inside functions)
11//! - Constants (class and global)
12//! - Namespaces
13//! - Enums (PHP 8.1+)
14
15use 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
21/// Parse PHP source code and extract symbols
22pub 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    // Extract different types of symbols using Tree-sitter queries
39    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    // Add file path to all symbols
56    for symbol in &mut symbols {
57        symbol.path = path.to_string();
58        symbol.lang = Language::PHP;
59    }
60
61    Ok(symbols)
62}
63
64/// Extract function definitions
65fn 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
80/// Extract class declarations (including abstract and final classes)
81fn 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
96/// Extract interface declarations
97fn 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
112/// Extract trait declarations
113fn 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
128/// Extract attributes: BOTH definitions and uses
129/// Definitions: #[Attribute] class Route { ... }
130/// Uses: #[Route("/api/users")] class UserController { ... }
131fn 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    // Part 1: Extract attribute class DEFINITIONS (#[Attribute] class X)
139    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        // Check if this class has #[Attribute] specifically
175        if let (Some(name), Some(node)) = (name, class_node) {
176            let class_text = node.utf8_text(source.as_bytes()).unwrap_or("");
177
178            // Check if the class has #[Attribute] attribute
179            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    // Part 2: Extract attribute USES (#[Route(...)] on classes/methods)
197    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
218/// Extract method definitions from classes, traits, and interfaces
219fn 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                    // Find the parent method_declaration node
299                    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
334/// Extract property declarations from classes and traits
335fn 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                    // Find the parent property_declaration node
403                    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
438/// Extract local variable assignments inside functions
439fn 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        // Accept all variable assignments (global, local in functions, local in methods)
481        // Note: Property declarations are handled separately by extract_properties()
482        // and use different syntax (property_declaration), so they won't match this query
483        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, // No scope for local variables or global variables
494                preview,
495            ));
496        }
497    }
498
499    Ok(symbols)
500}
501
502/// Extract constant declarations (class constants and global constants)
503fn 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
519/// Extract namespace definitions
520fn 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
535/// Extract enum declarations (PHP 8.1+)
536fn 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
551/// Generic symbol extraction helper
552fn 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        // Find the name capture and the full node
566        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                // Assume any other capture is the full node
581                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
622/// Convert a Tree-sitter node to a Span
623fn 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, // Convert 0-indexed to 1-indexed
629        start.column,
630        end.row + 1,
631        end.column,
632    )
633}
634
635/// Extract a preview (7 lines) around the symbol
636fn extract_preview(source: &str, span: &Span) -> String {
637    let lines: Vec<&str> = source.lines().collect();
638
639    // Extract 7 lines: the start line and 6 following lines
640    let start_idx = span.start_line - 1; // Convert back to 0-indexed
641    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        // Should find class
683        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        // Should find class + 2 methods
710        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        // Check scope
730        for _method in method_symbols {
731            // Removed: scope field no longer exists: assert_eq!(method.scope.as_ref().unwrap(), "class Calculator");
732        }
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        // Should find: namespace, interface, trait, class, methods, properties, const, function
931        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        // Filter to just variables (both global assignment, local vars, and class properties)
969        let variables: Vec<_> = symbols
970            .iter()
971            .filter(|s| matches!(s.kind, SymbolKind::Variable))
972            .collect();
973
974        // Should find: global_count (global), value (property), local_count, result, temp
975        assert_eq!(variables.len(), 5);
976
977        // Check that local variables inside functions are captured
978        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        // Check that global assignment is captured
995        assert!(
996            variables
997                .iter()
998                .any(|v| v.symbol.as_deref() == Some("global_count"))
999        );
1000
1001        // Check that class property is captured
1002        assert!(
1003            variables
1004                .iter()
1005                .any(|v| v.symbol.as_deref() == Some("value"))
1006        );
1007
1008        // Verify that local variables have no scope
1009        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            // Removed: scope field no longer exists: assert_eq!(var.scope, None);
1020        }
1021
1022        // Verify that class property has scope
1023        let _property = variables
1024            .iter()
1025            .find(|v| v.symbol.as_deref() == Some("value"))
1026            .unwrap();
1027        // Removed: scope field no longer exists: assert_eq!(property.scope.as_ref().unwrap(), "class Math");
1028    }
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        // Should find Route and Deprecated attribute classes
1056        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        // Should find attribute class definitions (Route, Deprecated)
1112        // AND attribute uses (Route appears 5 times, Deprecated appears 1 time)
1113        // Total expected: 2 definitions + 6 uses = 8
1114        assert!(attribute_symbols.len() >= 6);
1115
1116        // Count specific attribute uses
1117        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        // Should find Route at least 5 times (1 definition + 5 uses)
1128        assert!(route_count >= 5);
1129
1130        // Should find Deprecated at least 2 times (1 definition + 1 use)
1131        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        // Should find all 3 classes:
1196        // 1. SimpleClass
1197        // 2. MultiInterfaceClass (implements 2 interfaces)
1198        // 3. ComplexClass (extends + implements 2 interfaces + large docblock)
1199        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        // Should find 3 use statements
1244        assert_eq!(deps.len(), 3, "Should extract 3 use statements");
1245
1246        // Check specific imports
1247        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        // All should be Internal (Laravel framework classes)
1252        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        // Should only find static use statements and require with string literals
1279        // Variable and expression-based requires are filtered (not (string) nodes)
1280        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        // Verify dynamic requires are NOT captured
1288        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
1294// ============================================================================
1295// Dependency Extraction
1296// ============================================================================
1297
1298use crate::models::ImportType;
1299use crate::parsers::{DependencyExtractor, ImportInfo};
1300
1301/// PHP dependency extractor
1302pub 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        // Extract use declarations
1322        imports.extend(extract_php_uses(source, &root_node)?);
1323
1324        // Extract require/include statements
1325        imports.extend(extract_php_requires(source, &root_node)?);
1326
1327        Ok(imports)
1328    }
1329}
1330
1331/// Extract PHP `use` declarations
1332fn 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, // PHP imports entire namespace/class
1368                });
1369            }
1370        }
1371    }
1372
1373    Ok(imports)
1374}
1375
1376/// Extract PHP `require`, `require_once`, `include`, `include_once` statements
1377fn extract_php_requires(source: &str, root: &tree_sitter::Node) -> Result<Vec<ImportInfo>> {
1378    let language = tree_sitter_php::LANGUAGE_PHP;
1379
1380    // Match require/include with both string and expression
1381    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                    // Remove quotes from path
1417                    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            // For require/include, we consider them as internal file dependencies
1429            let line_number = node.start_position().row + 1;
1430
1431            imports.push(ImportInfo {
1432                imported_path: path,
1433                import_type: ImportType::Internal, // File includes are always internal
1434                line_number,
1435                imported_symbols: None, // Includes don't specify symbols
1436            });
1437        }
1438    }
1439
1440    Ok(imports)
1441}
1442
1443/// Classify a PHP `use` declaration as internal, external, or stdlib
1444fn classify_php_use(use_path: &str) -> ImportType {
1445    // PHP standard library extensions/classes (built-in PHP namespaces)
1446    const PHP_STDLIB_NAMESPACES: &[&str] = &[
1447        // PSR standards (PHP standard interfaces)
1448        "Psr\\",
1449        "Psr\\Http",
1450        "Psr\\Log",
1451        "Psr\\Cache",
1452        "Psr\\Container",
1453        // PHP built-in classes/interfaces
1454        "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        // SPL (Standard PHP Library)
1473        "SplFileInfo",
1474        "SplFileObject",
1475        "SplDoublyLinkedList",
1476        "SplQueue",
1477        "SplStack",
1478        "SplHeap",
1479        "SplMinHeap",
1480        "SplMaxHeap",
1481        "SplPriorityQueue",
1482        "SplFixedArray",
1483        "SplObjectStorage",
1484        // PHP XML classes
1485        "SimpleXMLElement",
1486        "DOMDocument",
1487        "DOMElement",
1488        "DOMNode",
1489        "XMLReader",
1490        "XMLWriter",
1491    ];
1492
1493    // Common vendor packages (third-party dependencies from composer)
1494    const PHP_VENDOR_NAMESPACES: &[&str] = &[
1495        // Symfony framework
1496        "Symfony\\",
1497        // Popular packages
1498        "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    // Check if it's a standard library class
1564    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    // Check if it's a vendor/third-party package
1571    for vendor_ns in PHP_VENDOR_NAMESPACES {
1572        if use_path.starts_with(vendor_ns) {
1573            return ImportType::External;
1574        }
1575    }
1576
1577    // Internal: project namespaces
1578    ImportType::Internal
1579}
1580
1581// ============================================================================
1582// PSR-4 Autoloading Support (composer.json parser)
1583// ============================================================================
1584
1585/// PSR-4 autoload mapping (namespace prefix → directory path)
1586#[derive(Debug, Clone)]
1587pub struct Psr4Mapping {
1588    pub namespace_prefix: String, // e.g., "App\\"
1589    pub directory: String,        // e.g., "app/"
1590    pub project_root: String,     // e.g., "services/php/rcm-backend/" (relative to index root)
1591}
1592
1593/// Parse composer.json and extract PSR-4 autoload mappings
1594///
1595/// Returns a vector of PSR-4 mappings sorted by namespace length (longest first)
1596/// to ensure more specific namespaces are matched before general ones.
1597///
1598/// # Arguments
1599///
1600/// * `project_root` - Root directory of the project (where composer.json is located)
1601pub fn parse_composer_psr4(project_root: &Path) -> Result<Vec<Psr4Mapping>> {
1602    let composer_path = project_root.join("composer.json");
1603
1604    // If composer.json doesn't exist, return empty mappings
1605    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    // Extract PSR-4 mappings from autoload section
1619    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            // path can be a string or array of strings
1625            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(), // Empty for single-project use
1639                });
1640            }
1641        }
1642    }
1643
1644    // Sort by namespace length (longest first) for correct matching
1645    // Example: "App\\Http\\" should match before "App\\"
1646    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
1659/// Find all composer.json files in a directory tree (excluding vendor directories)
1660///
1661/// # Arguments
1662///
1663/// * `index_root` - Root directory of the indexed codebase
1664///
1665/// # Returns
1666///
1667/// Vector of absolute paths to composer.json files (excluding vendor/)
1668pub 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        // Only process files named composer.json
1683        if !path.is_file() || path.file_name() != Some(std::ffi::OsStr::new("composer.json")) {
1684            continue;
1685        }
1686
1687        // Skip vendor directories (composer packages)
1688        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
1700/// Parse all composer.json files in a monorepo and extract PSR-4 mappings
1701///
1702/// # Arguments
1703///
1704/// * `index_root` - Root directory of the indexed codebase (e.g., monorepo root)
1705///
1706/// # Returns
1707///
1708/// Vector of PSR-4 mappings with project_root relative to index_root
1709pub 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(); // Save count before moving
1719
1720    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        // Get project root relative to index root
1726        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        // Parse this composer.json
1735        let mappings = parse_composer_psr4(project_root)?;
1736
1737        // Add project_root to each mapping
1738        for mut mapping in mappings {
1739            mapping.project_root = relative_project_root.clone();
1740            all_mappings.push(mapping);
1741        }
1742    }
1743
1744    // Sort by namespace length (longest first) for correct matching
1745    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
1756/// Resolve a PHP namespace to a file path using PSR-4 autoload rules
1757///
1758/// # Arguments
1759///
1760/// * `namespace` - Full namespace (e.g., "App\\Http\\Controllers\\UserController")
1761/// * `psr4_mappings` - PSR-4 mappings from composer.json
1762///
1763/// # Returns
1764///
1765/// Relative file path (e.g., "app/Http/Controllers/UserController.php") or None if not resolvable
1766///
1767/// # PSR-4 Resolution Rules
1768///
1769/// 1. Find the longest matching namespace prefix
1770/// 2. Strip the prefix from the namespace
1771/// 3. Convert remaining namespace to path (replace \\ with /)
1772/// 4. Append to the mapped directory
1773/// 5. Add .php extension
1774///
1775/// # Examples
1776///
1777/// ```
1778/// // PSR-4 mapping: "App\\" => "app/"
1779/// // Input: "App\\Http\\Controllers\\UserController"
1780/// // Output: "app/Http/Controllers/UserController.php"
1781/// ```
1782pub fn resolve_php_namespace_to_path(
1783    namespace: &str,
1784    psr4_mappings: &[Psr4Mapping],
1785) -> Option<String> {
1786    // Find the longest matching PSR-4 prefix
1787    for mapping in psr4_mappings {
1788        if namespace.starts_with(&mapping.namespace_prefix) {
1789            // Strip the namespace prefix
1790            let relative_namespace = &namespace[mapping.namespace_prefix.len()..];
1791
1792            // Convert namespace to path (replace \\ with /)
1793            let relative_path = relative_namespace.replace('\\', "/");
1794
1795            // Combine directory + relative_path + .php
1796            let file_path = if relative_path.is_empty() {
1797                // Namespace exactly matches prefix (e.g., "App\\") → "app/.php" (invalid)
1798                // This shouldn't happen for valid class imports
1799                return None;
1800            } else {
1801                // Build full path: project_root + directory + relative_path + .php
1802                let base_path = if mapping.project_root.is_empty() {
1803                    // Single-project mode: just directory + file
1804                    format!("{}{}.php", mapping.directory, relative_path)
1805                } else {
1806                    // Monorepo mode: project_root + directory + file
1807                    format!(
1808                        "{}/{}{}.php",
1809                        mapping.project_root, mapping.directory, relative_path
1810                    )
1811                };
1812
1813                // Normalize path separators (replace // with /)
1814                base_path.replace("//", "/")
1815            };
1816
1817            log::trace!("Resolved namespace '{}' to path '{}'", namespace, file_path);
1818            return Some(file_path);
1819        }
1820    }
1821
1822    // No matching PSR-4 prefix found
1823    log::trace!("No PSR-4 mapping found for namespace '{}'", namespace);
1824    None
1825}