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