1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Query, QueryCursor};
7
8use exspec_core::observe::{
9 BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
10 ProductionFunction,
11};
12
13use super::PhpExtractor;
14
15const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
16static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
17
18const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
19static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
20
21const EXTENDS_CLASS_QUERY: &str = include_str!("../queries/extends_class.scm");
22static EXTENDS_CLASS_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
23
24fn php_language() -> tree_sitter::Language {
25 tree_sitter_php::LANGUAGE_PHP.into()
26}
27
28fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
29 lock.get_or_init(|| Query::new(&php_language(), source).expect("invalid query"))
30}
31
32pub fn test_stem(path: &str) -> Option<&str> {
42 let file_name = Path::new(path).file_name()?.to_str()?;
43 let stem = file_name.strip_suffix(".php")?;
45
46 if let Some(rest) = stem.strip_suffix("Test") {
48 if !rest.is_empty() {
49 return Some(rest);
50 }
51 }
52
53 if let Some(rest) = stem.strip_suffix("_test") {
55 if !rest.is_empty() {
56 return Some(rest);
57 }
58 }
59
60 None
61}
62
63pub fn production_stem(path: &str) -> Option<&str> {
68 if test_stem(path).is_some() {
70 return None;
71 }
72
73 let file_name = Path::new(path).file_name()?.to_str()?;
74 let stem = file_name.strip_suffix(".php")?;
75
76 if stem.is_empty() {
77 return None;
78 }
79
80 Some(stem)
81}
82
83pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
85 if is_known_production {
87 return false;
88 }
89
90 let normalized = file_path.replace('\\', "/");
91 let file_name = Path::new(&normalized)
92 .file_name()
93 .and_then(|f| f.to_str())
94 .unwrap_or("");
95
96 if file_name == "TestCase.php" {
98 return true;
99 }
100
101 if file_name.ends_with("Factory.php") {
103 let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
104 if in_tests {
105 return true;
106 }
107 }
108
109 if file_name.starts_with("Abstract") && file_name.ends_with(".php") {
111 let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
112 if in_tests {
113 return true;
114 }
115 }
116
117 let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
119 if in_tests
120 && file_name.ends_with(".php")
121 && (file_name.starts_with("Trait") || file_name.ends_with("Trait.php"))
122 {
123 return true;
124 }
125
126 if normalized.contains("/tests/Traits/") || normalized.starts_with("tests/Traits/") {
128 return true;
129 }
130
131 let lower = normalized.to_lowercase();
133 if (lower.contains("/tests/fixtures/") || lower.starts_with("tests/fixtures/"))
134 || (lower.contains("/tests/stubs/") || lower.starts_with("tests/stubs/"))
135 {
136 return true;
137 }
138
139 if file_name == "Kernel.php" {
141 return true;
142 }
143
144 if file_name == "bootstrap.php" {
146 return true;
147 }
148 if normalized.starts_with("bootstrap/") || normalized.contains("/bootstrap/") {
149 return true;
150 }
151
152 false
153}
154
155pub fn load_psr4_prefixes(scan_root: &Path) -> HashMap<String, String> {
163 let composer_path = scan_root.join("composer.json");
164 let content = match std::fs::read_to_string(&composer_path) {
165 Ok(s) => s,
166 Err(_) => return HashMap::new(),
167 };
168 let value: serde_json::Value = match serde_json::from_str(&content) {
169 Ok(v) => v,
170 Err(_) => return HashMap::new(),
171 };
172
173 let mut result = HashMap::new();
174
175 for section in &["autoload", "autoload-dev"] {
177 if let Some(psr4) = value
178 .get(section)
179 .and_then(|a| a.get("psr-4"))
180 .and_then(|p| p.as_object())
181 {
182 for (ns, dir) in psr4 {
183 let ns_key = ns.trim_end_matches('\\').to_string();
185 let dir_val = dir.as_str().unwrap_or("").trim_end_matches('/').to_string();
187 if !ns_key.is_empty() {
188 result.insert(ns_key, dir_val);
189 }
190 }
191 }
192 }
193
194 result
195}
196
197const EXTERNAL_NAMESPACES: &[&str] = &[
203 "PHPUnit",
204 "Illuminate",
205 "Symfony",
206 "Doctrine",
207 "Mockery",
208 "Carbon",
209 "Pest",
210 "Laravel",
211 "Monolog",
212 "Psr",
213 "GuzzleHttp",
214 "League",
215 "Ramsey",
216 "Spatie",
217 "Nette",
218 "Webmozart",
219 "PhpParser",
220 "SebastianBergmann",
221];
222
223fn is_external_namespace(namespace: &str, scan_root: Option<&Path>) -> bool {
224 let first_segment = namespace.split('/').next().unwrap_or("");
225 let is_known_external = EXTERNAL_NAMESPACES
226 .iter()
227 .any(|&ext| first_segment.eq_ignore_ascii_case(ext));
228
229 if !is_known_external {
230 return false;
231 }
232
233 if let Some(root) = scan_root {
236 for prefix in &["src", "app", "lib", ""] {
237 let candidate = if prefix.is_empty() {
238 root.join(first_segment)
239 } else {
240 root.join(prefix).join(first_segment)
241 };
242 if candidate.is_dir() {
243 return false;
244 }
245 }
246 }
247
248 true
249}
250
251impl ObserveExtractor for PhpExtractor {
256 fn extract_production_functions(
257 &self,
258 source: &str,
259 file_path: &str,
260 ) -> Vec<ProductionFunction> {
261 let mut parser = Self::parser();
262 let tree = match parser.parse(source, None) {
263 Some(t) => t,
264 None => return Vec::new(),
265 };
266 let source_bytes = source.as_bytes();
267 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
268
269 let name_idx = query.capture_index_for_name("name");
270 let class_name_idx = query.capture_index_for_name("class_name");
271 let method_name_idx = query.capture_index_for_name("method_name");
272 let function_idx = query.capture_index_for_name("function");
273 let method_idx = query.capture_index_for_name("method");
274
275 let mut cursor = QueryCursor::new();
276 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
277 let mut result = Vec::new();
278
279 while let Some(m) = matches.next() {
280 let mut fn_name: Option<String> = None;
281 let mut class_name: Option<String> = None;
282 let mut line: usize = 1;
283 let mut is_exported = true; let mut method_node: Option<tree_sitter::Node> = None;
285
286 for cap in m.captures {
287 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
288 let node_line = cap.node.start_position().row + 1;
289
290 if name_idx == Some(cap.index) {
291 fn_name = Some(text);
292 line = node_line;
293 } else if class_name_idx == Some(cap.index) {
294 class_name = Some(text);
295 } else if method_name_idx == Some(cap.index) {
296 fn_name = Some(text);
297 line = node_line;
298 }
299
300 if method_idx == Some(cap.index) {
302 method_node = Some(cap.node);
303 }
304
305 if function_idx == Some(cap.index) {
307 is_exported = true;
308 }
309 }
310
311 if let Some(method) = method_node {
313 is_exported = has_public_visibility(method, source_bytes);
314 }
315
316 if let Some(name) = fn_name {
317 result.push(ProductionFunction {
318 name,
319 file: file_path.to_string(),
320 line,
321 class_name,
322 is_exported,
323 });
324 }
325 }
326
327 result
328 }
329
330 fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
331 Vec::new()
333 }
334
335 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
336 let mut parser = Self::parser();
337 let tree = match parser.parse(source, None) {
338 Some(t) => t,
339 None => return Vec::new(),
340 };
341 let source_bytes = source.as_bytes();
342 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
343
344 let namespace_path_idx = query.capture_index_for_name("namespace_path");
345
346 let mut cursor = QueryCursor::new();
347 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
348
349 let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
350
351 while let Some(m) = matches.next() {
352 for cap in m.captures {
353 if namespace_path_idx != Some(cap.index) {
354 continue;
355 }
356 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
357 let fs_path = raw.replace('\\', "/");
359
360 if is_external_namespace(&fs_path, None) {
362 continue;
363 }
364
365 let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
368 if parts.len() < 2 {
369 continue;
373 }
374
375 if let Some(last_slash) = fs_path.rfind('/') {
377 let module_path = &fs_path[..last_slash];
378 let symbol = &fs_path[last_slash + 1..];
379 if !module_path.is_empty() && !symbol.is_empty() {
380 result_map
381 .entry(module_path.to_string())
382 .or_default()
383 .push(symbol.to_string());
384 }
385 }
386 }
387 }
388
389 result_map.into_iter().collect()
390 }
391
392 fn extract_barrel_re_exports(&self, _source: &str, _file_path: &str) -> Vec<BarrelReExport> {
393 Vec::new()
395 }
396
397 fn source_extensions(&self) -> &[&str] {
398 &["php"]
399 }
400
401 fn index_file_names(&self) -> &[&str] {
402 &[]
404 }
405
406 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
407 production_stem(path)
408 }
409
410 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
411 test_stem(path)
412 }
413
414 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
415 is_non_sut_helper(file_path, is_known_production)
416 }
417}
418
419impl PhpExtractor {
424 fn extract_raw_import_specifiers(source: &str) -> Vec<(String, Vec<String>)> {
427 let mut parser = Self::parser();
428 let tree = match parser.parse(source, None) {
429 Some(t) => t,
430 None => return Vec::new(),
431 };
432 let source_bytes = source.as_bytes();
433 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
434
435 let namespace_path_idx = query.capture_index_for_name("namespace_path");
436
437 let mut cursor = QueryCursor::new();
438 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
439
440 let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
441
442 while let Some(m) = matches.next() {
443 for cap in m.captures {
444 if namespace_path_idx != Some(cap.index) {
445 continue;
446 }
447 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
448 let fs_path = raw.replace('\\', "/");
449
450 let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
451 if parts.len() < 2 {
452 continue;
453 }
454
455 if let Some(last_slash) = fs_path.rfind('/') {
456 let module_path = &fs_path[..last_slash];
457 let symbol = &fs_path[last_slash + 1..];
458 if !module_path.is_empty() && !symbol.is_empty() {
459 result_map
460 .entry(module_path.to_string())
461 .or_default()
462 .push(symbol.to_string());
463 }
464 }
465 }
466 }
467
468 result_map.into_iter().collect()
469 }
470
471 pub fn extract_parent_class_imports(
476 source: &str,
477 test_dir: &str,
478 ) -> Vec<(String, Vec<String>)> {
479 let mut parser = Self::parser();
481 let tree = match parser.parse(source, None) {
482 Some(t) => t,
483 None => return Vec::new(),
484 };
485 let source_bytes = source.as_bytes();
486 let query = cached_query(&EXTENDS_CLASS_QUERY_CACHE, EXTENDS_CLASS_QUERY);
487
488 let parent_class_idx = query.capture_index_for_name("parent_class");
489
490 let mut cursor = QueryCursor::new();
491 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
492
493 let mut parent_class_name: Option<String> = None;
494 while let Some(m) = matches.next() {
495 for cap in m.captures {
496 if parent_class_idx == Some(cap.index) {
497 let name = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
498 if !name.is_empty() {
499 parent_class_name = Some(name);
500 break;
501 }
502 }
503 }
504 if parent_class_name.is_some() {
505 break;
506 }
507 }
508
509 let parent_name = match parent_class_name {
510 Some(n) => n,
511 None => return Vec::new(),
512 };
513
514 let parent_file_name = format!("{parent_name}.php");
516 let parent_path = Path::new(test_dir).join(&parent_file_name);
517
518 let parent_source = match std::fs::read_to_string(&parent_path) {
520 Ok(s) => s,
521 Err(_) => return Vec::new(),
522 };
523
524 Self::extract_raw_import_specifiers(&parent_source)
526 }
527
528 pub fn map_test_files_with_imports(
530 &self,
531 production_files: &[String],
532 test_sources: &HashMap<String, String>,
533 scan_root: &Path,
534 l1_exclusive: bool,
535 ) -> Vec<FileMapping> {
536 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
537
538 let mut mappings =
540 exspec_core::observe::map_test_files(self, production_files, &test_file_list);
541
542 let canonical_root = match scan_root.canonicalize() {
544 Ok(r) => r,
545 Err(_) => return mappings,
546 };
547 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
548 for (idx, prod) in production_files.iter().enumerate() {
549 if let Ok(canonical) = Path::new(prod).canonicalize() {
550 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
551 }
552 }
553
554 let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
556 .iter()
557 .map(|m| m.test_files.iter().cloned().collect())
558 .collect();
559
560 let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
562 .iter()
563 .flat_map(|s| s.iter().cloned())
564 .collect();
565
566 let psr4_prefixes = load_psr4_prefixes(scan_root);
568
569 for (test_file, source) in test_sources {
572 if l1_exclusive && layer1_matched.contains(test_file) {
573 continue;
574 }
575 let raw_specifiers = Self::extract_raw_import_specifiers(source);
576 let parent_dir = Path::new(test_file.as_str())
578 .parent()
579 .map(|p| p.to_string_lossy().into_owned())
580 .unwrap_or_default();
581 let parent_specifiers = Self::extract_parent_class_imports(source, &parent_dir);
582 let combined: Vec<(String, Vec<String>)> = raw_specifiers
583 .into_iter()
584 .chain(parent_specifiers.into_iter())
585 .collect();
586 let specifiers: Vec<(String, Vec<String>)> = combined
587 .into_iter()
588 .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
589 .collect();
590 let mut matched_indices = std::collections::HashSet::<usize>::new();
591
592 for (module_path, _symbols) in &specifiers {
593 let parts: Vec<&str> = module_path.splitn(2, '/').collect();
599 let first_segment = parts[0];
600 let path_without_prefix = if parts.len() == 2 {
601 parts[1]
602 } else {
603 module_path.as_str()
604 };
605
606 let psr4_dir = psr4_prefixes.get(first_segment);
609
610 for symbol in _symbols {
619 let file_name = format!("{symbol}.php");
620
621 if let Some(psr4_base) = psr4_dir {
624 let candidate = canonical_root
625 .join(psr4_base)
626 .join(path_without_prefix)
627 .join(&file_name);
628 if let Ok(canonical_candidate) = candidate.canonicalize() {
629 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
630 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
631 matched_indices.insert(idx);
632 }
633 }
634 }
635
636 let common_prefixes = ["src", "app", "lib", ""];
638 for prefix in &common_prefixes {
639 let candidate = if prefix.is_empty() {
640 canonical_root.join(path_without_prefix).join(&file_name)
641 } else {
642 canonical_root
643 .join(prefix)
644 .join(path_without_prefix)
645 .join(&file_name)
646 };
647
648 if let Ok(canonical_candidate) = candidate.canonicalize() {
649 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
650 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
651 matched_indices.insert(idx);
652 }
653 }
654 }
655
656 for prefix in &common_prefixes {
659 let candidate = if prefix.is_empty() {
660 canonical_root.join(module_path).join(&file_name)
661 } else {
662 canonical_root
663 .join(prefix)
664 .join(module_path)
665 .join(&file_name)
666 };
667 if let Ok(canonical_candidate) = candidate.canonicalize() {
668 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
669 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
670 matched_indices.insert(idx);
671 }
672 }
673 }
674 }
675 }
676
677 for idx in matched_indices {
678 if !mappings[idx].test_files.contains(test_file) {
679 mappings[idx].test_files.push(test_file.clone());
680 }
681 }
682 }
683
684 for (i, mapping) in mappings.iter_mut().enumerate() {
687 let has_layer1 = !layer1_tests_per_prod[i].is_empty();
688 if !has_layer1 && !mapping.test_files.is_empty() {
689 mapping.strategy = MappingStrategy::ImportTracing;
690 }
691 }
692
693 mappings
694 }
695}
696
697fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
705 for i in 0..node.child_count() {
706 if let Some(child) = node.child(i) {
707 if child.kind() == "visibility_modifier" {
708 let text = child.utf8_text(source_bytes).unwrap_or("");
709 return text == "public";
710 }
711 }
712 }
713 true
715}
716
717#[cfg(test)]
722mod tests {
723 use super::*;
724 use std::collections::HashMap;
725
726 #[test]
730 fn php_stem_01_test_suffix() {
731 assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
735 }
736
737 #[test]
741 fn php_stem_02_pest_suffix() {
742 assert_eq!(test_stem("tests/user_test.php"), Some("user"));
746 }
747
748 #[test]
752 fn php_stem_03_nested() {
753 assert_eq!(
757 test_stem("tests/Unit/OrderServiceTest.php"),
758 Some("OrderService")
759 );
760 }
761
762 #[test]
766 fn php_stem_04_non_test() {
767 assert_eq!(test_stem("src/User.php"), None);
771 }
772
773 #[test]
777 fn php_stem_05_prod_stem() {
778 assert_eq!(production_stem("src/User.php"), Some("User"));
782 }
783
784 #[test]
788 fn php_stem_06_prod_nested() {
789 assert_eq!(production_stem("src/Models/User.php"), Some("User"));
793 }
794
795 #[test]
799 fn php_stem_07_test_not_prod() {
800 assert_eq!(production_stem("tests/UserTest.php"), None);
804 }
805
806 #[test]
810 fn php_helper_01_test_case() {
811 assert!(is_non_sut_helper("tests/TestCase.php", false));
815 }
816
817 #[test]
821 fn php_helper_02_factory() {
822 assert!(is_non_sut_helper("tests/UserFactory.php", false));
826 }
827
828 #[test]
832 fn php_helper_03_production() {
833 assert!(!is_non_sut_helper("src/User.php", false));
837 }
838
839 #[test]
843 fn php_helper_04_test_trait() {
844 assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
848 }
849
850 #[test]
854 fn php_helper_05_bootstrap() {
855 assert!(is_non_sut_helper("bootstrap/app.php", false));
859 }
860
861 #[test]
865 fn php_func_01_public_method() {
866 let ext = PhpExtractor::new();
870 let source = "<?php\nclass User {\n public function createUser() {}\n}";
871 let fns = ext.extract_production_functions(source, "src/User.php");
872 let f = fns.iter().find(|f| f.name == "createUser").unwrap();
873 assert!(f.is_exported);
874 }
875
876 #[test]
880 fn php_func_02_private_method() {
881 let ext = PhpExtractor::new();
885 let source = "<?php\nclass User {\n private function helper() {}\n}";
886 let fns = ext.extract_production_functions(source, "src/User.php");
887 let f = fns.iter().find(|f| f.name == "helper").unwrap();
888 assert!(!f.is_exported);
889 }
890
891 #[test]
895 fn php_func_03_class_method() {
896 let ext = PhpExtractor::new();
900 let source = "<?php\nclass User {\n public function save() {}\n}";
901 let fns = ext.extract_production_functions(source, "src/User.php");
902 let f = fns.iter().find(|f| f.name == "save").unwrap();
903 assert_eq!(f.class_name, Some("User".to_string()));
904 }
905
906 #[test]
910 fn php_func_04_top_level_function() {
911 let ext = PhpExtractor::new();
915 let source = "<?php\nfunction global_helper() {\n return 42;\n}";
916 let fns = ext.extract_production_functions(source, "src/helpers.php");
917 let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
918 assert!(f.is_exported);
919 assert_eq!(f.class_name, None);
920 }
921
922 #[test]
926 fn php_imp_01_app_models() {
927 let ext = PhpExtractor::new();
931 let source = "<?php\nuse App\\Models\\User;\n";
932 let imports = ext.extract_all_import_specifiers(source);
933 assert!(
934 imports
935 .iter()
936 .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
937 "expected App/Models -> [User], got: {imports:?}"
938 );
939 }
940
941 #[test]
945 fn php_imp_02_app_services() {
946 let ext = PhpExtractor::new();
950 let source = "<?php\nuse App\\Services\\UserService;\n";
951 let imports = ext.extract_all_import_specifiers(source);
952 assert!(
953 imports
954 .iter()
955 .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
956 "expected App/Services -> [UserService], got: {imports:?}"
957 );
958 }
959
960 #[test]
964 fn php_imp_03_external_phpunit() {
965 let ext = PhpExtractor::new();
969 let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
970 let imports = ext.extract_all_import_specifiers(source);
971 assert!(
972 imports.is_empty(),
973 "external PHPUnit should be filtered, got: {imports:?}"
974 );
975 }
976
977 #[test]
981 fn php_imp_04_external_illuminate() {
982 let ext = PhpExtractor::new();
986 let source = "<?php\nuse Illuminate\\Http\\Request;\n";
987 let imports = ext.extract_all_import_specifiers(source);
988 assert!(
989 imports.is_empty(),
990 "external Illuminate should be filtered, got: {imports:?}"
991 );
992 }
993
994 #[test]
998 fn php_e2e_01_stem_match() {
999 let dir = tempfile::tempdir().expect("failed to create tempdir");
1004
1005 let prod_file = dir.path().join("User.php");
1006 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1007
1008 let test_file = dir.path().join("UserTest.php");
1009 std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
1010
1011 let ext = PhpExtractor::new();
1012 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1013 let mut test_sources = HashMap::new();
1014 test_sources.insert(
1015 test_file.to_string_lossy().into_owned(),
1016 "<?php\nclass UserTest extends TestCase {}".to_string(),
1017 );
1018
1019 let mappings =
1020 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1021
1022 assert!(!mappings.is_empty(), "expected at least one mapping");
1023 let user_mapping = mappings
1024 .iter()
1025 .find(|m| m.production_file.contains("User.php"))
1026 .expect("expected User.php in mappings");
1027 assert!(
1028 !user_mapping.test_files.is_empty(),
1029 "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
1030 );
1031 }
1032
1033 #[test]
1038 fn php_e2e_02_import_match() {
1039 let dir = tempfile::tempdir().expect("failed to create tempdir");
1044 let services_dir = dir.path().join("app").join("Services");
1045 std::fs::create_dir_all(&services_dir).unwrap();
1046 let test_dir = dir.path().join("tests");
1047 std::fs::create_dir_all(&test_dir).unwrap();
1048
1049 let prod_file = services_dir.join("OrderService.php");
1050 std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
1051
1052 let test_file = test_dir.join("ServiceTest.php");
1053 let test_source =
1054 "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
1055 std::fs::write(&test_file, test_source).unwrap();
1056
1057 let ext = PhpExtractor::new();
1058 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1059 let mut test_sources = HashMap::new();
1060 test_sources.insert(
1061 test_file.to_string_lossy().into_owned(),
1062 test_source.to_string(),
1063 );
1064
1065 let mappings =
1066 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1067
1068 let order_mapping = mappings
1069 .iter()
1070 .find(|m| m.production_file.contains("OrderService.php"))
1071 .expect("expected OrderService.php in mappings");
1072 assert!(
1073 !order_mapping.test_files.is_empty(),
1074 "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
1075 );
1076 }
1077
1078 #[test]
1082 fn php_e2e_03_helper_exclusion() {
1083 let dir = tempfile::tempdir().expect("failed to create tempdir");
1087 let src_dir = dir.path().join("src");
1088 std::fs::create_dir_all(&src_dir).unwrap();
1089 let test_dir = dir.path().join("tests");
1090 std::fs::create_dir_all(&test_dir).unwrap();
1091
1092 let prod_file = src_dir.join("User.php");
1093 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1094
1095 let test_case_file = test_dir.join("TestCase.php");
1097 std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
1098
1099 let ext = PhpExtractor::new();
1100 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1101 let mut test_sources = HashMap::new();
1102 test_sources.insert(
1103 test_case_file.to_string_lossy().into_owned(),
1104 "<?php\nabstract class TestCase {}".to_string(),
1105 );
1106
1107 let mappings =
1108 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1109
1110 let user_mapping = mappings
1112 .iter()
1113 .find(|m| m.production_file.contains("User.php"));
1114 if let Some(mapping) = user_mapping {
1115 assert!(
1116 mapping.test_files.is_empty()
1117 || !mapping
1118 .test_files
1119 .iter()
1120 .any(|t| t.contains("TestCase.php")),
1121 "TestCase.php should not be mapped as a test file for User.php"
1122 );
1123 }
1124 }
1125
1126 #[test]
1130 fn php_fw_01_laravel_framework_self_test() {
1131 let dir = tempfile::tempdir().expect("failed to create tempdir");
1136 let src_dir = dir.path().join("src").join("Illuminate").join("Http");
1137 std::fs::create_dir_all(&src_dir).unwrap();
1138 let test_dir = dir.path().join("tests").join("Http");
1139 std::fs::create_dir_all(&test_dir).unwrap();
1140
1141 let prod_file = src_dir.join("Request.php");
1142 std::fs::write(
1143 &prod_file,
1144 "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1145 )
1146 .unwrap();
1147
1148 let test_file = test_dir.join("RequestTest.php");
1149 let test_source =
1150 "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1151 std::fs::write(&test_file, test_source).unwrap();
1152
1153 let ext = PhpExtractor::new();
1154 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1155 let mut test_sources = HashMap::new();
1156 test_sources.insert(
1157 test_file.to_string_lossy().into_owned(),
1158 test_source.to_string(),
1159 );
1160
1161 let mappings =
1162 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1163
1164 let request_mapping = mappings
1165 .iter()
1166 .find(|m| m.production_file.contains("Request.php"))
1167 .expect("expected Request.php in mappings");
1168 assert!(
1169 request_mapping
1170 .test_files
1171 .iter()
1172 .any(|t| t.contains("RequestTest.php")),
1173 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1174 request_mapping.test_files
1175 );
1176 }
1177
1178 #[test]
1182 fn php_fw_02_normal_app_illuminate_filtered() {
1183 let dir = tempfile::tempdir().expect("failed to create tempdir");
1189 let app_dir = dir.path().join("app").join("Models");
1190 std::fs::create_dir_all(&app_dir).unwrap();
1191 let test_dir = dir.path().join("tests");
1192 std::fs::create_dir_all(&test_dir).unwrap();
1193
1194 let prod_file = app_dir.join("User.php");
1195 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1196
1197 let test_file = test_dir.join("OrderTest.php");
1199 let test_source =
1200 "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1201 std::fs::write(&test_file, test_source).unwrap();
1202
1203 let ext = PhpExtractor::new();
1204 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1205 let mut test_sources = HashMap::new();
1206 test_sources.insert(
1207 test_file.to_string_lossy().into_owned(),
1208 test_source.to_string(),
1209 );
1210
1211 let mappings =
1212 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1213
1214 let user_mapping = mappings
1216 .iter()
1217 .find(|m| m.production_file.contains("User.php"))
1218 .expect("expected User.php in mappings");
1219 assert!(
1220 !user_mapping
1221 .test_files
1222 .iter()
1223 .any(|t| t.contains("OrderTest.php")),
1224 "Illuminate import should be filtered when no local source exists"
1225 );
1226 }
1227
1228 #[test]
1232 fn php_fw_03_phpunit_still_external() {
1233 let dir = tempfile::tempdir().expect("failed to create tempdir");
1238 let src_dir = dir.path().join("src");
1239 std::fs::create_dir_all(&src_dir).unwrap();
1240 let test_dir = dir.path().join("tests");
1241 std::fs::create_dir_all(&test_dir).unwrap();
1242
1243 let prod_file = src_dir.join("Calculator.php");
1244 std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1245
1246 let test_file = test_dir.join("OtherTest.php");
1248 let test_source =
1249 "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1250 std::fs::write(&test_file, test_source).unwrap();
1251
1252 let ext = PhpExtractor::new();
1253 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1254 let mut test_sources = HashMap::new();
1255 test_sources.insert(
1256 test_file.to_string_lossy().into_owned(),
1257 test_source.to_string(),
1258 );
1259
1260 let mappings =
1261 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1262
1263 let calc_mapping = mappings
1264 .iter()
1265 .find(|m| m.production_file.contains("Calculator.php"))
1266 .expect("expected Calculator.php in mappings");
1267 assert!(
1268 !calc_mapping
1269 .test_files
1270 .iter()
1271 .any(|t| t.contains("OtherTest.php")),
1272 "PHPUnit import should not create a mapping to Calculator.php"
1273 );
1274 }
1275
1276 #[test]
1280 fn php_fw_04_symfony_self_test() {
1281 let dir = tempfile::tempdir().expect("failed to create tempdir");
1287 let src_dir = dir
1288 .path()
1289 .join("src")
1290 .join("Symfony")
1291 .join("Component")
1292 .join("HttpFoundation");
1293 std::fs::create_dir_all(&src_dir).unwrap();
1294 let test_dir = dir.path().join("tests").join("HttpFoundation");
1295 std::fs::create_dir_all(&test_dir).unwrap();
1296
1297 let prod_file = src_dir.join("Request.php");
1298 std::fs::write(
1299 &prod_file,
1300 "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1301 )
1302 .unwrap();
1303
1304 let test_file = test_dir.join("RequestTest.php");
1305 let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1306 std::fs::write(&test_file, test_source).unwrap();
1307
1308 let ext = PhpExtractor::new();
1309 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1310 let mut test_sources = HashMap::new();
1311 test_sources.insert(
1312 test_file.to_string_lossy().into_owned(),
1313 test_source.to_string(),
1314 );
1315
1316 let mappings =
1317 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1318
1319 let request_mapping = mappings
1320 .iter()
1321 .find(|m| m.production_file.contains("Request.php"))
1322 .expect("expected Request.php in mappings");
1323 assert!(
1324 request_mapping
1325 .test_files
1326 .iter()
1327 .any(|t| t.contains("RequestTest.php")),
1328 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1329 request_mapping.test_files
1330 );
1331 }
1332
1333 #[test]
1337 fn php_helper_06_fixtures_dir() {
1338 assert!(is_non_sut_helper("tests/Fixtures/SomeHelper.php", false));
1342 }
1343
1344 #[test]
1348 fn php_helper_07_fixtures_nested() {
1349 assert!(is_non_sut_helper("tests/Fixtures/nested/Stub.php", false));
1353 }
1354
1355 #[test]
1359 fn php_helper_08_stubs_dir() {
1360 assert!(is_non_sut_helper("tests/Stubs/UserStub.php", false));
1364 }
1365
1366 #[test]
1370 fn php_helper_09_stubs_nested() {
1371 assert!(is_non_sut_helper("tests/Stubs/nested/FakeRepo.php", false));
1375 }
1376
1377 #[test]
1381 fn php_helper_10_non_test_stubs() {
1382 assert!(!is_non_sut_helper("app/Stubs/Template.php", false));
1386 }
1387
1388 #[test]
1392 fn php_psr4_01_composer_json_resolution() {
1393 let dir = tempfile::tempdir().expect("failed to create tempdir");
1400 let custom_src_dir = dir.path().join("custom_src").join("Models");
1401 std::fs::create_dir_all(&custom_src_dir).unwrap();
1402 let test_dir = dir.path().join("tests");
1403 std::fs::create_dir_all(&test_dir).unwrap();
1404
1405 let composer_json = r#"{"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}"#;
1407 std::fs::write(dir.path().join("composer.json"), composer_json).unwrap();
1408
1409 let prod_file = custom_src_dir.join("Order.php");
1410 std::fs::write(
1411 &prod_file,
1412 "<?php\nnamespace MyApp\\Models;\nclass Order {}",
1413 )
1414 .unwrap();
1415
1416 let test_file = test_dir.join("OrderTest.php");
1417 let test_source = "<?php\nuse MyApp\\Models\\Order;\nclass OrderTest extends TestCase {}";
1418 std::fs::write(&test_file, test_source).unwrap();
1419
1420 let ext = PhpExtractor::new();
1421 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1422 let mut test_sources = HashMap::new();
1423 test_sources.insert(
1424 test_file.to_string_lossy().into_owned(),
1425 test_source.to_string(),
1426 );
1427
1428 let mappings =
1429 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1430
1431 let order_mapping = mappings
1432 .iter()
1433 .find(|m| m.production_file.contains("Order.php"))
1434 .expect("expected Order.php in mappings");
1435 assert!(
1436 order_mapping
1437 .test_files
1438 .iter()
1439 .any(|t| t.contains("OrderTest.php")),
1440 "expected OrderTest.php to be mapped to Order.php via PSR-4 composer.json resolution, got: {:?}",
1441 order_mapping.test_files
1442 );
1443 }
1444
1445 #[test]
1449 fn php_cli_01_dispatch() {
1450 let dir = tempfile::tempdir().expect("failed to create tempdir");
1454 let ext = PhpExtractor::new();
1455 let production_files: Vec<String> = vec![];
1456 let test_sources: HashMap<String, String> = HashMap::new();
1457 let mappings =
1458 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1459 assert!(mappings.is_empty());
1460 }
1461}
1462
1463#[cfg(test)]
1468mod parent_class_tests {
1469 use super::*;
1470 use std::collections::HashMap;
1471
1472 #[test]
1477 fn tc01_parent_imports_propagated_to_child() {
1478 let dir = tempfile::tempdir().expect("failed to create tempdir");
1480 let test_dir = dir.path().join("tests");
1481 std::fs::create_dir_all(&test_dir).unwrap();
1482
1483 let parent_source = r#"<?php
1484namespace App\Tests;
1485use Illuminate\View\Compilers\BladeCompiler;
1486use Illuminate\Container\Container;
1487use PHPUnit\Framework\TestCase;
1488abstract class AbstractBaseTest extends TestCase {}"#;
1489
1490 let parent_file = test_dir.join("AbstractBaseTest.php");
1491 std::fs::write(&parent_file, parent_source).unwrap();
1492
1493 let child_source = r#"<?php
1494namespace App\Tests;
1495class ChildTest extends AbstractBaseTest {
1496 public function testSomething() { $this->assertTrue(true); }
1497}"#;
1498
1499 let parent_imports = PhpExtractor::extract_parent_class_imports(
1501 child_source,
1502 &parent_file.parent().unwrap().to_string_lossy(),
1503 );
1504
1505 assert!(
1509 !parent_imports.is_empty(),
1510 "expected parent Illuminate imports to be propagated, got: {parent_imports:?}"
1511 );
1512 let has_blade = parent_imports
1513 .iter()
1514 .any(|(m, _)| m.contains("BladeCompiler") || m.contains("Compilers"));
1515 let has_container = parent_imports.iter().any(|(m, _)| m.contains("Container"));
1516 assert!(
1517 has_blade || has_container,
1518 "expected BladeCompiler or Container in parent imports, got: {parent_imports:?}"
1519 );
1520 }
1521
1522 #[test]
1527 fn tc02_parent_with_no_production_imports_adds_nothing() {
1528 let dir = tempfile::tempdir().expect("failed to create tempdir");
1530 let test_dir = dir.path().join("tests");
1531 std::fs::create_dir_all(&test_dir).unwrap();
1532 let app_dir = dir.path().join("app").join("Models");
1533 std::fs::create_dir_all(&app_dir).unwrap();
1534
1535 let parent_source = r#"<?php
1536namespace App\Tests;
1537use PHPUnit\Framework\TestCase;
1538abstract class MinimalBaseTest extends TestCase {}"#;
1539
1540 let parent_file = test_dir.join("MinimalBaseTest.php");
1541 std::fs::write(&parent_file, parent_source).unwrap();
1542
1543 let child_source = r#"<?php
1544namespace App\Tests;
1545use App\Models\Order;
1546class OrderTest extends MinimalBaseTest {
1547 public function testOrder() { $this->assertTrue(true); }
1548}"#;
1549
1550 let child_file = test_dir.join("OrderTest.php");
1551 std::fs::write(&child_file, child_source).unwrap();
1552
1553 let prod_file = app_dir.join("Order.php");
1554 std::fs::write(&prod_file, "<?php\nnamespace App\\Models;\nclass Order {}").unwrap();
1555
1556 let ext = PhpExtractor::new();
1557 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1558 let mut test_sources = HashMap::new();
1559 test_sources.insert(
1560 child_file.to_string_lossy().into_owned(),
1561 child_source.to_string(),
1562 );
1563
1564 let mappings =
1566 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1567
1568 let order_mapping = mappings
1571 .iter()
1572 .find(|m| m.production_file.contains("Order.php"))
1573 .expect("expected Order.php in mappings");
1574 assert!(
1575 !order_mapping.test_files.is_empty(),
1576 "expected OrderTest.php to be mapped to Order.php (child's own import), got empty"
1577 );
1578 }
1579
1580 #[test]
1585 fn tc03_external_parent_class_skipped() {
1586 let dir = tempfile::tempdir().expect("failed to create tempdir");
1589 let app_dir = dir.path().join("app").join("Services");
1590 std::fs::create_dir_all(&app_dir).unwrap();
1591 let test_dir = dir.path().join("tests");
1592 std::fs::create_dir_all(&test_dir).unwrap();
1593
1594 let prod_file = app_dir.join("PaymentService.php");
1595 std::fs::write(
1596 &prod_file,
1597 "<?php\nnamespace App\\Services;\nclass PaymentService {}",
1598 )
1599 .unwrap();
1600
1601 let test_source = r#"<?php
1603use PHPUnit\Framework\TestCase;
1604use App\Services\PaymentService;
1605class PaymentServiceTest extends TestCase {
1606 public function testPay() { $this->assertTrue(true); }
1607}"#;
1608 let test_file = test_dir.join("PaymentServiceTest.php");
1609 std::fs::write(&test_file, test_source).unwrap();
1610
1611 let ext = PhpExtractor::new();
1612 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1613 let mut test_sources = HashMap::new();
1614 test_sources.insert(
1615 test_file.to_string_lossy().into_owned(),
1616 test_source.to_string(),
1617 );
1618
1619 let mappings =
1621 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1622
1623 let payment_mapping = mappings
1626 .iter()
1627 .find(|m| m.production_file.contains("PaymentService.php"))
1628 .expect("expected PaymentService.php in mappings");
1629 assert!(
1630 payment_mapping
1631 .test_files
1632 .iter()
1633 .any(|t| t.contains("PaymentServiceTest.php")),
1634 "expected PaymentServiceTest.php mapped via own import; got: {:?}",
1635 payment_mapping.test_files
1636 );
1637 }
1639
1640 #[test]
1645 fn tc04_circular_inheritance_no_infinite_loop() {
1646 let dir = tempfile::tempdir().expect("failed to create tempdir");
1648 let test_dir = dir.path().join("tests");
1649 std::fs::create_dir_all(&test_dir).unwrap();
1650
1651 let a_source = r#"<?php
1652namespace App\Tests;
1653use App\Models\Foo;
1654class ATest extends BTest {}"#;
1655
1656 let b_source = r#"<?php
1657namespace App\Tests;
1658use App\Models\Bar;
1659class BTest extends ATest {}"#;
1660
1661 let a_file = test_dir.join("ATest.php");
1662 let b_file = test_dir.join("BTest.php");
1663 std::fs::write(&a_file, a_source).unwrap();
1664 std::fs::write(&b_file, b_source).unwrap();
1665
1666 let result =
1669 PhpExtractor::extract_parent_class_imports(a_source, &test_dir.to_string_lossy());
1670
1671 let _ = result;
1674 }
1675
1676 #[test]
1680 #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
1681 fn tc05_laravel_recall_above_90_percent() {
1682 unimplemented!(
1689 "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
1690 );
1691 }
1692
1693 #[test]
1697 #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
1698 fn tc06_laravel_no_new_false_positives() {
1699 unimplemented!(
1704 "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
1705 );
1706 }
1707}