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
21fn php_language() -> tree_sitter::Language {
22    tree_sitter_php::LANGUAGE_PHP.into()
23}
24
25fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
26    lock.get_or_init(|| Query::new(&php_language(), source).expect("invalid query"))
27}
28
29// ---------------------------------------------------------------------------
30// Stem helpers
31// ---------------------------------------------------------------------------
32
33/// Extract stem from a PHP test file path.
34/// `tests/UserTest.php` -> `Some("User")`   (Test suffix, PHPUnit)
35/// `tests/user_test.php` -> `Some("user")`  (_test suffix, Pest)
36/// `tests/Unit/OrderServiceTest.php` -> `Some("OrderService")`
37/// `src/User.php` -> `None`
38pub fn test_stem(path: &str) -> Option<&str> {
39    let file_name = Path::new(path).file_name()?.to_str()?;
40    // Must end with .php
41    let stem = file_name.strip_suffix(".php")?;
42
43    // *Test.php (PHPUnit convention)
44    if let Some(rest) = stem.strip_suffix("Test") {
45        if !rest.is_empty() {
46            return Some(rest);
47        }
48    }
49
50    // *_test.php (Pest convention)
51    if let Some(rest) = stem.strip_suffix("_test") {
52        if !rest.is_empty() {
53            return Some(rest);
54        }
55    }
56
57    None
58}
59
60/// Extract stem from a PHP production file path.
61/// `src/User.php` -> `Some("User")`
62/// `src/Models/User.php` -> `Some("User")`
63/// `tests/UserTest.php` -> `None`
64pub fn production_stem(path: &str) -> Option<&str> {
65    // Test files are not production files
66    if test_stem(path).is_some() {
67        return None;
68    }
69
70    let file_name = Path::new(path).file_name()?.to_str()?;
71    let stem = file_name.strip_suffix(".php")?;
72
73    if stem.is_empty() {
74        return None;
75    }
76
77    Some(stem)
78}
79
80/// Check if a file is a non-SUT helper (not subject under test).
81pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
82    // If the file is already known to be a production file, it's not a helper.
83    if is_known_production {
84        return false;
85    }
86
87    let normalized = file_path.replace('\\', "/");
88    let file_name = Path::new(&normalized)
89        .file_name()
90        .and_then(|f| f.to_str())
91        .unwrap_or("");
92
93    // TestCase.php (base test class)
94    if file_name == "TestCase.php" {
95        return true;
96    }
97
98    // *Factory.php in tests/ (Laravel factory)
99    if file_name.ends_with("Factory.php") {
100        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
101        if in_tests {
102            return true;
103        }
104    }
105
106    // Abstract*.php in tests/
107    if file_name.starts_with("Abstract") && file_name.ends_with(".php") {
108        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
109        if in_tests {
110            return true;
111        }
112    }
113
114    // Trait*.php or *Trait.php in tests/ (test traits)
115    let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
116    if in_tests
117        && file_name.ends_with(".php")
118        && (file_name.starts_with("Trait") || file_name.ends_with("Trait.php"))
119    {
120        return true;
121    }
122
123    // Files in tests/Traits/ directory
124    if normalized.contains("/tests/Traits/") || normalized.starts_with("tests/Traits/") {
125        return true;
126    }
127
128    // Kernel.php
129    if file_name == "Kernel.php" {
130        return true;
131    }
132
133    // bootstrap.php or bootstrap/*.php
134    if file_name == "bootstrap.php" {
135        return true;
136    }
137    if normalized.starts_with("bootstrap/") || normalized.contains("/bootstrap/") {
138        return true;
139    }
140
141    false
142}
143
144// ---------------------------------------------------------------------------
145// External package detection
146// ---------------------------------------------------------------------------
147
148/// Known external PHP package namespace prefixes to skip during import resolution.
149const EXTERNAL_NAMESPACES: &[&str] = &[
150    "PHPUnit",
151    "Illuminate",
152    "Symfony",
153    "Doctrine",
154    "Mockery",
155    "Carbon",
156    "Pest",
157    "Laravel",
158    "Monolog",
159    "Psr",
160    "GuzzleHttp",
161    "League",
162    "Ramsey",
163    "Spatie",
164    "Nette",
165    "Webmozart",
166    "PhpParser",
167    "SebastianBergmann",
168];
169
170fn is_external_namespace(namespace: &str, scan_root: Option<&Path>) -> bool {
171    let first_segment = namespace.split('/').next().unwrap_or("");
172    let is_known_external = EXTERNAL_NAMESPACES
173        .iter()
174        .any(|&ext| first_segment.eq_ignore_ascii_case(ext));
175
176    if !is_known_external {
177        return false;
178    }
179
180    // If scan_root is provided, check if the namespace source exists locally.
181    // If it does, this is a framework self-test scenario — treat as internal.
182    if let Some(root) = scan_root {
183        for prefix in &["src", "app", "lib", ""] {
184            let candidate = if prefix.is_empty() {
185                root.join(first_segment)
186            } else {
187                root.join(prefix).join(first_segment)
188            };
189            if candidate.is_dir() {
190                return false;
191            }
192        }
193    }
194
195    true
196}
197
198// ---------------------------------------------------------------------------
199// ObserveExtractor impl
200// ---------------------------------------------------------------------------
201
202impl ObserveExtractor for PhpExtractor {
203    fn extract_production_functions(
204        &self,
205        source: &str,
206        file_path: &str,
207    ) -> Vec<ProductionFunction> {
208        let mut parser = Self::parser();
209        let tree = match parser.parse(source, None) {
210            Some(t) => t,
211            None => return Vec::new(),
212        };
213        let source_bytes = source.as_bytes();
214        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
215
216        let name_idx = query.capture_index_for_name("name");
217        let class_name_idx = query.capture_index_for_name("class_name");
218        let method_name_idx = query.capture_index_for_name("method_name");
219        let function_idx = query.capture_index_for_name("function");
220        let method_idx = query.capture_index_for_name("method");
221
222        let mut cursor = QueryCursor::new();
223        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
224        let mut result = Vec::new();
225
226        while let Some(m) = matches.next() {
227            let mut fn_name: Option<String> = None;
228            let mut class_name: Option<String> = None;
229            let mut line: usize = 1;
230            let mut is_exported = true; // default: top-level functions are exported
231            let mut method_node: Option<tree_sitter::Node> = None;
232
233            for cap in m.captures {
234                let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
235                let node_line = cap.node.start_position().row + 1;
236
237                if name_idx == Some(cap.index) {
238                    fn_name = Some(text);
239                    line = node_line;
240                } else if class_name_idx == Some(cap.index) {
241                    class_name = Some(text);
242                } else if method_name_idx == Some(cap.index) {
243                    fn_name = Some(text);
244                    line = node_line;
245                }
246
247                // Capture method node for visibility check
248                if method_idx == Some(cap.index) {
249                    method_node = Some(cap.node);
250                }
251
252                // Top-level function: always exported
253                if function_idx == Some(cap.index) {
254                    is_exported = true;
255                }
256            }
257
258            // Determine visibility from method node
259            if let Some(method) = method_node {
260                is_exported = has_public_visibility(method, source_bytes);
261            }
262
263            if let Some(name) = fn_name {
264                result.push(ProductionFunction {
265                    name,
266                    file: file_path.to_string(),
267                    line,
268                    class_name,
269                    is_exported,
270                });
271            }
272        }
273
274        result
275    }
276
277    fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
278        // PHP has no relative imports; Layer 2 uses PSR-4 namespace resolution
279        Vec::new()
280    }
281
282    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
283        let mut parser = Self::parser();
284        let tree = match parser.parse(source, None) {
285            Some(t) => t,
286            None => return Vec::new(),
287        };
288        let source_bytes = source.as_bytes();
289        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
290
291        let namespace_path_idx = query.capture_index_for_name("namespace_path");
292
293        let mut cursor = QueryCursor::new();
294        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
295
296        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
297
298        while let Some(m) = matches.next() {
299            for cap in m.captures {
300                if namespace_path_idx != Some(cap.index) {
301                    continue;
302                }
303                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
304                // Convert `App\Models\User` -> `App/Models/User`
305                let fs_path = raw.replace('\\', "/");
306
307                // Skip external packages (no scan_root — trait method, conservative filter)
308                if is_external_namespace(&fs_path, None) {
309                    continue;
310                }
311
312                // Split into module path and symbol
313                // `App/Models/User` -> module=`App/Models`, symbol=`User`
314                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
315                if parts.len() < 2 {
316                    // Single segment (no slash): use as both module and symbol
317                    // e.g., `use User;` -> module="", symbol="User"
318                    // Skip these edge cases
319                    continue;
320                }
321
322                // Find the last '/' to split module from symbol
323                if let Some(last_slash) = fs_path.rfind('/') {
324                    let module_path = &fs_path[..last_slash];
325                    let symbol = &fs_path[last_slash + 1..];
326                    if !module_path.is_empty() && !symbol.is_empty() {
327                        result_map
328                            .entry(module_path.to_string())
329                            .or_default()
330                            .push(symbol.to_string());
331                    }
332                }
333            }
334        }
335
336        result_map.into_iter().collect()
337    }
338
339    fn extract_barrel_re_exports(&self, _source: &str, _file_path: &str) -> Vec<BarrelReExport> {
340        // PHP has no barrel export pattern
341        Vec::new()
342    }
343
344    fn source_extensions(&self) -> &[&str] {
345        &["php"]
346    }
347
348    fn index_file_names(&self) -> &[&str] {
349        // PHP has no index files equivalent
350        &[]
351    }
352
353    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
354        production_stem(path)
355    }
356
357    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
358        test_stem(path)
359    }
360
361    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
362        is_non_sut_helper(file_path, is_known_production)
363    }
364}
365
366// ---------------------------------------------------------------------------
367// Concrete methods (not in trait)
368// ---------------------------------------------------------------------------
369
370impl PhpExtractor {
371    /// Extract all import specifiers without external namespace filtering.
372    /// Returns (module_path, [symbols]) pairs for all `use` statements.
373    fn extract_raw_import_specifiers(source: &str) -> Vec<(String, Vec<String>)> {
374        let mut parser = Self::parser();
375        let tree = match parser.parse(source, None) {
376            Some(t) => t,
377            None => return Vec::new(),
378        };
379        let source_bytes = source.as_bytes();
380        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
381
382        let namespace_path_idx = query.capture_index_for_name("namespace_path");
383
384        let mut cursor = QueryCursor::new();
385        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
386
387        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
388
389        while let Some(m) = matches.next() {
390            for cap in m.captures {
391                if namespace_path_idx != Some(cap.index) {
392                    continue;
393                }
394                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
395                let fs_path = raw.replace('\\', "/");
396
397                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
398                if parts.len() < 2 {
399                    continue;
400                }
401
402                if let Some(last_slash) = fs_path.rfind('/') {
403                    let module_path = &fs_path[..last_slash];
404                    let symbol = &fs_path[last_slash + 1..];
405                    if !module_path.is_empty() && !symbol.is_empty() {
406                        result_map
407                            .entry(module_path.to_string())
408                            .or_default()
409                            .push(symbol.to_string());
410                    }
411                }
412            }
413        }
414
415        result_map.into_iter().collect()
416    }
417
418    /// Layer 1 + Layer 2 (PSR-4): Map test files to production files.
419    pub fn map_test_files_with_imports(
420        &self,
421        production_files: &[String],
422        test_sources: &HashMap<String, String>,
423        scan_root: &Path,
424        l1_exclusive: bool,
425    ) -> Vec<FileMapping> {
426        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
427
428        // Layer 1: filename convention (stem matching)
429        let mut mappings =
430            exspec_core::observe::map_test_files(self, production_files, &test_file_list);
431
432        // Build canonical path -> production index lookup
433        let canonical_root = match scan_root.canonicalize() {
434            Ok(r) => r,
435            Err(_) => return mappings,
436        };
437        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
438        for (idx, prod) in production_files.iter().enumerate() {
439            if let Ok(canonical) = Path::new(prod).canonicalize() {
440                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
441            }
442        }
443
444        // Record Layer 1 matches per production file index
445        let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
446            .iter()
447            .map(|m| m.test_files.iter().cloned().collect())
448            .collect();
449
450        // Collect set of test files matched by L1 for l1_exclusive mode
451        let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
452            .iter()
453            .flat_map(|s| s.iter().cloned())
454            .collect();
455
456        // Layer 2: PSR-4 convention import resolution
457        // Use raw imports (unfiltered) and apply scan_root-aware external filtering
458        for (test_file, source) in test_sources {
459            if l1_exclusive && layer1_matched.contains(test_file) {
460                continue;
461            }
462            let raw_specifiers = Self::extract_raw_import_specifiers(source);
463            let specifiers: Vec<(String, Vec<String>)> = raw_specifiers
464                .into_iter()
465                .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
466                .collect();
467            let mut matched_indices = std::collections::HashSet::<usize>::new();
468
469            for (module_path, _symbols) in &specifiers {
470                // PSR-4 resolution:
471                // `App/Models/User` -> try `src/Models/User.php`, `app/Models/User.php`, etc.
472                //
473                // Strategy: strip the first segment (PSR-4 prefix like "App")
474                // and search for the remaining path under common directories.
475                let parts: Vec<&str> = module_path.splitn(2, '/').collect();
476                let path_without_prefix = if parts.len() == 2 {
477                    parts[1]
478                } else {
479                    module_path
480                };
481
482                // Derive the PHP file name from the last segment of module_path
483                // e.g., `App/Models` -> last segment is `Models` -> file is `Models.php`
484                // But module_path is actually the directory, not the file.
485                // The symbol is in the symbols list, but we need to reconstruct the file path.
486                // Actually, at this point module_path = `App/Models` and symbol could be `User`,
487                // so the full file is `Models/User.php` (without prefix).
488
489                // We need to get the symbols too
490                for symbol in _symbols {
491                    let file_name = format!("{symbol}.php");
492
493                    // Try: <scan_root>/<common_prefix>/<path_without_prefix>/<symbol>.php
494                    let common_prefixes = ["src", "app", "lib", ""];
495                    for prefix in &common_prefixes {
496                        let candidate = if prefix.is_empty() {
497                            canonical_root.join(path_without_prefix).join(&file_name)
498                        } else {
499                            canonical_root
500                                .join(prefix)
501                                .join(path_without_prefix)
502                                .join(&file_name)
503                        };
504
505                        if let Ok(canonical_candidate) = candidate.canonicalize() {
506                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
507                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
508                                matched_indices.insert(idx);
509                            }
510                        }
511                    }
512
513                    // Also try with the first segment kept (in case directory matches namespace 1:1)
514                    // e.g., framework self-tests: `Illuminate/Http` -> `src/Illuminate/Http/Request.php`
515                    for prefix in &common_prefixes {
516                        let candidate = if prefix.is_empty() {
517                            canonical_root.join(module_path).join(&file_name)
518                        } else {
519                            canonical_root
520                                .join(prefix)
521                                .join(module_path)
522                                .join(&file_name)
523                        };
524                        if let Ok(canonical_candidate) = candidate.canonicalize() {
525                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
526                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
527                                matched_indices.insert(idx);
528                            }
529                        }
530                    }
531                }
532            }
533
534            for idx in matched_indices {
535                if !mappings[idx].test_files.contains(test_file) {
536                    mappings[idx].test_files.push(test_file.clone());
537                }
538            }
539        }
540
541        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
542        // set strategy to ImportTracing
543        for (i, mapping) in mappings.iter_mut().enumerate() {
544            let has_layer1 = !layer1_tests_per_prod[i].is_empty();
545            if !has_layer1 && !mapping.test_files.is_empty() {
546                mapping.strategy = MappingStrategy::ImportTracing;
547            }
548        }
549
550        mappings
551    }
552}
553
554// ---------------------------------------------------------------------------
555// Visibility helper
556// ---------------------------------------------------------------------------
557
558/// Check if a PHP method_declaration node has `public` visibility.
559/// Returns true for public, false for private/protected.
560/// If no visibility_modifier child is found, defaults to true (public by convention in PHP).
561fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
562    for i in 0..node.child_count() {
563        if let Some(child) = node.child(i) {
564            if child.kind() == "visibility_modifier" {
565                let text = child.utf8_text(source_bytes).unwrap_or("");
566                return text == "public";
567            }
568        }
569    }
570    // No visibility modifier -> treat as public by default
571    true
572}
573
574// ---------------------------------------------------------------------------
575// Tests
576// ---------------------------------------------------------------------------
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use std::collections::HashMap;
582
583    // -----------------------------------------------------------------------
584    // PHP-STEM-01: tests/UserTest.php -> test_stem = Some("User")
585    // -----------------------------------------------------------------------
586    #[test]
587    fn php_stem_01_test_suffix() {
588        // Given: a file named UserTest.php in tests/
589        // When: test_stem is called
590        // Then: returns Some("User")
591        assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
592    }
593
594    // -----------------------------------------------------------------------
595    // PHP-STEM-02: tests/user_test.php -> test_stem = Some("user")
596    // -----------------------------------------------------------------------
597    #[test]
598    fn php_stem_02_pest_suffix() {
599        // Given: a Pest-style file user_test.php
600        // When: test_stem is called
601        // Then: returns Some("user")
602        assert_eq!(test_stem("tests/user_test.php"), Some("user"));
603    }
604
605    // -----------------------------------------------------------------------
606    // PHP-STEM-03: tests/Unit/OrderServiceTest.php -> test_stem = Some("OrderService")
607    // -----------------------------------------------------------------------
608    #[test]
609    fn php_stem_03_nested() {
610        // Given: a nested test file OrderServiceTest.php
611        // When: test_stem is called
612        // Then: returns Some("OrderService")
613        assert_eq!(
614            test_stem("tests/Unit/OrderServiceTest.php"),
615            Some("OrderService")
616        );
617    }
618
619    // -----------------------------------------------------------------------
620    // PHP-STEM-04: src/User.php -> test_stem = None
621    // -----------------------------------------------------------------------
622    #[test]
623    fn php_stem_04_non_test() {
624        // Given: a production file src/User.php
625        // When: test_stem is called
626        // Then: returns None
627        assert_eq!(test_stem("src/User.php"), None);
628    }
629
630    // -----------------------------------------------------------------------
631    // PHP-STEM-05: src/User.php -> production_stem = Some("User")
632    // -----------------------------------------------------------------------
633    #[test]
634    fn php_stem_05_prod_stem() {
635        // Given: a production file src/User.php
636        // When: production_stem is called
637        // Then: returns Some("User")
638        assert_eq!(production_stem("src/User.php"), Some("User"));
639    }
640
641    // -----------------------------------------------------------------------
642    // PHP-STEM-06: src/Models/User.php -> production_stem = Some("User")
643    // -----------------------------------------------------------------------
644    #[test]
645    fn php_stem_06_prod_nested() {
646        // Given: a nested production file src/Models/User.php
647        // When: production_stem is called
648        // Then: returns Some("User")
649        assert_eq!(production_stem("src/Models/User.php"), Some("User"));
650    }
651
652    // -----------------------------------------------------------------------
653    // PHP-STEM-07: tests/UserTest.php -> production_stem = None
654    // -----------------------------------------------------------------------
655    #[test]
656    fn php_stem_07_test_not_prod() {
657        // Given: a test file tests/UserTest.php
658        // When: production_stem is called
659        // Then: returns None (test files are not production files)
660        assert_eq!(production_stem("tests/UserTest.php"), None);
661    }
662
663    // -----------------------------------------------------------------------
664    // PHP-HELPER-01: tests/TestCase.php -> is_non_sut_helper = true
665    // -----------------------------------------------------------------------
666    #[test]
667    fn php_helper_01_test_case() {
668        // Given: the base test class TestCase.php
669        // When: is_non_sut_helper is called
670        // Then: returns true
671        assert!(is_non_sut_helper("tests/TestCase.php", false));
672    }
673
674    // -----------------------------------------------------------------------
675    // PHP-HELPER-02: tests/UserFactory.php -> is_non_sut_helper = true
676    // -----------------------------------------------------------------------
677    #[test]
678    fn php_helper_02_factory() {
679        // Given: a Laravel factory file in tests/
680        // When: is_non_sut_helper is called
681        // Then: returns true
682        assert!(is_non_sut_helper("tests/UserFactory.php", false));
683    }
684
685    // -----------------------------------------------------------------------
686    // PHP-HELPER-03: src/User.php -> is_non_sut_helper = false
687    // -----------------------------------------------------------------------
688    #[test]
689    fn php_helper_03_production() {
690        // Given: a regular production file
691        // When: is_non_sut_helper is called
692        // Then: returns false
693        assert!(!is_non_sut_helper("src/User.php", false));
694    }
695
696    // -----------------------------------------------------------------------
697    // PHP-HELPER-04: tests/Traits/CreatesUsers.php -> is_non_sut_helper = true
698    // -----------------------------------------------------------------------
699    #[test]
700    fn php_helper_04_test_trait() {
701        // Given: a test trait in tests/Traits/
702        // When: is_non_sut_helper is called
703        // Then: returns true
704        assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
705    }
706
707    // -----------------------------------------------------------------------
708    // PHP-HELPER-05: bootstrap/app.php -> is_non_sut_helper = true
709    // -----------------------------------------------------------------------
710    #[test]
711    fn php_helper_05_bootstrap() {
712        // Given: a bootstrap file
713        // When: is_non_sut_helper is called
714        // Then: returns true
715        assert!(is_non_sut_helper("bootstrap/app.php", false));
716    }
717
718    // -----------------------------------------------------------------------
719    // PHP-FUNC-01: public function createUser() -> name="createUser", is_exported=true
720    // -----------------------------------------------------------------------
721    #[test]
722    fn php_func_01_public_method() {
723        // Given: a class with a public method
724        // When: extract_production_functions is called
725        // Then: name="createUser", is_exported=true
726        let ext = PhpExtractor::new();
727        let source = "<?php\nclass User {\n    public function createUser() {}\n}";
728        let fns = ext.extract_production_functions(source, "src/User.php");
729        let f = fns.iter().find(|f| f.name == "createUser").unwrap();
730        assert!(f.is_exported);
731    }
732
733    // -----------------------------------------------------------------------
734    // PHP-FUNC-02: private function helper() -> name="helper", is_exported=false
735    // -----------------------------------------------------------------------
736    #[test]
737    fn php_func_02_private_method() {
738        // Given: a class with a private method
739        // When: extract_production_functions is called
740        // Then: name="helper", is_exported=false
741        let ext = PhpExtractor::new();
742        let source = "<?php\nclass User {\n    private function helper() {}\n}";
743        let fns = ext.extract_production_functions(source, "src/User.php");
744        let f = fns.iter().find(|f| f.name == "helper").unwrap();
745        assert!(!f.is_exported);
746    }
747
748    // -----------------------------------------------------------------------
749    // PHP-FUNC-03: class User { public function save() } -> class_name=Some("User")
750    // -----------------------------------------------------------------------
751    #[test]
752    fn php_func_03_class_method() {
753        // Given: a class User with a public method save()
754        // When: extract_production_functions is called
755        // Then: name="save", class_name=Some("User")
756        let ext = PhpExtractor::new();
757        let source = "<?php\nclass User {\n    public function save() {}\n}";
758        let fns = ext.extract_production_functions(source, "src/User.php");
759        let f = fns.iter().find(|f| f.name == "save").unwrap();
760        assert_eq!(f.class_name, Some("User".to_string()));
761    }
762
763    // -----------------------------------------------------------------------
764    // PHP-FUNC-04: function global_helper() (top-level) -> exported
765    // -----------------------------------------------------------------------
766    #[test]
767    fn php_func_04_top_level_function() {
768        // Given: a top-level function global_helper()
769        // When: extract_production_functions is called
770        // Then: name="global_helper", is_exported=true
771        let ext = PhpExtractor::new();
772        let source = "<?php\nfunction global_helper() {\n    return 42;\n}";
773        let fns = ext.extract_production_functions(source, "src/helpers.php");
774        let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
775        assert!(f.is_exported);
776        assert_eq!(f.class_name, None);
777    }
778
779    // -----------------------------------------------------------------------
780    // PHP-IMP-01: use App\Models\User; -> ("App/Models", ["User"])
781    // -----------------------------------------------------------------------
782    #[test]
783    fn php_imp_01_app_models() {
784        // Given: a use statement for App\Models\User
785        // When: extract_all_import_specifiers is called
786        // Then: returns ("App/Models", ["User"])
787        let ext = PhpExtractor::new();
788        let source = "<?php\nuse App\\Models\\User;\n";
789        let imports = ext.extract_all_import_specifiers(source);
790        assert!(
791            imports
792                .iter()
793                .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
794            "expected App/Models -> [User], got: {imports:?}"
795        );
796    }
797
798    // -----------------------------------------------------------------------
799    // PHP-IMP-02: use App\Services\UserService; -> ("App/Services", ["UserService"])
800    // -----------------------------------------------------------------------
801    #[test]
802    fn php_imp_02_app_services() {
803        // Given: a use statement for App\Services\UserService
804        // When: extract_all_import_specifiers is called
805        // Then: returns ("App/Services", ["UserService"])
806        let ext = PhpExtractor::new();
807        let source = "<?php\nuse App\\Services\\UserService;\n";
808        let imports = ext.extract_all_import_specifiers(source);
809        assert!(
810            imports
811                .iter()
812                .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
813            "expected App/Services -> [UserService], got: {imports:?}"
814        );
815    }
816
817    // -----------------------------------------------------------------------
818    // PHP-IMP-03: use PHPUnit\Framework\TestCase; -> external package -> skipped
819    // -----------------------------------------------------------------------
820    #[test]
821    fn php_imp_03_external_phpunit() {
822        // Given: a use statement for external PHPUnit package
823        // When: extract_all_import_specifiers is called
824        // Then: returns empty (external packages are filtered)
825        let ext = PhpExtractor::new();
826        let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
827        let imports = ext.extract_all_import_specifiers(source);
828        assert!(
829            imports.is_empty(),
830            "external PHPUnit should be filtered, got: {imports:?}"
831        );
832    }
833
834    // -----------------------------------------------------------------------
835    // PHP-IMP-04: use Illuminate\Http\Request; -> external package -> skipped
836    // -----------------------------------------------------------------------
837    #[test]
838    fn php_imp_04_external_illuminate() {
839        // Given: a use statement for external Illuminate (Laravel) package
840        // When: extract_all_import_specifiers is called
841        // Then: returns empty (external packages are filtered)
842        let ext = PhpExtractor::new();
843        let source = "<?php\nuse Illuminate\\Http\\Request;\n";
844        let imports = ext.extract_all_import_specifiers(source);
845        assert!(
846            imports.is_empty(),
847            "external Illuminate should be filtered, got: {imports:?}"
848        );
849    }
850
851    // -----------------------------------------------------------------------
852    // PHP-E2E-01: User.php + UserTest.php in the same directory -> Layer 1 stem match
853    // -----------------------------------------------------------------------
854    #[test]
855    fn php_e2e_01_stem_match() {
856        // Given: production file User.php and test file UserTest.php in the same directory
857        // (Layer 1 stem matching works when files share the same parent directory)
858        // When: map_test_files_with_imports is called
859        // Then: UserTest.php is matched to User.php via Layer 1 stem matching
860        let dir = tempfile::tempdir().expect("failed to create tempdir");
861
862        let prod_file = dir.path().join("User.php");
863        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
864
865        let test_file = dir.path().join("UserTest.php");
866        std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
867
868        let ext = PhpExtractor::new();
869        let production_files = vec![prod_file.to_string_lossy().into_owned()];
870        let mut test_sources = HashMap::new();
871        test_sources.insert(
872            test_file.to_string_lossy().into_owned(),
873            "<?php\nclass UserTest extends TestCase {}".to_string(),
874        );
875
876        let mappings =
877            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
878
879        assert!(!mappings.is_empty(), "expected at least one mapping");
880        let user_mapping = mappings
881            .iter()
882            .find(|m| m.production_file.contains("User.php"))
883            .expect("expected User.php in mappings");
884        assert!(
885            !user_mapping.test_files.is_empty(),
886            "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
887        );
888    }
889
890    // -----------------------------------------------------------------------
891    // PHP-E2E-02: tests/ServiceTest.php imports use App\Services\OrderService
892    //             -> Layer 2 PSR-4 import match
893    // -----------------------------------------------------------------------
894    #[test]
895    fn php_e2e_02_import_match() {
896        // Given: production file app/Services/OrderService.php
897        //        and test file tests/ServiceTest.php with `use App\Services\OrderService;`
898        // When: map_test_files_with_imports is called
899        // Then: ServiceTest.php is matched to OrderService.php via Layer 2 import tracing
900        let dir = tempfile::tempdir().expect("failed to create tempdir");
901        let services_dir = dir.path().join("app").join("Services");
902        std::fs::create_dir_all(&services_dir).unwrap();
903        let test_dir = dir.path().join("tests");
904        std::fs::create_dir_all(&test_dir).unwrap();
905
906        let prod_file = services_dir.join("OrderService.php");
907        std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
908
909        let test_file = test_dir.join("ServiceTest.php");
910        let test_source =
911            "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
912        std::fs::write(&test_file, test_source).unwrap();
913
914        let ext = PhpExtractor::new();
915        let production_files = vec![prod_file.to_string_lossy().into_owned()];
916        let mut test_sources = HashMap::new();
917        test_sources.insert(
918            test_file.to_string_lossy().into_owned(),
919            test_source.to_string(),
920        );
921
922        let mappings =
923            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
924
925        let order_mapping = mappings
926            .iter()
927            .find(|m| m.production_file.contains("OrderService.php"))
928            .expect("expected OrderService.php in mappings");
929        assert!(
930            !order_mapping.test_files.is_empty(),
931            "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
932        );
933    }
934
935    // -----------------------------------------------------------------------
936    // PHP-E2E-03: tests/TestCase.php -> helper exclusion
937    // -----------------------------------------------------------------------
938    #[test]
939    fn php_e2e_03_helper_exclusion() {
940        // Given: a TestCase.php base class in tests/
941        // When: map_test_files_with_imports is called
942        // Then: TestCase.php is excluded (is_non_sut_helper = true)
943        let dir = tempfile::tempdir().expect("failed to create tempdir");
944        let src_dir = dir.path().join("src");
945        std::fs::create_dir_all(&src_dir).unwrap();
946        let test_dir = dir.path().join("tests");
947        std::fs::create_dir_all(&test_dir).unwrap();
948
949        let prod_file = src_dir.join("User.php");
950        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
951
952        // TestCase.php should be treated as a helper, not a test file
953        let test_case_file = test_dir.join("TestCase.php");
954        std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
955
956        let ext = PhpExtractor::new();
957        let production_files = vec![prod_file.to_string_lossy().into_owned()];
958        let mut test_sources = HashMap::new();
959        test_sources.insert(
960            test_case_file.to_string_lossy().into_owned(),
961            "<?php\nabstract class TestCase {}".to_string(),
962        );
963
964        let mappings =
965            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
966
967        // TestCase.php should not be matched to User.php
968        let user_mapping = mappings
969            .iter()
970            .find(|m| m.production_file.contains("User.php"));
971        if let Some(mapping) = user_mapping {
972            assert!(
973                mapping.test_files.is_empty()
974                    || !mapping
975                        .test_files
976                        .iter()
977                        .any(|t| t.contains("TestCase.php")),
978                "TestCase.php should not be mapped as a test file for User.php"
979            );
980        }
981    }
982
983    // -----------------------------------------------------------------------
984    // PHP-FW-01: laravel/framework layout -> Illuminate import resolves locally
985    // -----------------------------------------------------------------------
986    #[test]
987    fn php_fw_01_laravel_framework_self_test() {
988        // Given: laravel/framework layout with src/Illuminate/Http/Request.php
989        //        and tests/Http/RequestTest.php importing `use Illuminate\Http\Request`
990        // When: map_test_files_with_imports is called
991        // Then: RequestTest.php is mapped to Request.php via Layer 2
992        let dir = tempfile::tempdir().expect("failed to create tempdir");
993        let src_dir = dir.path().join("src").join("Illuminate").join("Http");
994        std::fs::create_dir_all(&src_dir).unwrap();
995        let test_dir = dir.path().join("tests").join("Http");
996        std::fs::create_dir_all(&test_dir).unwrap();
997
998        let prod_file = src_dir.join("Request.php");
999        std::fs::write(
1000            &prod_file,
1001            "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1002        )
1003        .unwrap();
1004
1005        let test_file = test_dir.join("RequestTest.php");
1006        let test_source =
1007            "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1008        std::fs::write(&test_file, test_source).unwrap();
1009
1010        let ext = PhpExtractor::new();
1011        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1012        let mut test_sources = HashMap::new();
1013        test_sources.insert(
1014            test_file.to_string_lossy().into_owned(),
1015            test_source.to_string(),
1016        );
1017
1018        let mappings =
1019            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1020
1021        let request_mapping = mappings
1022            .iter()
1023            .find(|m| m.production_file.contains("Request.php"))
1024            .expect("expected Request.php in mappings");
1025        assert!(
1026            request_mapping
1027                .test_files
1028                .iter()
1029                .any(|t| t.contains("RequestTest.php")),
1030            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1031            request_mapping.test_files
1032        );
1033    }
1034
1035    // -----------------------------------------------------------------------
1036    // PHP-FW-02: normal app -> Illuminate import still filtered (no local source)
1037    // -----------------------------------------------------------------------
1038    #[test]
1039    fn php_fw_02_normal_app_illuminate_filtered() {
1040        // Given: normal app layout with app/Models/User.php
1041        //        and tests/UserTest.php importing `use Illuminate\Http\Request`
1042        //        (no local Illuminate directory)
1043        // When: map_test_files_with_imports is called
1044        // Then: Illuminate import is NOT resolved (no mapping via import)
1045        let dir = tempfile::tempdir().expect("failed to create tempdir");
1046        let app_dir = dir.path().join("app").join("Models");
1047        std::fs::create_dir_all(&app_dir).unwrap();
1048        let test_dir = dir.path().join("tests");
1049        std::fs::create_dir_all(&test_dir).unwrap();
1050
1051        let prod_file = app_dir.join("User.php");
1052        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1053
1054        // This test imports Illuminate but there's no local Illuminate source
1055        let test_file = test_dir.join("OrderTest.php");
1056        let test_source =
1057            "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1058        std::fs::write(&test_file, test_source).unwrap();
1059
1060        let ext = PhpExtractor::new();
1061        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1062        let mut test_sources = HashMap::new();
1063        test_sources.insert(
1064            test_file.to_string_lossy().into_owned(),
1065            test_source.to_string(),
1066        );
1067
1068        let mappings =
1069            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1070
1071        // User.php should not have OrderTest.php mapped (no stem match, no import match)
1072        let user_mapping = mappings
1073            .iter()
1074            .find(|m| m.production_file.contains("User.php"))
1075            .expect("expected User.php in mappings");
1076        assert!(
1077            !user_mapping
1078                .test_files
1079                .iter()
1080                .any(|t| t.contains("OrderTest.php")),
1081            "Illuminate import should be filtered when no local source exists"
1082        );
1083    }
1084
1085    // -----------------------------------------------------------------------
1086    // PHP-FW-03: PHPUnit import still filtered via integration test (regression)
1087    // -----------------------------------------------------------------------
1088    #[test]
1089    fn php_fw_03_phpunit_still_external() {
1090        // Given: app with src/Calculator.php and tests/CalculatorTest.php
1091        //        importing only `use PHPUnit\Framework\TestCase` (no local PHPUnit source)
1092        // When: map_test_files_with_imports is called
1093        // Then: PHPUnit import does not create a false mapping
1094        let dir = tempfile::tempdir().expect("failed to create tempdir");
1095        let src_dir = dir.path().join("src");
1096        std::fs::create_dir_all(&src_dir).unwrap();
1097        let test_dir = dir.path().join("tests");
1098        std::fs::create_dir_all(&test_dir).unwrap();
1099
1100        let prod_file = src_dir.join("Calculator.php");
1101        std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1102
1103        // Test imports only PHPUnit (external) — no import-based mapping should occur
1104        let test_file = test_dir.join("OtherTest.php");
1105        let test_source =
1106            "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1107        std::fs::write(&test_file, test_source).unwrap();
1108
1109        let ext = PhpExtractor::new();
1110        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1111        let mut test_sources = HashMap::new();
1112        test_sources.insert(
1113            test_file.to_string_lossy().into_owned(),
1114            test_source.to_string(),
1115        );
1116
1117        let mappings =
1118            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1119
1120        let calc_mapping = mappings
1121            .iter()
1122            .find(|m| m.production_file.contains("Calculator.php"))
1123            .expect("expected Calculator.php in mappings");
1124        assert!(
1125            !calc_mapping
1126                .test_files
1127                .iter()
1128                .any(|t| t.contains("OtherTest.php")),
1129            "PHPUnit import should not create a mapping to Calculator.php"
1130        );
1131    }
1132
1133    // -----------------------------------------------------------------------
1134    // PHP-FW-04: symfony/symfony layout -> Symfony import resolves locally
1135    // -----------------------------------------------------------------------
1136    #[test]
1137    fn php_fw_04_symfony_self_test() {
1138        // Given: symfony layout with src/Symfony/Component/HttpFoundation/Request.php
1139        //        and tests/HttpFoundation/RequestTest.php importing
1140        //        `use Symfony\Component\HttpFoundation\Request`
1141        // When: map_test_files_with_imports is called
1142        // Then: RequestTest.php is mapped to Request.php via Layer 2
1143        let dir = tempfile::tempdir().expect("failed to create tempdir");
1144        let src_dir = dir
1145            .path()
1146            .join("src")
1147            .join("Symfony")
1148            .join("Component")
1149            .join("HttpFoundation");
1150        std::fs::create_dir_all(&src_dir).unwrap();
1151        let test_dir = dir.path().join("tests").join("HttpFoundation");
1152        std::fs::create_dir_all(&test_dir).unwrap();
1153
1154        let prod_file = src_dir.join("Request.php");
1155        std::fs::write(
1156            &prod_file,
1157            "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1158        )
1159        .unwrap();
1160
1161        let test_file = test_dir.join("RequestTest.php");
1162        let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1163        std::fs::write(&test_file, test_source).unwrap();
1164
1165        let ext = PhpExtractor::new();
1166        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1167        let mut test_sources = HashMap::new();
1168        test_sources.insert(
1169            test_file.to_string_lossy().into_owned(),
1170            test_source.to_string(),
1171        );
1172
1173        let mappings =
1174            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1175
1176        let request_mapping = mappings
1177            .iter()
1178            .find(|m| m.production_file.contains("Request.php"))
1179            .expect("expected Request.php in mappings");
1180        assert!(
1181            request_mapping
1182                .test_files
1183                .iter()
1184                .any(|t| t.contains("RequestTest.php")),
1185            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1186            request_mapping.test_files
1187        );
1188    }
1189
1190    // -----------------------------------------------------------------------
1191    // PHP-CLI-01: observe --lang php . -> CLI dispatch verification
1192    // -----------------------------------------------------------------------
1193    #[test]
1194    fn php_cli_01_dispatch() {
1195        // Given: a tempdir with a PHP file
1196        // When: PhpExtractor::map_test_files_with_imports is called on an empty project
1197        // Then: returns an empty (or valid) mapping without panicking
1198        let dir = tempfile::tempdir().expect("failed to create tempdir");
1199        let ext = PhpExtractor::new();
1200        let production_files: Vec<String> = vec![];
1201        let test_sources: HashMap<String, String> = HashMap::new();
1202        let mappings =
1203            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1204        assert!(mappings.is_empty());
1205    }
1206}