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    ) -> Vec<FileMapping> {
425        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
426
427        // Layer 1: filename convention (stem matching)
428        let mut mappings =
429            exspec_core::observe::map_test_files(self, production_files, &test_file_list);
430
431        // Build canonical path -> production index lookup
432        let canonical_root = match scan_root.canonicalize() {
433            Ok(r) => r,
434            Err(_) => return mappings,
435        };
436        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
437        for (idx, prod) in production_files.iter().enumerate() {
438            if let Ok(canonical) = Path::new(prod).canonicalize() {
439                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
440            }
441        }
442
443        // Record Layer 1 matches per production file index
444        let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
445            .iter()
446            .map(|m| m.test_files.iter().cloned().collect())
447            .collect();
448
449        // Layer 2: PSR-4 convention import resolution
450        // Use raw imports (unfiltered) and apply scan_root-aware external filtering
451        for (test_file, source) in test_sources {
452            let raw_specifiers = Self::extract_raw_import_specifiers(source);
453            let specifiers: Vec<(String, Vec<String>)> = raw_specifiers
454                .into_iter()
455                .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
456                .collect();
457            let mut matched_indices = std::collections::HashSet::<usize>::new();
458
459            for (module_path, _symbols) in &specifiers {
460                // PSR-4 resolution:
461                // `App/Models/User` -> try `src/Models/User.php`, `app/Models/User.php`, etc.
462                //
463                // Strategy: strip the first segment (PSR-4 prefix like "App")
464                // and search for the remaining path under common directories.
465                let parts: Vec<&str> = module_path.splitn(2, '/').collect();
466                let path_without_prefix = if parts.len() == 2 {
467                    parts[1]
468                } else {
469                    module_path
470                };
471
472                // Derive the PHP file name from the last segment of module_path
473                // e.g., `App/Models` -> last segment is `Models` -> file is `Models.php`
474                // But module_path is actually the directory, not the file.
475                // The symbol is in the symbols list, but we need to reconstruct the file path.
476                // Actually, at this point module_path = `App/Models` and symbol could be `User`,
477                // so the full file is `Models/User.php` (without prefix).
478
479                // We need to get the symbols too
480                for symbol in _symbols {
481                    let file_name = format!("{symbol}.php");
482
483                    // Try: <scan_root>/<common_prefix>/<path_without_prefix>/<symbol>.php
484                    let common_prefixes = ["src", "app", "lib", ""];
485                    for prefix in &common_prefixes {
486                        let candidate = if prefix.is_empty() {
487                            canonical_root.join(path_without_prefix).join(&file_name)
488                        } else {
489                            canonical_root
490                                .join(prefix)
491                                .join(path_without_prefix)
492                                .join(&file_name)
493                        };
494
495                        if let Ok(canonical_candidate) = candidate.canonicalize() {
496                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
497                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
498                                matched_indices.insert(idx);
499                            }
500                        }
501                    }
502
503                    // Also try with the first segment kept (in case directory matches namespace 1:1)
504                    // e.g., framework self-tests: `Illuminate/Http` -> `src/Illuminate/Http/Request.php`
505                    for prefix in &common_prefixes {
506                        let candidate = if prefix.is_empty() {
507                            canonical_root.join(module_path).join(&file_name)
508                        } else {
509                            canonical_root
510                                .join(prefix)
511                                .join(module_path)
512                                .join(&file_name)
513                        };
514                        if let Ok(canonical_candidate) = candidate.canonicalize() {
515                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
516                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
517                                matched_indices.insert(idx);
518                            }
519                        }
520                    }
521                }
522            }
523
524            for idx in matched_indices {
525                if !mappings[idx].test_files.contains(test_file) {
526                    mappings[idx].test_files.push(test_file.clone());
527                }
528            }
529        }
530
531        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
532        // set strategy to ImportTracing
533        for (i, mapping) in mappings.iter_mut().enumerate() {
534            let has_layer1 = !layer1_tests_per_prod[i].is_empty();
535            if !has_layer1 && !mapping.test_files.is_empty() {
536                mapping.strategy = MappingStrategy::ImportTracing;
537            }
538        }
539
540        mappings
541    }
542}
543
544// ---------------------------------------------------------------------------
545// Visibility helper
546// ---------------------------------------------------------------------------
547
548/// Check if a PHP method_declaration node has `public` visibility.
549/// Returns true for public, false for private/protected.
550/// If no visibility_modifier child is found, defaults to true (public by convention in PHP).
551fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
552    for i in 0..node.child_count() {
553        if let Some(child) = node.child(i) {
554            if child.kind() == "visibility_modifier" {
555                let text = child.utf8_text(source_bytes).unwrap_or("");
556                return text == "public";
557            }
558        }
559    }
560    // No visibility modifier -> treat as public by default
561    true
562}
563
564// ---------------------------------------------------------------------------
565// Tests
566// ---------------------------------------------------------------------------
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use std::collections::HashMap;
572
573    // -----------------------------------------------------------------------
574    // PHP-STEM-01: tests/UserTest.php -> test_stem = Some("User")
575    // -----------------------------------------------------------------------
576    #[test]
577    fn php_stem_01_test_suffix() {
578        // Given: a file named UserTest.php in tests/
579        // When: test_stem is called
580        // Then: returns Some("User")
581        assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
582    }
583
584    // -----------------------------------------------------------------------
585    // PHP-STEM-02: tests/user_test.php -> test_stem = Some("user")
586    // -----------------------------------------------------------------------
587    #[test]
588    fn php_stem_02_pest_suffix() {
589        // Given: a Pest-style file user_test.php
590        // When: test_stem is called
591        // Then: returns Some("user")
592        assert_eq!(test_stem("tests/user_test.php"), Some("user"));
593    }
594
595    // -----------------------------------------------------------------------
596    // PHP-STEM-03: tests/Unit/OrderServiceTest.php -> test_stem = Some("OrderService")
597    // -----------------------------------------------------------------------
598    #[test]
599    fn php_stem_03_nested() {
600        // Given: a nested test file OrderServiceTest.php
601        // When: test_stem is called
602        // Then: returns Some("OrderService")
603        assert_eq!(
604            test_stem("tests/Unit/OrderServiceTest.php"),
605            Some("OrderService")
606        );
607    }
608
609    // -----------------------------------------------------------------------
610    // PHP-STEM-04: src/User.php -> test_stem = None
611    // -----------------------------------------------------------------------
612    #[test]
613    fn php_stem_04_non_test() {
614        // Given: a production file src/User.php
615        // When: test_stem is called
616        // Then: returns None
617        assert_eq!(test_stem("src/User.php"), None);
618    }
619
620    // -----------------------------------------------------------------------
621    // PHP-STEM-05: src/User.php -> production_stem = Some("User")
622    // -----------------------------------------------------------------------
623    #[test]
624    fn php_stem_05_prod_stem() {
625        // Given: a production file src/User.php
626        // When: production_stem is called
627        // Then: returns Some("User")
628        assert_eq!(production_stem("src/User.php"), Some("User"));
629    }
630
631    // -----------------------------------------------------------------------
632    // PHP-STEM-06: src/Models/User.php -> production_stem = Some("User")
633    // -----------------------------------------------------------------------
634    #[test]
635    fn php_stem_06_prod_nested() {
636        // Given: a nested production file src/Models/User.php
637        // When: production_stem is called
638        // Then: returns Some("User")
639        assert_eq!(production_stem("src/Models/User.php"), Some("User"));
640    }
641
642    // -----------------------------------------------------------------------
643    // PHP-STEM-07: tests/UserTest.php -> production_stem = None
644    // -----------------------------------------------------------------------
645    #[test]
646    fn php_stem_07_test_not_prod() {
647        // Given: a test file tests/UserTest.php
648        // When: production_stem is called
649        // Then: returns None (test files are not production files)
650        assert_eq!(production_stem("tests/UserTest.php"), None);
651    }
652
653    // -----------------------------------------------------------------------
654    // PHP-HELPER-01: tests/TestCase.php -> is_non_sut_helper = true
655    // -----------------------------------------------------------------------
656    #[test]
657    fn php_helper_01_test_case() {
658        // Given: the base test class TestCase.php
659        // When: is_non_sut_helper is called
660        // Then: returns true
661        assert!(is_non_sut_helper("tests/TestCase.php", false));
662    }
663
664    // -----------------------------------------------------------------------
665    // PHP-HELPER-02: tests/UserFactory.php -> is_non_sut_helper = true
666    // -----------------------------------------------------------------------
667    #[test]
668    fn php_helper_02_factory() {
669        // Given: a Laravel factory file in tests/
670        // When: is_non_sut_helper is called
671        // Then: returns true
672        assert!(is_non_sut_helper("tests/UserFactory.php", false));
673    }
674
675    // -----------------------------------------------------------------------
676    // PHP-HELPER-03: src/User.php -> is_non_sut_helper = false
677    // -----------------------------------------------------------------------
678    #[test]
679    fn php_helper_03_production() {
680        // Given: a regular production file
681        // When: is_non_sut_helper is called
682        // Then: returns false
683        assert!(!is_non_sut_helper("src/User.php", false));
684    }
685
686    // -----------------------------------------------------------------------
687    // PHP-HELPER-04: tests/Traits/CreatesUsers.php -> is_non_sut_helper = true
688    // -----------------------------------------------------------------------
689    #[test]
690    fn php_helper_04_test_trait() {
691        // Given: a test trait in tests/Traits/
692        // When: is_non_sut_helper is called
693        // Then: returns true
694        assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
695    }
696
697    // -----------------------------------------------------------------------
698    // PHP-HELPER-05: bootstrap/app.php -> is_non_sut_helper = true
699    // -----------------------------------------------------------------------
700    #[test]
701    fn php_helper_05_bootstrap() {
702        // Given: a bootstrap file
703        // When: is_non_sut_helper is called
704        // Then: returns true
705        assert!(is_non_sut_helper("bootstrap/app.php", false));
706    }
707
708    // -----------------------------------------------------------------------
709    // PHP-FUNC-01: public function createUser() -> name="createUser", is_exported=true
710    // -----------------------------------------------------------------------
711    #[test]
712    fn php_func_01_public_method() {
713        // Given: a class with a public method
714        // When: extract_production_functions is called
715        // Then: name="createUser", is_exported=true
716        let ext = PhpExtractor::new();
717        let source = "<?php\nclass User {\n    public function createUser() {}\n}";
718        let fns = ext.extract_production_functions(source, "src/User.php");
719        let f = fns.iter().find(|f| f.name == "createUser").unwrap();
720        assert!(f.is_exported);
721    }
722
723    // -----------------------------------------------------------------------
724    // PHP-FUNC-02: private function helper() -> name="helper", is_exported=false
725    // -----------------------------------------------------------------------
726    #[test]
727    fn php_func_02_private_method() {
728        // Given: a class with a private method
729        // When: extract_production_functions is called
730        // Then: name="helper", is_exported=false
731        let ext = PhpExtractor::new();
732        let source = "<?php\nclass User {\n    private function helper() {}\n}";
733        let fns = ext.extract_production_functions(source, "src/User.php");
734        let f = fns.iter().find(|f| f.name == "helper").unwrap();
735        assert!(!f.is_exported);
736    }
737
738    // -----------------------------------------------------------------------
739    // PHP-FUNC-03: class User { public function save() } -> class_name=Some("User")
740    // -----------------------------------------------------------------------
741    #[test]
742    fn php_func_03_class_method() {
743        // Given: a class User with a public method save()
744        // When: extract_production_functions is called
745        // Then: name="save", class_name=Some("User")
746        let ext = PhpExtractor::new();
747        let source = "<?php\nclass User {\n    public function save() {}\n}";
748        let fns = ext.extract_production_functions(source, "src/User.php");
749        let f = fns.iter().find(|f| f.name == "save").unwrap();
750        assert_eq!(f.class_name, Some("User".to_string()));
751    }
752
753    // -----------------------------------------------------------------------
754    // PHP-FUNC-04: function global_helper() (top-level) -> exported
755    // -----------------------------------------------------------------------
756    #[test]
757    fn php_func_04_top_level_function() {
758        // Given: a top-level function global_helper()
759        // When: extract_production_functions is called
760        // Then: name="global_helper", is_exported=true
761        let ext = PhpExtractor::new();
762        let source = "<?php\nfunction global_helper() {\n    return 42;\n}";
763        let fns = ext.extract_production_functions(source, "src/helpers.php");
764        let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
765        assert!(f.is_exported);
766        assert_eq!(f.class_name, None);
767    }
768
769    // -----------------------------------------------------------------------
770    // PHP-IMP-01: use App\Models\User; -> ("App/Models", ["User"])
771    // -----------------------------------------------------------------------
772    #[test]
773    fn php_imp_01_app_models() {
774        // Given: a use statement for App\Models\User
775        // When: extract_all_import_specifiers is called
776        // Then: returns ("App/Models", ["User"])
777        let ext = PhpExtractor::new();
778        let source = "<?php\nuse App\\Models\\User;\n";
779        let imports = ext.extract_all_import_specifiers(source);
780        assert!(
781            imports
782                .iter()
783                .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
784            "expected App/Models -> [User], got: {imports:?}"
785        );
786    }
787
788    // -----------------------------------------------------------------------
789    // PHP-IMP-02: use App\Services\UserService; -> ("App/Services", ["UserService"])
790    // -----------------------------------------------------------------------
791    #[test]
792    fn php_imp_02_app_services() {
793        // Given: a use statement for App\Services\UserService
794        // When: extract_all_import_specifiers is called
795        // Then: returns ("App/Services", ["UserService"])
796        let ext = PhpExtractor::new();
797        let source = "<?php\nuse App\\Services\\UserService;\n";
798        let imports = ext.extract_all_import_specifiers(source);
799        assert!(
800            imports
801                .iter()
802                .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
803            "expected App/Services -> [UserService], got: {imports:?}"
804        );
805    }
806
807    // -----------------------------------------------------------------------
808    // PHP-IMP-03: use PHPUnit\Framework\TestCase; -> external package -> skipped
809    // -----------------------------------------------------------------------
810    #[test]
811    fn php_imp_03_external_phpunit() {
812        // Given: a use statement for external PHPUnit package
813        // When: extract_all_import_specifiers is called
814        // Then: returns empty (external packages are filtered)
815        let ext = PhpExtractor::new();
816        let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
817        let imports = ext.extract_all_import_specifiers(source);
818        assert!(
819            imports.is_empty(),
820            "external PHPUnit should be filtered, got: {imports:?}"
821        );
822    }
823
824    // -----------------------------------------------------------------------
825    // PHP-IMP-04: use Illuminate\Http\Request; -> external package -> skipped
826    // -----------------------------------------------------------------------
827    #[test]
828    fn php_imp_04_external_illuminate() {
829        // Given: a use statement for external Illuminate (Laravel) package
830        // When: extract_all_import_specifiers is called
831        // Then: returns empty (external packages are filtered)
832        let ext = PhpExtractor::new();
833        let source = "<?php\nuse Illuminate\\Http\\Request;\n";
834        let imports = ext.extract_all_import_specifiers(source);
835        assert!(
836            imports.is_empty(),
837            "external Illuminate should be filtered, got: {imports:?}"
838        );
839    }
840
841    // -----------------------------------------------------------------------
842    // PHP-E2E-01: User.php + UserTest.php in the same directory -> Layer 1 stem match
843    // -----------------------------------------------------------------------
844    #[test]
845    fn php_e2e_01_stem_match() {
846        // Given: production file User.php and test file UserTest.php in the same directory
847        // (Layer 1 stem matching works when files share the same parent directory)
848        // When: map_test_files_with_imports is called
849        // Then: UserTest.php is matched to User.php via Layer 1 stem matching
850        let dir = tempfile::tempdir().expect("failed to create tempdir");
851
852        let prod_file = dir.path().join("User.php");
853        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
854
855        let test_file = dir.path().join("UserTest.php");
856        std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
857
858        let ext = PhpExtractor::new();
859        let production_files = vec![prod_file.to_string_lossy().into_owned()];
860        let mut test_sources = HashMap::new();
861        test_sources.insert(
862            test_file.to_string_lossy().into_owned(),
863            "<?php\nclass UserTest extends TestCase {}".to_string(),
864        );
865
866        let mappings =
867            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
868
869        assert!(!mappings.is_empty(), "expected at least one mapping");
870        let user_mapping = mappings
871            .iter()
872            .find(|m| m.production_file.contains("User.php"))
873            .expect("expected User.php in mappings");
874        assert!(
875            !user_mapping.test_files.is_empty(),
876            "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
877        );
878    }
879
880    // -----------------------------------------------------------------------
881    // PHP-E2E-02: tests/ServiceTest.php imports use App\Services\OrderService
882    //             -> Layer 2 PSR-4 import match
883    // -----------------------------------------------------------------------
884    #[test]
885    fn php_e2e_02_import_match() {
886        // Given: production file app/Services/OrderService.php
887        //        and test file tests/ServiceTest.php with `use App\Services\OrderService;`
888        // When: map_test_files_with_imports is called
889        // Then: ServiceTest.php is matched to OrderService.php via Layer 2 import tracing
890        let dir = tempfile::tempdir().expect("failed to create tempdir");
891        let services_dir = dir.path().join("app").join("Services");
892        std::fs::create_dir_all(&services_dir).unwrap();
893        let test_dir = dir.path().join("tests");
894        std::fs::create_dir_all(&test_dir).unwrap();
895
896        let prod_file = services_dir.join("OrderService.php");
897        std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
898
899        let test_file = test_dir.join("ServiceTest.php");
900        let test_source =
901            "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
902        std::fs::write(&test_file, test_source).unwrap();
903
904        let ext = PhpExtractor::new();
905        let production_files = vec![prod_file.to_string_lossy().into_owned()];
906        let mut test_sources = HashMap::new();
907        test_sources.insert(
908            test_file.to_string_lossy().into_owned(),
909            test_source.to_string(),
910        );
911
912        let mappings =
913            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
914
915        let order_mapping = mappings
916            .iter()
917            .find(|m| m.production_file.contains("OrderService.php"))
918            .expect("expected OrderService.php in mappings");
919        assert!(
920            !order_mapping.test_files.is_empty(),
921            "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
922        );
923    }
924
925    // -----------------------------------------------------------------------
926    // PHP-E2E-03: tests/TestCase.php -> helper exclusion
927    // -----------------------------------------------------------------------
928    #[test]
929    fn php_e2e_03_helper_exclusion() {
930        // Given: a TestCase.php base class in tests/
931        // When: map_test_files_with_imports is called
932        // Then: TestCase.php is excluded (is_non_sut_helper = true)
933        let dir = tempfile::tempdir().expect("failed to create tempdir");
934        let src_dir = dir.path().join("src");
935        std::fs::create_dir_all(&src_dir).unwrap();
936        let test_dir = dir.path().join("tests");
937        std::fs::create_dir_all(&test_dir).unwrap();
938
939        let prod_file = src_dir.join("User.php");
940        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
941
942        // TestCase.php should be treated as a helper, not a test file
943        let test_case_file = test_dir.join("TestCase.php");
944        std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
945
946        let ext = PhpExtractor::new();
947        let production_files = vec![prod_file.to_string_lossy().into_owned()];
948        let mut test_sources = HashMap::new();
949        test_sources.insert(
950            test_case_file.to_string_lossy().into_owned(),
951            "<?php\nabstract class TestCase {}".to_string(),
952        );
953
954        let mappings =
955            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
956
957        // TestCase.php should not be matched to User.php
958        let user_mapping = mappings
959            .iter()
960            .find(|m| m.production_file.contains("User.php"));
961        if let Some(mapping) = user_mapping {
962            assert!(
963                mapping.test_files.is_empty()
964                    || !mapping
965                        .test_files
966                        .iter()
967                        .any(|t| t.contains("TestCase.php")),
968                "TestCase.php should not be mapped as a test file for User.php"
969            );
970        }
971    }
972
973    // -----------------------------------------------------------------------
974    // PHP-FW-01: laravel/framework layout -> Illuminate import resolves locally
975    // -----------------------------------------------------------------------
976    #[test]
977    fn php_fw_01_laravel_framework_self_test() {
978        // Given: laravel/framework layout with src/Illuminate/Http/Request.php
979        //        and tests/Http/RequestTest.php importing `use Illuminate\Http\Request`
980        // When: map_test_files_with_imports is called
981        // Then: RequestTest.php is mapped to Request.php via Layer 2
982        let dir = tempfile::tempdir().expect("failed to create tempdir");
983        let src_dir = dir.path().join("src").join("Illuminate").join("Http");
984        std::fs::create_dir_all(&src_dir).unwrap();
985        let test_dir = dir.path().join("tests").join("Http");
986        std::fs::create_dir_all(&test_dir).unwrap();
987
988        let prod_file = src_dir.join("Request.php");
989        std::fs::write(
990            &prod_file,
991            "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
992        )
993        .unwrap();
994
995        let test_file = test_dir.join("RequestTest.php");
996        let test_source =
997            "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
998        std::fs::write(&test_file, test_source).unwrap();
999
1000        let ext = PhpExtractor::new();
1001        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1002        let mut test_sources = HashMap::new();
1003        test_sources.insert(
1004            test_file.to_string_lossy().into_owned(),
1005            test_source.to_string(),
1006        );
1007
1008        let mappings =
1009            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1010
1011        let request_mapping = mappings
1012            .iter()
1013            .find(|m| m.production_file.contains("Request.php"))
1014            .expect("expected Request.php in mappings");
1015        assert!(
1016            request_mapping
1017                .test_files
1018                .iter()
1019                .any(|t| t.contains("RequestTest.php")),
1020            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1021            request_mapping.test_files
1022        );
1023    }
1024
1025    // -----------------------------------------------------------------------
1026    // PHP-FW-02: normal app -> Illuminate import still filtered (no local source)
1027    // -----------------------------------------------------------------------
1028    #[test]
1029    fn php_fw_02_normal_app_illuminate_filtered() {
1030        // Given: normal app layout with app/Models/User.php
1031        //        and tests/UserTest.php importing `use Illuminate\Http\Request`
1032        //        (no local Illuminate directory)
1033        // When: map_test_files_with_imports is called
1034        // Then: Illuminate import is NOT resolved (no mapping via import)
1035        let dir = tempfile::tempdir().expect("failed to create tempdir");
1036        let app_dir = dir.path().join("app").join("Models");
1037        std::fs::create_dir_all(&app_dir).unwrap();
1038        let test_dir = dir.path().join("tests");
1039        std::fs::create_dir_all(&test_dir).unwrap();
1040
1041        let prod_file = app_dir.join("User.php");
1042        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1043
1044        // This test imports Illuminate but there's no local Illuminate source
1045        let test_file = test_dir.join("OrderTest.php");
1046        let test_source =
1047            "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1048        std::fs::write(&test_file, test_source).unwrap();
1049
1050        let ext = PhpExtractor::new();
1051        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1052        let mut test_sources = HashMap::new();
1053        test_sources.insert(
1054            test_file.to_string_lossy().into_owned(),
1055            test_source.to_string(),
1056        );
1057
1058        let mappings =
1059            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1060
1061        // User.php should not have OrderTest.php mapped (no stem match, no import match)
1062        let user_mapping = mappings
1063            .iter()
1064            .find(|m| m.production_file.contains("User.php"))
1065            .expect("expected User.php in mappings");
1066        assert!(
1067            !user_mapping
1068                .test_files
1069                .iter()
1070                .any(|t| t.contains("OrderTest.php")),
1071            "Illuminate import should be filtered when no local source exists"
1072        );
1073    }
1074
1075    // -----------------------------------------------------------------------
1076    // PHP-FW-03: PHPUnit import still filtered via integration test (regression)
1077    // -----------------------------------------------------------------------
1078    #[test]
1079    fn php_fw_03_phpunit_still_external() {
1080        // Given: app with src/Calculator.php and tests/CalculatorTest.php
1081        //        importing only `use PHPUnit\Framework\TestCase` (no local PHPUnit source)
1082        // When: map_test_files_with_imports is called
1083        // Then: PHPUnit import does not create a false mapping
1084        let dir = tempfile::tempdir().expect("failed to create tempdir");
1085        let src_dir = dir.path().join("src");
1086        std::fs::create_dir_all(&src_dir).unwrap();
1087        let test_dir = dir.path().join("tests");
1088        std::fs::create_dir_all(&test_dir).unwrap();
1089
1090        let prod_file = src_dir.join("Calculator.php");
1091        std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1092
1093        // Test imports only PHPUnit (external) — no import-based mapping should occur
1094        let test_file = test_dir.join("OtherTest.php");
1095        let test_source =
1096            "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1097        std::fs::write(&test_file, test_source).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_file.to_string_lossy().into_owned(),
1104            test_source.to_string(),
1105        );
1106
1107        let mappings =
1108            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1109
1110        let calc_mapping = mappings
1111            .iter()
1112            .find(|m| m.production_file.contains("Calculator.php"))
1113            .expect("expected Calculator.php in mappings");
1114        assert!(
1115            !calc_mapping
1116                .test_files
1117                .iter()
1118                .any(|t| t.contains("OtherTest.php")),
1119            "PHPUnit import should not create a mapping to Calculator.php"
1120        );
1121    }
1122
1123    // -----------------------------------------------------------------------
1124    // PHP-FW-04: symfony/symfony layout -> Symfony import resolves locally
1125    // -----------------------------------------------------------------------
1126    #[test]
1127    fn php_fw_04_symfony_self_test() {
1128        // Given: symfony layout with src/Symfony/Component/HttpFoundation/Request.php
1129        //        and tests/HttpFoundation/RequestTest.php importing
1130        //        `use Symfony\Component\HttpFoundation\Request`
1131        // When: map_test_files_with_imports is called
1132        // Then: RequestTest.php is mapped to Request.php via Layer 2
1133        let dir = tempfile::tempdir().expect("failed to create tempdir");
1134        let src_dir = dir
1135            .path()
1136            .join("src")
1137            .join("Symfony")
1138            .join("Component")
1139            .join("HttpFoundation");
1140        std::fs::create_dir_all(&src_dir).unwrap();
1141        let test_dir = dir.path().join("tests").join("HttpFoundation");
1142        std::fs::create_dir_all(&test_dir).unwrap();
1143
1144        let prod_file = src_dir.join("Request.php");
1145        std::fs::write(
1146            &prod_file,
1147            "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1148        )
1149        .unwrap();
1150
1151        let test_file = test_dir.join("RequestTest.php");
1152        let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1153        std::fs::write(&test_file, test_source).unwrap();
1154
1155        let ext = PhpExtractor::new();
1156        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1157        let mut test_sources = HashMap::new();
1158        test_sources.insert(
1159            test_file.to_string_lossy().into_owned(),
1160            test_source.to_string(),
1161        );
1162
1163        let mappings =
1164            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1165
1166        let request_mapping = mappings
1167            .iter()
1168            .find(|m| m.production_file.contains("Request.php"))
1169            .expect("expected Request.php in mappings");
1170        assert!(
1171            request_mapping
1172                .test_files
1173                .iter()
1174                .any(|t| t.contains("RequestTest.php")),
1175            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1176            request_mapping.test_files
1177        );
1178    }
1179
1180    // -----------------------------------------------------------------------
1181    // PHP-CLI-01: observe --lang php . -> CLI dispatch verification
1182    // -----------------------------------------------------------------------
1183    #[test]
1184    fn php_cli_01_dispatch() {
1185        // Given: a tempdir with a PHP file
1186        // When: PhpExtractor::map_test_files_with_imports is called on an empty project
1187        // Then: returns an empty (or valid) mapping without panicking
1188        let dir = tempfile::tempdir().expect("failed to create tempdir");
1189        let ext = PhpExtractor::new();
1190        let production_files: Vec<String> = vec![];
1191        let test_sources: HashMap<String, String> = HashMap::new();
1192        let mappings =
1193            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path());
1194        assert!(mappings.is_empty());
1195    }
1196}