Skip to main content

exspec_lang_php/
observe.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Query, QueryCursor};
7
8use exspec_core::observe::{
9    BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
10    ProductionFunction,
11};
12
13use super::PhpExtractor;
14
15const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
16static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
17
18const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
19static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
20
21const EXTENDS_CLASS_QUERY: &str = include_str!("../queries/extends_class.scm");
22static EXTENDS_CLASS_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
23
24fn php_language() -> tree_sitter::Language {
25    tree_sitter_php::LANGUAGE_PHP.into()
26}
27
28fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
29    lock.get_or_init(|| Query::new(&php_language(), source).expect("invalid query"))
30}
31
32// ---------------------------------------------------------------------------
33// Stem helpers
34// ---------------------------------------------------------------------------
35
36/// Extract stem from a PHP test file path.
37/// `tests/UserTest.php` -> `Some("User")`   (Test suffix, PHPUnit)
38/// `tests/user_test.php` -> `Some("user")`  (_test suffix, Pest)
39/// `tests/Unit/OrderServiceTest.php` -> `Some("OrderService")`
40/// `src/User.php` -> `None`
41pub fn test_stem(path: &str) -> Option<&str> {
42    let file_name = Path::new(path).file_name()?.to_str()?;
43    // Must end with .php
44    let stem = file_name.strip_suffix(".php")?;
45
46    // *Test.php (PHPUnit convention)
47    if let Some(rest) = stem.strip_suffix("Test") {
48        if !rest.is_empty() {
49            return Some(rest);
50        }
51    }
52
53    // *_test.php (Pest convention)
54    if let Some(rest) = stem.strip_suffix("_test") {
55        if !rest.is_empty() {
56            return Some(rest);
57        }
58    }
59
60    None
61}
62
63/// Extract stem from a PHP production file path.
64/// `src/User.php` -> `Some("User")`
65/// `src/Models/User.php` -> `Some("User")`
66/// `tests/UserTest.php` -> `None`
67pub fn production_stem(path: &str) -> Option<&str> {
68    // Test files are not production files
69    if test_stem(path).is_some() {
70        return None;
71    }
72
73    let file_name = Path::new(path).file_name()?.to_str()?;
74    let stem = file_name.strip_suffix(".php")?;
75
76    if stem.is_empty() {
77        return None;
78    }
79
80    Some(stem)
81}
82
83/// Check if a file is a non-SUT helper (not subject under test).
84pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
85    // If the file is already known to be a production file, it's not a helper.
86    if is_known_production {
87        return false;
88    }
89
90    let normalized = file_path.replace('\\', "/");
91    let file_name = Path::new(&normalized)
92        .file_name()
93        .and_then(|f| f.to_str())
94        .unwrap_or("");
95
96    // TestCase.php (base test class)
97    if file_name == "TestCase.php" {
98        return true;
99    }
100
101    // *Factory.php in tests/ (Laravel factory)
102    if file_name.ends_with("Factory.php") {
103        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
104        if in_tests {
105            return true;
106        }
107    }
108
109    // Abstract*.php in tests/
110    if file_name.starts_with("Abstract") && file_name.ends_with(".php") {
111        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
112        if in_tests {
113            return true;
114        }
115    }
116
117    // Trait*.php or *Trait.php in tests/ (test traits)
118    let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
119    if in_tests
120        && file_name.ends_with(".php")
121        && (file_name.starts_with("Trait") || file_name.ends_with("Trait.php"))
122    {
123        return true;
124    }
125
126    // Files in tests/Traits/ directory
127    if normalized.contains("/tests/Traits/") || normalized.starts_with("tests/Traits/") {
128        return true;
129    }
130
131    // Fixtures and Stubs directories under tests/ are test infrastructure, not SUT
132    let lower = normalized.to_lowercase();
133    if (lower.contains("/tests/fixtures/") || lower.starts_with("tests/fixtures/"))
134        || (lower.contains("/tests/stubs/") || lower.starts_with("tests/stubs/"))
135    {
136        return true;
137    }
138
139    // Kernel.php
140    if file_name == "Kernel.php" {
141        return true;
142    }
143
144    // bootstrap.php or bootstrap/*.php
145    if file_name == "bootstrap.php" {
146        return true;
147    }
148    if normalized.starts_with("bootstrap/") || normalized.contains("/bootstrap/") {
149        return true;
150    }
151
152    false
153}
154
155// ---------------------------------------------------------------------------
156// PSR-4 prefix resolution
157// ---------------------------------------------------------------------------
158
159/// Load PSR-4 namespace prefix -> directory mappings from composer.json.
160/// Returns a map of namespace prefix (trailing `\` stripped) -> directory (trailing `/` stripped).
161/// Returns an empty map if composer.json is absent or unparseable.
162pub fn load_psr4_prefixes(scan_root: &Path) -> HashMap<String, String> {
163    let composer_path = scan_root.join("composer.json");
164    let content = match std::fs::read_to_string(&composer_path) {
165        Ok(s) => s,
166        Err(_) => return HashMap::new(),
167    };
168    let value: serde_json::Value = match serde_json::from_str(&content) {
169        Ok(v) => v,
170        Err(_) => return HashMap::new(),
171    };
172
173    let mut result = HashMap::new();
174
175    // Parse both autoload and autoload-dev psr-4 sections
176    for section in &["autoload", "autoload-dev"] {
177        if let Some(psr4) = value
178            .get(section)
179            .and_then(|a| a.get("psr-4"))
180            .and_then(|p| p.as_object())
181        {
182            for (ns, dir) in psr4 {
183                // Strip trailing backslash from namespace prefix
184                let ns_key = ns.trim_end_matches('\\').to_string();
185                // Strip trailing slash from directory
186                let dir_val = dir.as_str().unwrap_or("").trim_end_matches('/').to_string();
187                if !ns_key.is_empty() {
188                    result.insert(ns_key, dir_val);
189                }
190            }
191        }
192    }
193
194    result
195}
196
197// ---------------------------------------------------------------------------
198// External package detection
199// ---------------------------------------------------------------------------
200
201/// Known external PHP package namespace prefixes to skip during import resolution.
202const EXTERNAL_NAMESPACES: &[&str] = &[
203    "PHPUnit",
204    "Illuminate",
205    "Symfony",
206    "Doctrine",
207    "Mockery",
208    "Carbon",
209    "Pest",
210    "Laravel",
211    "Monolog",
212    "Psr",
213    "GuzzleHttp",
214    "League",
215    "Ramsey",
216    "Spatie",
217    "Nette",
218    "Webmozart",
219    "PhpParser",
220    "SebastianBergmann",
221];
222
223fn is_external_namespace(namespace: &str, scan_root: Option<&Path>) -> bool {
224    let first_segment = namespace.split('/').next().unwrap_or("");
225    let is_known_external = EXTERNAL_NAMESPACES
226        .iter()
227        .any(|&ext| first_segment.eq_ignore_ascii_case(ext));
228
229    if !is_known_external {
230        return false;
231    }
232
233    // If scan_root is provided, check if the namespace source exists locally.
234    // If it does, this is a framework self-test scenario — treat as internal.
235    if let Some(root) = scan_root {
236        for prefix in &["src", "app", "lib", ""] {
237            let candidate = if prefix.is_empty() {
238                root.join(first_segment)
239            } else {
240                root.join(prefix).join(first_segment)
241            };
242            if candidate.is_dir() {
243                return false;
244            }
245        }
246    }
247
248    true
249}
250
251// ---------------------------------------------------------------------------
252// ObserveExtractor impl
253// ---------------------------------------------------------------------------
254
255impl ObserveExtractor for PhpExtractor {
256    fn extract_production_functions(
257        &self,
258        source: &str,
259        file_path: &str,
260    ) -> Vec<ProductionFunction> {
261        let mut parser = Self::parser();
262        let tree = match parser.parse(source, None) {
263            Some(t) => t,
264            None => return Vec::new(),
265        };
266        let source_bytes = source.as_bytes();
267        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
268
269        let name_idx = query.capture_index_for_name("name");
270        let class_name_idx = query.capture_index_for_name("class_name");
271        let method_name_idx = query.capture_index_for_name("method_name");
272        let function_idx = query.capture_index_for_name("function");
273        let method_idx = query.capture_index_for_name("method");
274
275        let mut cursor = QueryCursor::new();
276        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
277        let mut result = Vec::new();
278
279        while let Some(m) = matches.next() {
280            let mut fn_name: Option<String> = None;
281            let mut class_name: Option<String> = None;
282            let mut line: usize = 1;
283            let mut is_exported = true; // default: top-level functions are exported
284            let mut method_node: Option<tree_sitter::Node> = None;
285
286            for cap in m.captures {
287                let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
288                let node_line = cap.node.start_position().row + 1;
289
290                if name_idx == Some(cap.index) {
291                    fn_name = Some(text);
292                    line = node_line;
293                } else if class_name_idx == Some(cap.index) {
294                    class_name = Some(text);
295                } else if method_name_idx == Some(cap.index) {
296                    fn_name = Some(text);
297                    line = node_line;
298                }
299
300                // Capture method node for visibility check
301                if method_idx == Some(cap.index) {
302                    method_node = Some(cap.node);
303                }
304
305                // Top-level function: always exported
306                if function_idx == Some(cap.index) {
307                    is_exported = true;
308                }
309            }
310
311            // Determine visibility from method node
312            if let Some(method) = method_node {
313                is_exported = has_public_visibility(method, source_bytes);
314            }
315
316            if let Some(name) = fn_name {
317                result.push(ProductionFunction {
318                    name,
319                    file: file_path.to_string(),
320                    line,
321                    class_name,
322                    is_exported,
323                });
324            }
325        }
326
327        result
328    }
329
330    fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
331        // PHP has no relative imports; Layer 2 uses PSR-4 namespace resolution
332        Vec::new()
333    }
334
335    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
336        let mut parser = Self::parser();
337        let tree = match parser.parse(source, None) {
338            Some(t) => t,
339            None => return Vec::new(),
340        };
341        let source_bytes = source.as_bytes();
342        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
343
344        let namespace_path_idx = query.capture_index_for_name("namespace_path");
345
346        let mut cursor = QueryCursor::new();
347        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
348
349        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
350
351        while let Some(m) = matches.next() {
352            for cap in m.captures {
353                if namespace_path_idx != Some(cap.index) {
354                    continue;
355                }
356                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
357                // Convert `App\Models\User` -> `App/Models/User`
358                let fs_path = raw.replace('\\', "/");
359
360                // Skip external packages (no scan_root — trait method, conservative filter)
361                if is_external_namespace(&fs_path, None) {
362                    continue;
363                }
364
365                // Split into module path and symbol
366                // `App/Models/User` -> module=`App/Models`, symbol=`User`
367                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
368                if parts.len() < 2 {
369                    // Single segment (no slash): use as both module and symbol
370                    // e.g., `use User;` -> module="", symbol="User"
371                    // Skip these edge cases
372                    continue;
373                }
374
375                // Find the last '/' to split module from symbol
376                if let Some(last_slash) = fs_path.rfind('/') {
377                    let module_path = &fs_path[..last_slash];
378                    let symbol = &fs_path[last_slash + 1..];
379                    if !module_path.is_empty() && !symbol.is_empty() {
380                        result_map
381                            .entry(module_path.to_string())
382                            .or_default()
383                            .push(symbol.to_string());
384                    }
385                }
386            }
387        }
388
389        result_map.into_iter().collect()
390    }
391
392    fn extract_barrel_re_exports(&self, _source: &str, _file_path: &str) -> Vec<BarrelReExport> {
393        // PHP has no barrel export pattern
394        Vec::new()
395    }
396
397    fn source_extensions(&self) -> &[&str] {
398        &["php"]
399    }
400
401    fn index_file_names(&self) -> &[&str] {
402        // PHP has no index files equivalent
403        &[]
404    }
405
406    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
407        production_stem(path)
408    }
409
410    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
411        test_stem(path)
412    }
413
414    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
415        is_non_sut_helper(file_path, is_known_production)
416    }
417}
418
419// ---------------------------------------------------------------------------
420// Concrete methods (not in trait)
421// ---------------------------------------------------------------------------
422
423impl PhpExtractor {
424    /// Extract all import specifiers without external namespace filtering.
425    /// Returns (module_path, [symbols]) pairs for all `use` statements.
426    fn extract_raw_import_specifiers(source: &str) -> Vec<(String, Vec<String>)> {
427        let mut parser = Self::parser();
428        let tree = match parser.parse(source, None) {
429            Some(t) => t,
430            None => return Vec::new(),
431        };
432        let source_bytes = source.as_bytes();
433        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
434
435        let namespace_path_idx = query.capture_index_for_name("namespace_path");
436
437        let mut cursor = QueryCursor::new();
438        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
439
440        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
441
442        while let Some(m) = matches.next() {
443            for cap in m.captures {
444                if namespace_path_idx != Some(cap.index) {
445                    continue;
446                }
447                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
448                let fs_path = raw.replace('\\', "/");
449
450                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
451                if parts.len() < 2 {
452                    continue;
453                }
454
455                if let Some(last_slash) = fs_path.rfind('/') {
456                    let module_path = &fs_path[..last_slash];
457                    let symbol = &fs_path[last_slash + 1..];
458                    if !module_path.is_empty() && !symbol.is_empty() {
459                        result_map
460                            .entry(module_path.to_string())
461                            .or_default()
462                            .push(symbol.to_string());
463                    }
464                }
465            }
466        }
467
468        result_map.into_iter().collect()
469    }
470
471    /// Extract import specifiers from the parent class of a test file.
472    /// Resolves the parent class to a file in the same directory, reads it,
473    /// and returns its raw `use` statements (unfiltered).
474    /// Only traces 1 level deep (direct parent only).
475    pub fn extract_parent_class_imports(
476        source: &str,
477        test_dir: &str,
478    ) -> Vec<(String, Vec<String>)> {
479        // Step 1: parse source and find extends clause
480        let mut parser = Self::parser();
481        let tree = match parser.parse(source, None) {
482            Some(t) => t,
483            None => return Vec::new(),
484        };
485        let source_bytes = source.as_bytes();
486        let query = cached_query(&EXTENDS_CLASS_QUERY_CACHE, EXTENDS_CLASS_QUERY);
487
488        let parent_class_idx = query.capture_index_for_name("parent_class");
489
490        let mut cursor = QueryCursor::new();
491        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
492
493        let mut parent_class_name: Option<String> = None;
494        while let Some(m) = matches.next() {
495            for cap in m.captures {
496                if parent_class_idx == Some(cap.index) {
497                    let name = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
498                    if !name.is_empty() {
499                        parent_class_name = Some(name);
500                        break;
501                    }
502                }
503            }
504            if parent_class_name.is_some() {
505                break;
506            }
507        }
508
509        let parent_name = match parent_class_name {
510            Some(n) => n,
511            None => return Vec::new(),
512        };
513
514        // Step 2: look for parent file in same directory
515        let parent_file_name = format!("{parent_name}.php");
516        let parent_path = Path::new(test_dir).join(&parent_file_name);
517
518        // Read parent file
519        let parent_source = match std::fs::read_to_string(&parent_path) {
520            Ok(s) => s,
521            Err(_) => return Vec::new(),
522        };
523
524        // Step 3: extract raw import specifiers from parent
525        Self::extract_raw_import_specifiers(&parent_source)
526    }
527
528    /// Layer 1 + Layer 2 (PSR-4): Map test files to production files.
529    pub fn map_test_files_with_imports(
530        &self,
531        production_files: &[String],
532        test_sources: &HashMap<String, String>,
533        scan_root: &Path,
534        l1_exclusive: bool,
535    ) -> Vec<FileMapping> {
536        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
537
538        // Layer 1: filename convention (stem matching)
539        let mut mappings =
540            exspec_core::observe::map_test_files(self, production_files, &test_file_list);
541
542        // Build canonical path -> production index lookup
543        let canonical_root = match scan_root.canonicalize() {
544            Ok(r) => r,
545            Err(_) => return mappings,
546        };
547        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
548        for (idx, prod) in production_files.iter().enumerate() {
549            if let Ok(canonical) = Path::new(prod).canonicalize() {
550                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
551            }
552        }
553
554        // Record Layer 1 matches per production file index
555        let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
556            .iter()
557            .map(|m| m.test_files.iter().cloned().collect())
558            .collect();
559
560        // Collect set of test files matched by L1 for l1_exclusive mode
561        let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
562            .iter()
563            .flat_map(|s| s.iter().cloned())
564            .collect();
565
566        // Load PSR-4 prefix mappings from composer.json (e.g., "MyApp" -> "custom_src")
567        let psr4_prefixes = load_psr4_prefixes(scan_root);
568
569        // Layer 2: PSR-4 convention import resolution
570        // Use raw imports (unfiltered) and apply scan_root-aware external filtering
571        for (test_file, source) in test_sources {
572            if l1_exclusive && layer1_matched.contains(test_file) {
573                continue;
574            }
575            let raw_specifiers = Self::extract_raw_import_specifiers(source);
576            // Merge parent class imports (1-level, same directory only)
577            let parent_dir = Path::new(test_file.as_str())
578                .parent()
579                .map(|p| p.to_string_lossy().into_owned())
580                .unwrap_or_default();
581            let parent_specifiers = Self::extract_parent_class_imports(source, &parent_dir);
582            let combined: Vec<(String, Vec<String>)> = raw_specifiers
583                .into_iter()
584                .chain(parent_specifiers.into_iter())
585                .collect();
586            let specifiers: Vec<(String, Vec<String>)> = combined
587                .into_iter()
588                .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
589                .collect();
590            let mut matched_indices = std::collections::HashSet::<usize>::new();
591
592            for (module_path, _symbols) in &specifiers {
593                // PSR-4 resolution:
594                // `App/Models/User` -> try `src/Models/User.php`, `app/Models/User.php`, etc.
595                //
596                // Strategy: strip the first segment (PSR-4 prefix like "App")
597                // and search for the remaining path under common directories.
598                let parts: Vec<&str> = module_path.splitn(2, '/').collect();
599                let first_segment = parts[0];
600                let path_without_prefix = if parts.len() == 2 {
601                    parts[1]
602                } else {
603                    module_path.as_str()
604                };
605
606                // Check if first segment matches a PSR-4 prefix from composer.json
607                // e.g., "MyApp" -> "custom_src" means resolve under custom_src/
608                let psr4_dir = psr4_prefixes.get(first_segment);
609
610                // Derive the PHP file name from the last segment of module_path
611                // e.g., `App/Models` -> last segment is `Models` -> file is `Models.php`
612                // But module_path is actually the directory, not the file.
613                // The symbol is in the symbols list, but we need to reconstruct the file path.
614                // Actually, at this point module_path = `App/Models` and symbol could be `User`,
615                // so the full file is `Models/User.php` (without prefix).
616
617                // We need to get the symbols too
618                for symbol in _symbols {
619                    let file_name = format!("{symbol}.php");
620
621                    // If composer.json defines a PSR-4 mapping for this namespace prefix,
622                    // try the mapped directory first.
623                    if let Some(psr4_base) = psr4_dir {
624                        let candidate = canonical_root
625                            .join(psr4_base)
626                            .join(path_without_prefix)
627                            .join(&file_name);
628                        if let Ok(canonical_candidate) = candidate.canonicalize() {
629                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
630                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
631                                matched_indices.insert(idx);
632                            }
633                        }
634                    }
635
636                    // Try: <scan_root>/<common_prefix>/<path_without_prefix>/<symbol>.php
637                    let common_prefixes = ["src", "app", "lib", ""];
638                    for prefix in &common_prefixes {
639                        let candidate = if prefix.is_empty() {
640                            canonical_root.join(path_without_prefix).join(&file_name)
641                        } else {
642                            canonical_root
643                                .join(prefix)
644                                .join(path_without_prefix)
645                                .join(&file_name)
646                        };
647
648                        if let Ok(canonical_candidate) = candidate.canonicalize() {
649                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
650                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
651                                matched_indices.insert(idx);
652                            }
653                        }
654                    }
655
656                    // Also try with the first segment kept (in case directory matches namespace 1:1)
657                    // e.g., framework self-tests: `Illuminate/Http` -> `src/Illuminate/Http/Request.php`
658                    for prefix in &common_prefixes {
659                        let candidate = if prefix.is_empty() {
660                            canonical_root.join(module_path).join(&file_name)
661                        } else {
662                            canonical_root
663                                .join(prefix)
664                                .join(module_path)
665                                .join(&file_name)
666                        };
667                        if let Ok(canonical_candidate) = candidate.canonicalize() {
668                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
669                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
670                                matched_indices.insert(idx);
671                            }
672                        }
673                    }
674                }
675            }
676
677            for idx in matched_indices {
678                if !mappings[idx].test_files.contains(test_file) {
679                    mappings[idx].test_files.push(test_file.clone());
680                }
681            }
682        }
683
684        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
685        // set strategy to ImportTracing
686        for (i, mapping) in mappings.iter_mut().enumerate() {
687            let has_layer1 = !layer1_tests_per_prod[i].is_empty();
688            if !has_layer1 && !mapping.test_files.is_empty() {
689                mapping.strategy = MappingStrategy::ImportTracing;
690            }
691        }
692
693        mappings
694    }
695}
696
697// ---------------------------------------------------------------------------
698// Visibility helper
699// ---------------------------------------------------------------------------
700
701/// Check if a PHP method_declaration node has `public` visibility.
702/// Returns true for public, false for private/protected.
703/// If no visibility_modifier child is found, defaults to true (public by convention in PHP).
704fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
705    for i in 0..node.child_count() {
706        if let Some(child) = node.child(i) {
707            if child.kind() == "visibility_modifier" {
708                let text = child.utf8_text(source_bytes).unwrap_or("");
709                return text == "public";
710            }
711        }
712    }
713    // No visibility modifier -> treat as public by default
714    true
715}
716
717// ---------------------------------------------------------------------------
718// Tests
719// ---------------------------------------------------------------------------
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use std::collections::HashMap;
725
726    // -----------------------------------------------------------------------
727    // PHP-STEM-01: tests/UserTest.php -> test_stem = Some("User")
728    // -----------------------------------------------------------------------
729    #[test]
730    fn php_stem_01_test_suffix() {
731        // Given: a file named UserTest.php in tests/
732        // When: test_stem is called
733        // Then: returns Some("User")
734        assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
735    }
736
737    // -----------------------------------------------------------------------
738    // PHP-STEM-02: tests/user_test.php -> test_stem = Some("user")
739    // -----------------------------------------------------------------------
740    #[test]
741    fn php_stem_02_pest_suffix() {
742        // Given: a Pest-style file user_test.php
743        // When: test_stem is called
744        // Then: returns Some("user")
745        assert_eq!(test_stem("tests/user_test.php"), Some("user"));
746    }
747
748    // -----------------------------------------------------------------------
749    // PHP-STEM-03: tests/Unit/OrderServiceTest.php -> test_stem = Some("OrderService")
750    // -----------------------------------------------------------------------
751    #[test]
752    fn php_stem_03_nested() {
753        // Given: a nested test file OrderServiceTest.php
754        // When: test_stem is called
755        // Then: returns Some("OrderService")
756        assert_eq!(
757            test_stem("tests/Unit/OrderServiceTest.php"),
758            Some("OrderService")
759        );
760    }
761
762    // -----------------------------------------------------------------------
763    // PHP-STEM-04: src/User.php -> test_stem = None
764    // -----------------------------------------------------------------------
765    #[test]
766    fn php_stem_04_non_test() {
767        // Given: a production file src/User.php
768        // When: test_stem is called
769        // Then: returns None
770        assert_eq!(test_stem("src/User.php"), None);
771    }
772
773    // -----------------------------------------------------------------------
774    // PHP-STEM-05: src/User.php -> production_stem = Some("User")
775    // -----------------------------------------------------------------------
776    #[test]
777    fn php_stem_05_prod_stem() {
778        // Given: a production file src/User.php
779        // When: production_stem is called
780        // Then: returns Some("User")
781        assert_eq!(production_stem("src/User.php"), Some("User"));
782    }
783
784    // -----------------------------------------------------------------------
785    // PHP-STEM-06: src/Models/User.php -> production_stem = Some("User")
786    // -----------------------------------------------------------------------
787    #[test]
788    fn php_stem_06_prod_nested() {
789        // Given: a nested production file src/Models/User.php
790        // When: production_stem is called
791        // Then: returns Some("User")
792        assert_eq!(production_stem("src/Models/User.php"), Some("User"));
793    }
794
795    // -----------------------------------------------------------------------
796    // PHP-STEM-07: tests/UserTest.php -> production_stem = None
797    // -----------------------------------------------------------------------
798    #[test]
799    fn php_stem_07_test_not_prod() {
800        // Given: a test file tests/UserTest.php
801        // When: production_stem is called
802        // Then: returns None (test files are not production files)
803        assert_eq!(production_stem("tests/UserTest.php"), None);
804    }
805
806    // -----------------------------------------------------------------------
807    // PHP-HELPER-01: tests/TestCase.php -> is_non_sut_helper = true
808    // -----------------------------------------------------------------------
809    #[test]
810    fn php_helper_01_test_case() {
811        // Given: the base test class TestCase.php
812        // When: is_non_sut_helper is called
813        // Then: returns true
814        assert!(is_non_sut_helper("tests/TestCase.php", false));
815    }
816
817    // -----------------------------------------------------------------------
818    // PHP-HELPER-02: tests/UserFactory.php -> is_non_sut_helper = true
819    // -----------------------------------------------------------------------
820    #[test]
821    fn php_helper_02_factory() {
822        // Given: a Laravel factory file in tests/
823        // When: is_non_sut_helper is called
824        // Then: returns true
825        assert!(is_non_sut_helper("tests/UserFactory.php", false));
826    }
827
828    // -----------------------------------------------------------------------
829    // PHP-HELPER-03: src/User.php -> is_non_sut_helper = false
830    // -----------------------------------------------------------------------
831    #[test]
832    fn php_helper_03_production() {
833        // Given: a regular production file
834        // When: is_non_sut_helper is called
835        // Then: returns false
836        assert!(!is_non_sut_helper("src/User.php", false));
837    }
838
839    // -----------------------------------------------------------------------
840    // PHP-HELPER-04: tests/Traits/CreatesUsers.php -> is_non_sut_helper = true
841    // -----------------------------------------------------------------------
842    #[test]
843    fn php_helper_04_test_trait() {
844        // Given: a test trait in tests/Traits/
845        // When: is_non_sut_helper is called
846        // Then: returns true
847        assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
848    }
849
850    // -----------------------------------------------------------------------
851    // PHP-HELPER-05: bootstrap/app.php -> is_non_sut_helper = true
852    // -----------------------------------------------------------------------
853    #[test]
854    fn php_helper_05_bootstrap() {
855        // Given: a bootstrap file
856        // When: is_non_sut_helper is called
857        // Then: returns true
858        assert!(is_non_sut_helper("bootstrap/app.php", false));
859    }
860
861    // -----------------------------------------------------------------------
862    // PHP-FUNC-01: public function createUser() -> name="createUser", is_exported=true
863    // -----------------------------------------------------------------------
864    #[test]
865    fn php_func_01_public_method() {
866        // Given: a class with a public method
867        // When: extract_production_functions is called
868        // Then: name="createUser", is_exported=true
869        let ext = PhpExtractor::new();
870        let source = "<?php\nclass User {\n    public function createUser() {}\n}";
871        let fns = ext.extract_production_functions(source, "src/User.php");
872        let f = fns.iter().find(|f| f.name == "createUser").unwrap();
873        assert!(f.is_exported);
874    }
875
876    // -----------------------------------------------------------------------
877    // PHP-FUNC-02: private function helper() -> name="helper", is_exported=false
878    // -----------------------------------------------------------------------
879    #[test]
880    fn php_func_02_private_method() {
881        // Given: a class with a private method
882        // When: extract_production_functions is called
883        // Then: name="helper", is_exported=false
884        let ext = PhpExtractor::new();
885        let source = "<?php\nclass User {\n    private function helper() {}\n}";
886        let fns = ext.extract_production_functions(source, "src/User.php");
887        let f = fns.iter().find(|f| f.name == "helper").unwrap();
888        assert!(!f.is_exported);
889    }
890
891    // -----------------------------------------------------------------------
892    // PHP-FUNC-03: class User { public function save() } -> class_name=Some("User")
893    // -----------------------------------------------------------------------
894    #[test]
895    fn php_func_03_class_method() {
896        // Given: a class User with a public method save()
897        // When: extract_production_functions is called
898        // Then: name="save", class_name=Some("User")
899        let ext = PhpExtractor::new();
900        let source = "<?php\nclass User {\n    public function save() {}\n}";
901        let fns = ext.extract_production_functions(source, "src/User.php");
902        let f = fns.iter().find(|f| f.name == "save").unwrap();
903        assert_eq!(f.class_name, Some("User".to_string()));
904    }
905
906    // -----------------------------------------------------------------------
907    // PHP-FUNC-04: function global_helper() (top-level) -> exported
908    // -----------------------------------------------------------------------
909    #[test]
910    fn php_func_04_top_level_function() {
911        // Given: a top-level function global_helper()
912        // When: extract_production_functions is called
913        // Then: name="global_helper", is_exported=true
914        let ext = PhpExtractor::new();
915        let source = "<?php\nfunction global_helper() {\n    return 42;\n}";
916        let fns = ext.extract_production_functions(source, "src/helpers.php");
917        let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
918        assert!(f.is_exported);
919        assert_eq!(f.class_name, None);
920    }
921
922    // -----------------------------------------------------------------------
923    // PHP-IMP-01: use App\Models\User; -> ("App/Models", ["User"])
924    // -----------------------------------------------------------------------
925    #[test]
926    fn php_imp_01_app_models() {
927        // Given: a use statement for App\Models\User
928        // When: extract_all_import_specifiers is called
929        // Then: returns ("App/Models", ["User"])
930        let ext = PhpExtractor::new();
931        let source = "<?php\nuse App\\Models\\User;\n";
932        let imports = ext.extract_all_import_specifiers(source);
933        assert!(
934            imports
935                .iter()
936                .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
937            "expected App/Models -> [User], got: {imports:?}"
938        );
939    }
940
941    // -----------------------------------------------------------------------
942    // PHP-IMP-02: use App\Services\UserService; -> ("App/Services", ["UserService"])
943    // -----------------------------------------------------------------------
944    #[test]
945    fn php_imp_02_app_services() {
946        // Given: a use statement for App\Services\UserService
947        // When: extract_all_import_specifiers is called
948        // Then: returns ("App/Services", ["UserService"])
949        let ext = PhpExtractor::new();
950        let source = "<?php\nuse App\\Services\\UserService;\n";
951        let imports = ext.extract_all_import_specifiers(source);
952        assert!(
953            imports
954                .iter()
955                .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
956            "expected App/Services -> [UserService], got: {imports:?}"
957        );
958    }
959
960    // -----------------------------------------------------------------------
961    // PHP-IMP-03: use PHPUnit\Framework\TestCase; -> external package -> skipped
962    // -----------------------------------------------------------------------
963    #[test]
964    fn php_imp_03_external_phpunit() {
965        // Given: a use statement for external PHPUnit package
966        // When: extract_all_import_specifiers is called
967        // Then: returns empty (external packages are filtered)
968        let ext = PhpExtractor::new();
969        let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
970        let imports = ext.extract_all_import_specifiers(source);
971        assert!(
972            imports.is_empty(),
973            "external PHPUnit should be filtered, got: {imports:?}"
974        );
975    }
976
977    // -----------------------------------------------------------------------
978    // PHP-IMP-04: use Illuminate\Http\Request; -> external package -> skipped
979    // -----------------------------------------------------------------------
980    #[test]
981    fn php_imp_04_external_illuminate() {
982        // Given: a use statement for external Illuminate (Laravel) package
983        // When: extract_all_import_specifiers is called
984        // Then: returns empty (external packages are filtered)
985        let ext = PhpExtractor::new();
986        let source = "<?php\nuse Illuminate\\Http\\Request;\n";
987        let imports = ext.extract_all_import_specifiers(source);
988        assert!(
989            imports.is_empty(),
990            "external Illuminate should be filtered, got: {imports:?}"
991        );
992    }
993
994    // -----------------------------------------------------------------------
995    // PHP-E2E-01: User.php + UserTest.php in the same directory -> Layer 1 stem match
996    // -----------------------------------------------------------------------
997    #[test]
998    fn php_e2e_01_stem_match() {
999        // Given: production file User.php and test file UserTest.php in the same directory
1000        // (Layer 1 stem matching works when files share the same parent directory)
1001        // When: map_test_files_with_imports is called
1002        // Then: UserTest.php is matched to User.php via Layer 1 stem matching
1003        let dir = tempfile::tempdir().expect("failed to create tempdir");
1004
1005        let prod_file = dir.path().join("User.php");
1006        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1007
1008        let test_file = dir.path().join("UserTest.php");
1009        std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
1010
1011        let ext = PhpExtractor::new();
1012        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1013        let mut test_sources = HashMap::new();
1014        test_sources.insert(
1015            test_file.to_string_lossy().into_owned(),
1016            "<?php\nclass UserTest extends TestCase {}".to_string(),
1017        );
1018
1019        let mappings =
1020            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1021
1022        assert!(!mappings.is_empty(), "expected at least one mapping");
1023        let user_mapping = mappings
1024            .iter()
1025            .find(|m| m.production_file.contains("User.php"))
1026            .expect("expected User.php in mappings");
1027        assert!(
1028            !user_mapping.test_files.is_empty(),
1029            "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
1030        );
1031    }
1032
1033    // -----------------------------------------------------------------------
1034    // PHP-E2E-02: tests/ServiceTest.php imports use App\Services\OrderService
1035    //             -> Layer 2 PSR-4 import match
1036    // -----------------------------------------------------------------------
1037    #[test]
1038    fn php_e2e_02_import_match() {
1039        // Given: production file app/Services/OrderService.php
1040        //        and test file tests/ServiceTest.php with `use App\Services\OrderService;`
1041        // When: map_test_files_with_imports is called
1042        // Then: ServiceTest.php is matched to OrderService.php via Layer 2 import tracing
1043        let dir = tempfile::tempdir().expect("failed to create tempdir");
1044        let services_dir = dir.path().join("app").join("Services");
1045        std::fs::create_dir_all(&services_dir).unwrap();
1046        let test_dir = dir.path().join("tests");
1047        std::fs::create_dir_all(&test_dir).unwrap();
1048
1049        let prod_file = services_dir.join("OrderService.php");
1050        std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
1051
1052        let test_file = test_dir.join("ServiceTest.php");
1053        let test_source =
1054            "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
1055        std::fs::write(&test_file, test_source).unwrap();
1056
1057        let ext = PhpExtractor::new();
1058        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1059        let mut test_sources = HashMap::new();
1060        test_sources.insert(
1061            test_file.to_string_lossy().into_owned(),
1062            test_source.to_string(),
1063        );
1064
1065        let mappings =
1066            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1067
1068        let order_mapping = mappings
1069            .iter()
1070            .find(|m| m.production_file.contains("OrderService.php"))
1071            .expect("expected OrderService.php in mappings");
1072        assert!(
1073            !order_mapping.test_files.is_empty(),
1074            "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
1075        );
1076    }
1077
1078    // -----------------------------------------------------------------------
1079    // PHP-E2E-03: tests/TestCase.php -> helper exclusion
1080    // -----------------------------------------------------------------------
1081    #[test]
1082    fn php_e2e_03_helper_exclusion() {
1083        // Given: a TestCase.php base class in tests/
1084        // When: map_test_files_with_imports is called
1085        // Then: TestCase.php is excluded (is_non_sut_helper = true)
1086        let dir = tempfile::tempdir().expect("failed to create tempdir");
1087        let src_dir = dir.path().join("src");
1088        std::fs::create_dir_all(&src_dir).unwrap();
1089        let test_dir = dir.path().join("tests");
1090        std::fs::create_dir_all(&test_dir).unwrap();
1091
1092        let prod_file = src_dir.join("User.php");
1093        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1094
1095        // TestCase.php should be treated as a helper, not a test file
1096        let test_case_file = test_dir.join("TestCase.php");
1097        std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
1098
1099        let ext = PhpExtractor::new();
1100        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1101        let mut test_sources = HashMap::new();
1102        test_sources.insert(
1103            test_case_file.to_string_lossy().into_owned(),
1104            "<?php\nabstract class TestCase {}".to_string(),
1105        );
1106
1107        let mappings =
1108            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1109
1110        // TestCase.php should not be matched to User.php
1111        let user_mapping = mappings
1112            .iter()
1113            .find(|m| m.production_file.contains("User.php"));
1114        if let Some(mapping) = user_mapping {
1115            assert!(
1116                mapping.test_files.is_empty()
1117                    || !mapping
1118                        .test_files
1119                        .iter()
1120                        .any(|t| t.contains("TestCase.php")),
1121                "TestCase.php should not be mapped as a test file for User.php"
1122            );
1123        }
1124    }
1125
1126    // -----------------------------------------------------------------------
1127    // PHP-FW-01: laravel/framework layout -> Illuminate import resolves locally
1128    // -----------------------------------------------------------------------
1129    #[test]
1130    fn php_fw_01_laravel_framework_self_test() {
1131        // Given: laravel/framework layout with src/Illuminate/Http/Request.php
1132        //        and tests/Http/RequestTest.php importing `use Illuminate\Http\Request`
1133        // When: map_test_files_with_imports is called
1134        // Then: RequestTest.php is mapped to Request.php via Layer 2
1135        let dir = tempfile::tempdir().expect("failed to create tempdir");
1136        let src_dir = dir.path().join("src").join("Illuminate").join("Http");
1137        std::fs::create_dir_all(&src_dir).unwrap();
1138        let test_dir = dir.path().join("tests").join("Http");
1139        std::fs::create_dir_all(&test_dir).unwrap();
1140
1141        let prod_file = src_dir.join("Request.php");
1142        std::fs::write(
1143            &prod_file,
1144            "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1145        )
1146        .unwrap();
1147
1148        let test_file = test_dir.join("RequestTest.php");
1149        let test_source =
1150            "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1151        std::fs::write(&test_file, test_source).unwrap();
1152
1153        let ext = PhpExtractor::new();
1154        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1155        let mut test_sources = HashMap::new();
1156        test_sources.insert(
1157            test_file.to_string_lossy().into_owned(),
1158            test_source.to_string(),
1159        );
1160
1161        let mappings =
1162            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1163
1164        let request_mapping = mappings
1165            .iter()
1166            .find(|m| m.production_file.contains("Request.php"))
1167            .expect("expected Request.php in mappings");
1168        assert!(
1169            request_mapping
1170                .test_files
1171                .iter()
1172                .any(|t| t.contains("RequestTest.php")),
1173            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1174            request_mapping.test_files
1175        );
1176    }
1177
1178    // -----------------------------------------------------------------------
1179    // PHP-FW-02: normal app -> Illuminate import still filtered (no local source)
1180    // -----------------------------------------------------------------------
1181    #[test]
1182    fn php_fw_02_normal_app_illuminate_filtered() {
1183        // Given: normal app layout with app/Models/User.php
1184        //        and tests/UserTest.php importing `use Illuminate\Http\Request`
1185        //        (no local Illuminate directory)
1186        // When: map_test_files_with_imports is called
1187        // Then: Illuminate import is NOT resolved (no mapping via import)
1188        let dir = tempfile::tempdir().expect("failed to create tempdir");
1189        let app_dir = dir.path().join("app").join("Models");
1190        std::fs::create_dir_all(&app_dir).unwrap();
1191        let test_dir = dir.path().join("tests");
1192        std::fs::create_dir_all(&test_dir).unwrap();
1193
1194        let prod_file = app_dir.join("User.php");
1195        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1196
1197        // This test imports Illuminate but there's no local Illuminate source
1198        let test_file = test_dir.join("OrderTest.php");
1199        let test_source =
1200            "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1201        std::fs::write(&test_file, test_source).unwrap();
1202
1203        let ext = PhpExtractor::new();
1204        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1205        let mut test_sources = HashMap::new();
1206        test_sources.insert(
1207            test_file.to_string_lossy().into_owned(),
1208            test_source.to_string(),
1209        );
1210
1211        let mappings =
1212            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1213
1214        // User.php should not have OrderTest.php mapped (no stem match, no import match)
1215        let user_mapping = mappings
1216            .iter()
1217            .find(|m| m.production_file.contains("User.php"))
1218            .expect("expected User.php in mappings");
1219        assert!(
1220            !user_mapping
1221                .test_files
1222                .iter()
1223                .any(|t| t.contains("OrderTest.php")),
1224            "Illuminate import should be filtered when no local source exists"
1225        );
1226    }
1227
1228    // -----------------------------------------------------------------------
1229    // PHP-FW-03: PHPUnit import still filtered via integration test (regression)
1230    // -----------------------------------------------------------------------
1231    #[test]
1232    fn php_fw_03_phpunit_still_external() {
1233        // Given: app with src/Calculator.php and tests/CalculatorTest.php
1234        //        importing only `use PHPUnit\Framework\TestCase` (no local PHPUnit source)
1235        // When: map_test_files_with_imports is called
1236        // Then: PHPUnit import does not create a false mapping
1237        let dir = tempfile::tempdir().expect("failed to create tempdir");
1238        let src_dir = dir.path().join("src");
1239        std::fs::create_dir_all(&src_dir).unwrap();
1240        let test_dir = dir.path().join("tests");
1241        std::fs::create_dir_all(&test_dir).unwrap();
1242
1243        let prod_file = src_dir.join("Calculator.php");
1244        std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1245
1246        // Test imports only PHPUnit (external) — no import-based mapping should occur
1247        let test_file = test_dir.join("OtherTest.php");
1248        let test_source =
1249            "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1250        std::fs::write(&test_file, test_source).unwrap();
1251
1252        let ext = PhpExtractor::new();
1253        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1254        let mut test_sources = HashMap::new();
1255        test_sources.insert(
1256            test_file.to_string_lossy().into_owned(),
1257            test_source.to_string(),
1258        );
1259
1260        let mappings =
1261            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1262
1263        let calc_mapping = mappings
1264            .iter()
1265            .find(|m| m.production_file.contains("Calculator.php"))
1266            .expect("expected Calculator.php in mappings");
1267        assert!(
1268            !calc_mapping
1269                .test_files
1270                .iter()
1271                .any(|t| t.contains("OtherTest.php")),
1272            "PHPUnit import should not create a mapping to Calculator.php"
1273        );
1274    }
1275
1276    // -----------------------------------------------------------------------
1277    // PHP-FW-04: symfony/symfony layout -> Symfony import resolves locally
1278    // -----------------------------------------------------------------------
1279    #[test]
1280    fn php_fw_04_symfony_self_test() {
1281        // Given: symfony layout with src/Symfony/Component/HttpFoundation/Request.php
1282        //        and tests/HttpFoundation/RequestTest.php importing
1283        //        `use Symfony\Component\HttpFoundation\Request`
1284        // When: map_test_files_with_imports is called
1285        // Then: RequestTest.php is mapped to Request.php via Layer 2
1286        let dir = tempfile::tempdir().expect("failed to create tempdir");
1287        let src_dir = dir
1288            .path()
1289            .join("src")
1290            .join("Symfony")
1291            .join("Component")
1292            .join("HttpFoundation");
1293        std::fs::create_dir_all(&src_dir).unwrap();
1294        let test_dir = dir.path().join("tests").join("HttpFoundation");
1295        std::fs::create_dir_all(&test_dir).unwrap();
1296
1297        let prod_file = src_dir.join("Request.php");
1298        std::fs::write(
1299            &prod_file,
1300            "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1301        )
1302        .unwrap();
1303
1304        let test_file = test_dir.join("RequestTest.php");
1305        let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1306        std::fs::write(&test_file, test_source).unwrap();
1307
1308        let ext = PhpExtractor::new();
1309        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1310        let mut test_sources = HashMap::new();
1311        test_sources.insert(
1312            test_file.to_string_lossy().into_owned(),
1313            test_source.to_string(),
1314        );
1315
1316        let mappings =
1317            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1318
1319        let request_mapping = mappings
1320            .iter()
1321            .find(|m| m.production_file.contains("Request.php"))
1322            .expect("expected Request.php in mappings");
1323        assert!(
1324            request_mapping
1325                .test_files
1326                .iter()
1327                .any(|t| t.contains("RequestTest.php")),
1328            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1329            request_mapping.test_files
1330        );
1331    }
1332
1333    // -----------------------------------------------------------------------
1334    // PHP-HELPER-06: tests/Fixtures/SomeHelper.php -> is_non_sut_helper = true
1335    // -----------------------------------------------------------------------
1336    #[test]
1337    fn php_helper_06_fixtures_dir() {
1338        // Given: a file in tests/Fixtures/
1339        // When: is_non_sut_helper is called
1340        // Then: returns true (Fixtures are test infrastructure, not SUT)
1341        assert!(is_non_sut_helper("tests/Fixtures/SomeHelper.php", false));
1342    }
1343
1344    // -----------------------------------------------------------------------
1345    // PHP-HELPER-07: tests/Fixtures/nested/Stub.php -> is_non_sut_helper = true
1346    // -----------------------------------------------------------------------
1347    #[test]
1348    fn php_helper_07_fixtures_nested() {
1349        // Given: a file in tests/Fixtures/nested/
1350        // When: is_non_sut_helper is called
1351        // Then: returns true
1352        assert!(is_non_sut_helper("tests/Fixtures/nested/Stub.php", false));
1353    }
1354
1355    // -----------------------------------------------------------------------
1356    // PHP-HELPER-08: tests/Stubs/UserStub.php -> is_non_sut_helper = true
1357    // -----------------------------------------------------------------------
1358    #[test]
1359    fn php_helper_08_stubs_dir() {
1360        // Given: a file in tests/Stubs/
1361        // When: is_non_sut_helper is called
1362        // Then: returns true (Stubs are test infrastructure, not SUT)
1363        assert!(is_non_sut_helper("tests/Stubs/UserStub.php", false));
1364    }
1365
1366    // -----------------------------------------------------------------------
1367    // PHP-HELPER-09: tests/Stubs/nested/FakeRepo.php -> is_non_sut_helper = true
1368    // -----------------------------------------------------------------------
1369    #[test]
1370    fn php_helper_09_stubs_nested() {
1371        // Given: a file in tests/Stubs/nested/
1372        // When: is_non_sut_helper is called
1373        // Then: returns true
1374        assert!(is_non_sut_helper("tests/Stubs/nested/FakeRepo.php", false));
1375    }
1376
1377    // -----------------------------------------------------------------------
1378    // PHP-HELPER-10: app/Stubs/Template.php -> is_non_sut_helper = false (guard test)
1379    // -----------------------------------------------------------------------
1380    #[test]
1381    fn php_helper_10_non_test_stubs() {
1382        // Given: a file in app/Stubs/ (not under tests/)
1383        // When: is_non_sut_helper is called
1384        // Then: returns false (only tests/ subdirs are filtered)
1385        assert!(!is_non_sut_helper("app/Stubs/Template.php", false));
1386    }
1387
1388    // -----------------------------------------------------------------------
1389    // PHP-PSR4-01: custom_src/ prefix via composer.json -> resolution success
1390    // -----------------------------------------------------------------------
1391    #[test]
1392    fn php_psr4_01_composer_json_resolution() {
1393        // Given: a project with composer.json defining PSR-4 autoload:
1394        //   {"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}
1395        //   production file: custom_src/Models/Order.php
1396        //   test file: tests/OrderTest.php with `use MyApp\Models\Order;`
1397        // When: map_test_files_with_imports is called
1398        // Then: OrderTest.php is matched to Order.php via PSR-4 resolution
1399        let dir = tempfile::tempdir().expect("failed to create tempdir");
1400        let custom_src_dir = dir.path().join("custom_src").join("Models");
1401        std::fs::create_dir_all(&custom_src_dir).unwrap();
1402        let test_dir = dir.path().join("tests");
1403        std::fs::create_dir_all(&test_dir).unwrap();
1404
1405        // Write composer.json with custom PSR-4 prefix
1406        let composer_json = r#"{"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}"#;
1407        std::fs::write(dir.path().join("composer.json"), composer_json).unwrap();
1408
1409        let prod_file = custom_src_dir.join("Order.php");
1410        std::fs::write(
1411            &prod_file,
1412            "<?php\nnamespace MyApp\\Models;\nclass Order {}",
1413        )
1414        .unwrap();
1415
1416        let test_file = test_dir.join("OrderTest.php");
1417        let test_source = "<?php\nuse MyApp\\Models\\Order;\nclass OrderTest extends TestCase {}";
1418        std::fs::write(&test_file, test_source).unwrap();
1419
1420        let ext = PhpExtractor::new();
1421        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1422        let mut test_sources = HashMap::new();
1423        test_sources.insert(
1424            test_file.to_string_lossy().into_owned(),
1425            test_source.to_string(),
1426        );
1427
1428        let mappings =
1429            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1430
1431        let order_mapping = mappings
1432            .iter()
1433            .find(|m| m.production_file.contains("Order.php"))
1434            .expect("expected Order.php in mappings");
1435        assert!(
1436            order_mapping
1437                .test_files
1438                .iter()
1439                .any(|t| t.contains("OrderTest.php")),
1440            "expected OrderTest.php to be mapped to Order.php via PSR-4 composer.json resolution, got: {:?}",
1441            order_mapping.test_files
1442        );
1443    }
1444
1445    // -----------------------------------------------------------------------
1446    // PHP-CLI-01: observe --lang php . -> CLI dispatch verification
1447    // -----------------------------------------------------------------------
1448    #[test]
1449    fn php_cli_01_dispatch() {
1450        // Given: a tempdir with a PHP file
1451        // When: PhpExtractor::map_test_files_with_imports is called on an empty project
1452        // Then: returns an empty (or valid) mapping without panicking
1453        let dir = tempfile::tempdir().expect("failed to create tempdir");
1454        let ext = PhpExtractor::new();
1455        let production_files: Vec<String> = vec![];
1456        let test_sources: HashMap<String, String> = HashMap::new();
1457        let mappings =
1458            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1459        assert!(mappings.is_empty());
1460    }
1461}
1462
1463// ---------------------------------------------------------------------------
1464// PHP-PARENT tests: parent class import propagation (TC-01 to TC-04)
1465// ---------------------------------------------------------------------------
1466
1467#[cfg(test)]
1468mod parent_class_tests {
1469    use super::*;
1470    use std::collections::HashMap;
1471
1472    // -----------------------------------------------------------------------
1473    // TC-01: Given test extends ParentClass in same dir, When parent has
1474    //        `use Illuminate\Foo`, Then Foo is in test's import list
1475    // -----------------------------------------------------------------------
1476    #[test]
1477    fn tc01_parent_imports_propagated_to_child() {
1478        // Given: parent class with Illuminate imports in same directory as child
1479        let dir = tempfile::tempdir().expect("failed to create tempdir");
1480        let test_dir = dir.path().join("tests");
1481        std::fs::create_dir_all(&test_dir).unwrap();
1482
1483        let parent_source = r#"<?php
1484namespace App\Tests;
1485use Illuminate\View\Compilers\BladeCompiler;
1486use Illuminate\Container\Container;
1487use PHPUnit\Framework\TestCase;
1488abstract class AbstractBaseTest extends TestCase {}"#;
1489
1490        let parent_file = test_dir.join("AbstractBaseTest.php");
1491        std::fs::write(&parent_file, parent_source).unwrap();
1492
1493        let child_source = r#"<?php
1494namespace App\Tests;
1495class ChildTest extends AbstractBaseTest {
1496    public function testSomething() { $this->assertTrue(true); }
1497}"#;
1498
1499        // When: extract_parent_class_imports is called on the child
1500        let parent_imports = PhpExtractor::extract_parent_class_imports(
1501            child_source,
1502            &parent_file.parent().unwrap().to_string_lossy(),
1503        );
1504
1505        // Then: Illuminate imports from parent are returned
1506        // (Illuminate is normally external, but parent_class_imports returns raw specifiers
1507        //  from parent file — filtering for L2 is done at the call site)
1508        assert!(
1509            !parent_imports.is_empty(),
1510            "expected parent Illuminate imports to be propagated, got: {parent_imports:?}"
1511        );
1512        let has_blade = parent_imports
1513            .iter()
1514            .any(|(m, _)| m.contains("BladeCompiler") || m.contains("Compilers"));
1515        let has_container = parent_imports.iter().any(|(m, _)| m.contains("Container"));
1516        assert!(
1517            has_blade || has_container,
1518            "expected BladeCompiler or Container in parent imports, got: {parent_imports:?}"
1519        );
1520    }
1521
1522    // -----------------------------------------------------------------------
1523    // TC-02: Given test extends ParentClass, When parent has no production
1524    //        imports, Then no additional imports are added (guard test)
1525    // -----------------------------------------------------------------------
1526    #[test]
1527    fn tc02_parent_with_no_production_imports_adds_nothing() {
1528        // Given: a parent class that only imports PHPUnit (external, no production imports)
1529        let dir = tempfile::tempdir().expect("failed to create tempdir");
1530        let test_dir = dir.path().join("tests");
1531        std::fs::create_dir_all(&test_dir).unwrap();
1532        let app_dir = dir.path().join("app").join("Models");
1533        std::fs::create_dir_all(&app_dir).unwrap();
1534
1535        let parent_source = r#"<?php
1536namespace App\Tests;
1537use PHPUnit\Framework\TestCase;
1538abstract class MinimalBaseTest extends TestCase {}"#;
1539
1540        let parent_file = test_dir.join("MinimalBaseTest.php");
1541        std::fs::write(&parent_file, parent_source).unwrap();
1542
1543        let child_source = r#"<?php
1544namespace App\Tests;
1545use App\Models\Order;
1546class OrderTest extends MinimalBaseTest {
1547    public function testOrder() { $this->assertTrue(true); }
1548}"#;
1549
1550        let child_file = test_dir.join("OrderTest.php");
1551        std::fs::write(&child_file, child_source).unwrap();
1552
1553        let prod_file = app_dir.join("Order.php");
1554        std::fs::write(&prod_file, "<?php\nnamespace App\\Models;\nclass Order {}").unwrap();
1555
1556        let ext = PhpExtractor::new();
1557        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1558        let mut test_sources = HashMap::new();
1559        test_sources.insert(
1560            child_file.to_string_lossy().into_owned(),
1561            child_source.to_string(),
1562        );
1563
1564        // When: map_test_files_with_imports is called (parent has no production imports)
1565        let mappings =
1566            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1567
1568        // Then: OrderTest.php is still matched to Order.php via its own import (L2)
1569        // and parent's lack of production imports does not break anything
1570        let order_mapping = mappings
1571            .iter()
1572            .find(|m| m.production_file.contains("Order.php"))
1573            .expect("expected Order.php in mappings");
1574        assert!(
1575            !order_mapping.test_files.is_empty(),
1576            "expected OrderTest.php to be mapped to Order.php (child's own import), got empty"
1577        );
1578    }
1579
1580    // -----------------------------------------------------------------------
1581    // TC-03: Given test extends external class (PHPUnit\TestCase),
1582    //        When resolve parent, Then skip (external namespace guard)
1583    // -----------------------------------------------------------------------
1584    #[test]
1585    fn tc03_external_parent_class_skipped() {
1586        // Given: a test that extends PHPUnit\Framework\TestCase directly
1587        //        (no local parent file to trace)
1588        let dir = tempfile::tempdir().expect("failed to create tempdir");
1589        let app_dir = dir.path().join("app").join("Services");
1590        std::fs::create_dir_all(&app_dir).unwrap();
1591        let test_dir = dir.path().join("tests");
1592        std::fs::create_dir_all(&test_dir).unwrap();
1593
1594        let prod_file = app_dir.join("PaymentService.php");
1595        std::fs::write(
1596            &prod_file,
1597            "<?php\nnamespace App\\Services;\nclass PaymentService {}",
1598        )
1599        .unwrap();
1600
1601        // This test directly extends TestCase (external), not a local abstract class
1602        let test_source = r#"<?php
1603use PHPUnit\Framework\TestCase;
1604use App\Services\PaymentService;
1605class PaymentServiceTest extends TestCase {
1606    public function testPay() { $this->assertTrue(true); }
1607}"#;
1608        let test_file = test_dir.join("PaymentServiceTest.php");
1609        std::fs::write(&test_file, test_source).unwrap();
1610
1611        let ext = PhpExtractor::new();
1612        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1613        let mut test_sources = HashMap::new();
1614        test_sources.insert(
1615            test_file.to_string_lossy().into_owned(),
1616            test_source.to_string(),
1617        );
1618
1619        // When: map_test_files_with_imports is called
1620        let mappings =
1621            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1622
1623        // Then: PaymentServiceTest.php is matched to PaymentService.php
1624        //       (its own import works; external parent does not cause errors)
1625        let payment_mapping = mappings
1626            .iter()
1627            .find(|m| m.production_file.contains("PaymentService.php"))
1628            .expect("expected PaymentService.php in mappings");
1629        assert!(
1630            payment_mapping
1631                .test_files
1632                .iter()
1633                .any(|t| t.contains("PaymentServiceTest.php")),
1634            "expected PaymentServiceTest.php mapped via own import; got: {:?}",
1635            payment_mapping.test_files
1636        );
1637        // No panic, no infinite loop = external parent was skipped silently
1638    }
1639
1640    // -----------------------------------------------------------------------
1641    // TC-04: Given circular inheritance (A extends B, B extends A),
1642    //        When extract_parent_class_imports is called, Then no infinite loop
1643    // -----------------------------------------------------------------------
1644    #[test]
1645    fn tc04_circular_inheritance_no_infinite_loop() {
1646        // Given: two files that mutually extend each other (pathological case)
1647        let dir = tempfile::tempdir().expect("failed to create tempdir");
1648        let test_dir = dir.path().join("tests");
1649        std::fs::create_dir_all(&test_dir).unwrap();
1650
1651        let a_source = r#"<?php
1652namespace App\Tests;
1653use App\Models\Foo;
1654class ATest extends BTest {}"#;
1655
1656        let b_source = r#"<?php
1657namespace App\Tests;
1658use App\Models\Bar;
1659class BTest extends ATest {}"#;
1660
1661        let a_file = test_dir.join("ATest.php");
1662        let b_file = test_dir.join("BTest.php");
1663        std::fs::write(&a_file, a_source).unwrap();
1664        std::fs::write(&b_file, b_source).unwrap();
1665
1666        // When: extract_parent_class_imports is called on A (which extends B, which extends A)
1667        // Then: returns without infinite loop (function must complete in finite time)
1668        let result =
1669            PhpExtractor::extract_parent_class_imports(a_source, &test_dir.to_string_lossy());
1670
1671        // The result may be empty or contain Bar; crucially it must NOT hang.
1672        // Just asserting this line is reached proves no infinite loop.
1673        let _ = result;
1674    }
1675
1676    // -----------------------------------------------------------------------
1677    // TC-05: Given Laravel observe after fix, When measure recall, Then R > 90%
1678    // -----------------------------------------------------------------------
1679    #[test]
1680    #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
1681    fn tc05_laravel_recall_above_90_percent() {
1682        // Given: Laravel source tree at /tmp/laravel
1683        // When: observe --lang php is run
1684        // Then: Recall > 90% (parent class import propagation resolves AbstractBladeTestCase FN)
1685        // NOTE: This is a placeholder. Actual measurement is done manually via:
1686        //   cargo run -- observe --lang php --format json /tmp/laravel
1687        // and compared against the ground truth in docs/dogfooding-results.md.
1688        unimplemented!(
1689            "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
1690        );
1691    }
1692
1693    // -----------------------------------------------------------------------
1694    // TC-06: Given Laravel observe after fix, When check precision, Then no new FP
1695    // -----------------------------------------------------------------------
1696    #[test]
1697    #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
1698    fn tc06_laravel_no_new_false_positives() {
1699        // Given: Laravel source tree at /tmp/laravel
1700        // When: observe --lang php is run after parent class propagation is implemented
1701        // Then: Precision >= 96% (no new false positives introduced by parent import merging)
1702        // NOTE: This is a placeholder. Actual measurement is done manually.
1703        unimplemented!(
1704            "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
1705        );
1706    }
1707}