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
29pub fn test_stem(path: &str) -> Option<&str> {
39 let file_name = Path::new(path).file_name()?.to_str()?;
40 let stem = file_name.strip_suffix(".php")?;
42
43 if let Some(rest) = stem.strip_suffix("Test") {
45 if !rest.is_empty() {
46 return Some(rest);
47 }
48 }
49
50 if let Some(rest) = stem.strip_suffix("_test") {
52 if !rest.is_empty() {
53 return Some(rest);
54 }
55 }
56
57 None
58}
59
60pub fn production_stem(path: &str) -> Option<&str> {
65 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
80pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
82 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 if file_name == "TestCase.php" {
95 return true;
96 }
97
98 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 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 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 if normalized.contains("/tests/Traits/") || normalized.starts_with("tests/Traits/") {
125 return true;
126 }
127
128 let lower = normalized.to_lowercase();
130 if (lower.contains("/tests/fixtures/") || lower.starts_with("tests/fixtures/"))
131 || (lower.contains("/tests/stubs/") || lower.starts_with("tests/stubs/"))
132 {
133 return true;
134 }
135
136 if file_name == "Kernel.php" {
138 return true;
139 }
140
141 if file_name == "bootstrap.php" {
143 return true;
144 }
145 if normalized.starts_with("bootstrap/") || normalized.contains("/bootstrap/") {
146 return true;
147 }
148
149 false
150}
151
152pub fn load_psr4_prefixes(scan_root: &Path) -> HashMap<String, String> {
160 let composer_path = scan_root.join("composer.json");
161 let content = match std::fs::read_to_string(&composer_path) {
162 Ok(s) => s,
163 Err(_) => return HashMap::new(),
164 };
165 let value: serde_json::Value = match serde_json::from_str(&content) {
166 Ok(v) => v,
167 Err(_) => return HashMap::new(),
168 };
169
170 let mut result = HashMap::new();
171
172 for section in &["autoload", "autoload-dev"] {
174 if let Some(psr4) = value
175 .get(section)
176 .and_then(|a| a.get("psr-4"))
177 .and_then(|p| p.as_object())
178 {
179 for (ns, dir) in psr4 {
180 let ns_key = ns.trim_end_matches('\\').to_string();
182 let dir_val = dir.as_str().unwrap_or("").trim_end_matches('/').to_string();
184 if !ns_key.is_empty() {
185 result.insert(ns_key, dir_val);
186 }
187 }
188 }
189 }
190
191 result
192}
193
194const EXTERNAL_NAMESPACES: &[&str] = &[
200 "PHPUnit",
201 "Illuminate",
202 "Symfony",
203 "Doctrine",
204 "Mockery",
205 "Carbon",
206 "Pest",
207 "Laravel",
208 "Monolog",
209 "Psr",
210 "GuzzleHttp",
211 "League",
212 "Ramsey",
213 "Spatie",
214 "Nette",
215 "Webmozart",
216 "PhpParser",
217 "SebastianBergmann",
218];
219
220fn is_external_namespace(namespace: &str, scan_root: Option<&Path>) -> bool {
221 let first_segment = namespace.split('/').next().unwrap_or("");
222 let is_known_external = EXTERNAL_NAMESPACES
223 .iter()
224 .any(|&ext| first_segment.eq_ignore_ascii_case(ext));
225
226 if !is_known_external {
227 return false;
228 }
229
230 if let Some(root) = scan_root {
233 for prefix in &["src", "app", "lib", ""] {
234 let candidate = if prefix.is_empty() {
235 root.join(first_segment)
236 } else {
237 root.join(prefix).join(first_segment)
238 };
239 if candidate.is_dir() {
240 return false;
241 }
242 }
243 }
244
245 true
246}
247
248impl ObserveExtractor for PhpExtractor {
253 fn extract_production_functions(
254 &self,
255 source: &str,
256 file_path: &str,
257 ) -> Vec<ProductionFunction> {
258 let mut parser = Self::parser();
259 let tree = match parser.parse(source, None) {
260 Some(t) => t,
261 None => return Vec::new(),
262 };
263 let source_bytes = source.as_bytes();
264 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
265
266 let name_idx = query.capture_index_for_name("name");
267 let class_name_idx = query.capture_index_for_name("class_name");
268 let method_name_idx = query.capture_index_for_name("method_name");
269 let function_idx = query.capture_index_for_name("function");
270 let method_idx = query.capture_index_for_name("method");
271
272 let mut cursor = QueryCursor::new();
273 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
274 let mut result = Vec::new();
275
276 while let Some(m) = matches.next() {
277 let mut fn_name: Option<String> = None;
278 let mut class_name: Option<String> = None;
279 let mut line: usize = 1;
280 let mut is_exported = true; let mut method_node: Option<tree_sitter::Node> = None;
282
283 for cap in m.captures {
284 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
285 let node_line = cap.node.start_position().row + 1;
286
287 if name_idx == Some(cap.index) {
288 fn_name = Some(text);
289 line = node_line;
290 } else if class_name_idx == Some(cap.index) {
291 class_name = Some(text);
292 } else if method_name_idx == Some(cap.index) {
293 fn_name = Some(text);
294 line = node_line;
295 }
296
297 if method_idx == Some(cap.index) {
299 method_node = Some(cap.node);
300 }
301
302 if function_idx == Some(cap.index) {
304 is_exported = true;
305 }
306 }
307
308 if let Some(method) = method_node {
310 is_exported = has_public_visibility(method, source_bytes);
311 }
312
313 if let Some(name) = fn_name {
314 result.push(ProductionFunction {
315 name,
316 file: file_path.to_string(),
317 line,
318 class_name,
319 is_exported,
320 });
321 }
322 }
323
324 result
325 }
326
327 fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
328 Vec::new()
330 }
331
332 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
333 let mut parser = Self::parser();
334 let tree = match parser.parse(source, None) {
335 Some(t) => t,
336 None => return Vec::new(),
337 };
338 let source_bytes = source.as_bytes();
339 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
340
341 let namespace_path_idx = query.capture_index_for_name("namespace_path");
342
343 let mut cursor = QueryCursor::new();
344 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
345
346 let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
347
348 while let Some(m) = matches.next() {
349 for cap in m.captures {
350 if namespace_path_idx != Some(cap.index) {
351 continue;
352 }
353 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
354 let fs_path = raw.replace('\\', "/");
356
357 if is_external_namespace(&fs_path, None) {
359 continue;
360 }
361
362 let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
365 if parts.len() < 2 {
366 continue;
370 }
371
372 if let Some(last_slash) = fs_path.rfind('/') {
374 let module_path = &fs_path[..last_slash];
375 let symbol = &fs_path[last_slash + 1..];
376 if !module_path.is_empty() && !symbol.is_empty() {
377 result_map
378 .entry(module_path.to_string())
379 .or_default()
380 .push(symbol.to_string());
381 }
382 }
383 }
384 }
385
386 result_map.into_iter().collect()
387 }
388
389 fn extract_barrel_re_exports(&self, _source: &str, _file_path: &str) -> Vec<BarrelReExport> {
390 Vec::new()
392 }
393
394 fn source_extensions(&self) -> &[&str] {
395 &["php"]
396 }
397
398 fn index_file_names(&self) -> &[&str] {
399 &[]
401 }
402
403 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
404 production_stem(path)
405 }
406
407 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
408 test_stem(path)
409 }
410
411 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
412 is_non_sut_helper(file_path, is_known_production)
413 }
414}
415
416impl PhpExtractor {
421 fn extract_raw_import_specifiers(source: &str) -> Vec<(String, Vec<String>)> {
424 let mut parser = Self::parser();
425 let tree = match parser.parse(source, None) {
426 Some(t) => t,
427 None => return Vec::new(),
428 };
429 let source_bytes = source.as_bytes();
430 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
431
432 let namespace_path_idx = query.capture_index_for_name("namespace_path");
433
434 let mut cursor = QueryCursor::new();
435 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
436
437 let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
438
439 while let Some(m) = matches.next() {
440 for cap in m.captures {
441 if namespace_path_idx != Some(cap.index) {
442 continue;
443 }
444 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
445 let fs_path = raw.replace('\\', "/");
446
447 let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
448 if parts.len() < 2 {
449 continue;
450 }
451
452 if let Some(last_slash) = fs_path.rfind('/') {
453 let module_path = &fs_path[..last_slash];
454 let symbol = &fs_path[last_slash + 1..];
455 if !module_path.is_empty() && !symbol.is_empty() {
456 result_map
457 .entry(module_path.to_string())
458 .or_default()
459 .push(symbol.to_string());
460 }
461 }
462 }
463 }
464
465 result_map.into_iter().collect()
466 }
467
468 pub fn map_test_files_with_imports(
470 &self,
471 production_files: &[String],
472 test_sources: &HashMap<String, String>,
473 scan_root: &Path,
474 l1_exclusive: bool,
475 ) -> Vec<FileMapping> {
476 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
477
478 let mut mappings =
480 exspec_core::observe::map_test_files(self, production_files, &test_file_list);
481
482 let canonical_root = match scan_root.canonicalize() {
484 Ok(r) => r,
485 Err(_) => return mappings,
486 };
487 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
488 for (idx, prod) in production_files.iter().enumerate() {
489 if let Ok(canonical) = Path::new(prod).canonicalize() {
490 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
491 }
492 }
493
494 let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
496 .iter()
497 .map(|m| m.test_files.iter().cloned().collect())
498 .collect();
499
500 let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
502 .iter()
503 .flat_map(|s| s.iter().cloned())
504 .collect();
505
506 let psr4_prefixes = load_psr4_prefixes(scan_root);
508
509 for (test_file, source) in test_sources {
512 if l1_exclusive && layer1_matched.contains(test_file) {
513 continue;
514 }
515 let raw_specifiers = Self::extract_raw_import_specifiers(source);
516 let specifiers: Vec<(String, Vec<String>)> = raw_specifiers
517 .into_iter()
518 .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
519 .collect();
520 let mut matched_indices = std::collections::HashSet::<usize>::new();
521
522 for (module_path, _symbols) in &specifiers {
523 let parts: Vec<&str> = module_path.splitn(2, '/').collect();
529 let first_segment = parts[0];
530 let path_without_prefix = if parts.len() == 2 {
531 parts[1]
532 } else {
533 module_path.as_str()
534 };
535
536 let psr4_dir = psr4_prefixes.get(first_segment);
539
540 for symbol in _symbols {
549 let file_name = format!("{symbol}.php");
550
551 if let Some(psr4_base) = psr4_dir {
554 let candidate = canonical_root
555 .join(psr4_base)
556 .join(path_without_prefix)
557 .join(&file_name);
558 if let Ok(canonical_candidate) = candidate.canonicalize() {
559 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
560 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
561 matched_indices.insert(idx);
562 }
563 }
564 }
565
566 let common_prefixes = ["src", "app", "lib", ""];
568 for prefix in &common_prefixes {
569 let candidate = if prefix.is_empty() {
570 canonical_root.join(path_without_prefix).join(&file_name)
571 } else {
572 canonical_root
573 .join(prefix)
574 .join(path_without_prefix)
575 .join(&file_name)
576 };
577
578 if let Ok(canonical_candidate) = candidate.canonicalize() {
579 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
580 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
581 matched_indices.insert(idx);
582 }
583 }
584 }
585
586 for prefix in &common_prefixes {
589 let candidate = if prefix.is_empty() {
590 canonical_root.join(module_path).join(&file_name)
591 } else {
592 canonical_root
593 .join(prefix)
594 .join(module_path)
595 .join(&file_name)
596 };
597 if let Ok(canonical_candidate) = candidate.canonicalize() {
598 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
599 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
600 matched_indices.insert(idx);
601 }
602 }
603 }
604 }
605 }
606
607 for idx in matched_indices {
608 if !mappings[idx].test_files.contains(test_file) {
609 mappings[idx].test_files.push(test_file.clone());
610 }
611 }
612 }
613
614 for (i, mapping) in mappings.iter_mut().enumerate() {
617 let has_layer1 = !layer1_tests_per_prod[i].is_empty();
618 if !has_layer1 && !mapping.test_files.is_empty() {
619 mapping.strategy = MappingStrategy::ImportTracing;
620 }
621 }
622
623 mappings
624 }
625}
626
627fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
635 for i in 0..node.child_count() {
636 if let Some(child) = node.child(i) {
637 if child.kind() == "visibility_modifier" {
638 let text = child.utf8_text(source_bytes).unwrap_or("");
639 return text == "public";
640 }
641 }
642 }
643 true
645}
646
647#[cfg(test)]
652mod tests {
653 use super::*;
654 use std::collections::HashMap;
655
656 #[test]
660 fn php_stem_01_test_suffix() {
661 assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
665 }
666
667 #[test]
671 fn php_stem_02_pest_suffix() {
672 assert_eq!(test_stem("tests/user_test.php"), Some("user"));
676 }
677
678 #[test]
682 fn php_stem_03_nested() {
683 assert_eq!(
687 test_stem("tests/Unit/OrderServiceTest.php"),
688 Some("OrderService")
689 );
690 }
691
692 #[test]
696 fn php_stem_04_non_test() {
697 assert_eq!(test_stem("src/User.php"), None);
701 }
702
703 #[test]
707 fn php_stem_05_prod_stem() {
708 assert_eq!(production_stem("src/User.php"), Some("User"));
712 }
713
714 #[test]
718 fn php_stem_06_prod_nested() {
719 assert_eq!(production_stem("src/Models/User.php"), Some("User"));
723 }
724
725 #[test]
729 fn php_stem_07_test_not_prod() {
730 assert_eq!(production_stem("tests/UserTest.php"), None);
734 }
735
736 #[test]
740 fn php_helper_01_test_case() {
741 assert!(is_non_sut_helper("tests/TestCase.php", false));
745 }
746
747 #[test]
751 fn php_helper_02_factory() {
752 assert!(is_non_sut_helper("tests/UserFactory.php", false));
756 }
757
758 #[test]
762 fn php_helper_03_production() {
763 assert!(!is_non_sut_helper("src/User.php", false));
767 }
768
769 #[test]
773 fn php_helper_04_test_trait() {
774 assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
778 }
779
780 #[test]
784 fn php_helper_05_bootstrap() {
785 assert!(is_non_sut_helper("bootstrap/app.php", false));
789 }
790
791 #[test]
795 fn php_func_01_public_method() {
796 let ext = PhpExtractor::new();
800 let source = "<?php\nclass User {\n public function createUser() {}\n}";
801 let fns = ext.extract_production_functions(source, "src/User.php");
802 let f = fns.iter().find(|f| f.name == "createUser").unwrap();
803 assert!(f.is_exported);
804 }
805
806 #[test]
810 fn php_func_02_private_method() {
811 let ext = PhpExtractor::new();
815 let source = "<?php\nclass User {\n private function helper() {}\n}";
816 let fns = ext.extract_production_functions(source, "src/User.php");
817 let f = fns.iter().find(|f| f.name == "helper").unwrap();
818 assert!(!f.is_exported);
819 }
820
821 #[test]
825 fn php_func_03_class_method() {
826 let ext = PhpExtractor::new();
830 let source = "<?php\nclass User {\n public function save() {}\n}";
831 let fns = ext.extract_production_functions(source, "src/User.php");
832 let f = fns.iter().find(|f| f.name == "save").unwrap();
833 assert_eq!(f.class_name, Some("User".to_string()));
834 }
835
836 #[test]
840 fn php_func_04_top_level_function() {
841 let ext = PhpExtractor::new();
845 let source = "<?php\nfunction global_helper() {\n return 42;\n}";
846 let fns = ext.extract_production_functions(source, "src/helpers.php");
847 let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
848 assert!(f.is_exported);
849 assert_eq!(f.class_name, None);
850 }
851
852 #[test]
856 fn php_imp_01_app_models() {
857 let ext = PhpExtractor::new();
861 let source = "<?php\nuse App\\Models\\User;\n";
862 let imports = ext.extract_all_import_specifiers(source);
863 assert!(
864 imports
865 .iter()
866 .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
867 "expected App/Models -> [User], got: {imports:?}"
868 );
869 }
870
871 #[test]
875 fn php_imp_02_app_services() {
876 let ext = PhpExtractor::new();
880 let source = "<?php\nuse App\\Services\\UserService;\n";
881 let imports = ext.extract_all_import_specifiers(source);
882 assert!(
883 imports
884 .iter()
885 .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
886 "expected App/Services -> [UserService], got: {imports:?}"
887 );
888 }
889
890 #[test]
894 fn php_imp_03_external_phpunit() {
895 let ext = PhpExtractor::new();
899 let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
900 let imports = ext.extract_all_import_specifiers(source);
901 assert!(
902 imports.is_empty(),
903 "external PHPUnit should be filtered, got: {imports:?}"
904 );
905 }
906
907 #[test]
911 fn php_imp_04_external_illuminate() {
912 let ext = PhpExtractor::new();
916 let source = "<?php\nuse Illuminate\\Http\\Request;\n";
917 let imports = ext.extract_all_import_specifiers(source);
918 assert!(
919 imports.is_empty(),
920 "external Illuminate should be filtered, got: {imports:?}"
921 );
922 }
923
924 #[test]
928 fn php_e2e_01_stem_match() {
929 let dir = tempfile::tempdir().expect("failed to create tempdir");
934
935 let prod_file = dir.path().join("User.php");
936 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
937
938 let test_file = dir.path().join("UserTest.php");
939 std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
940
941 let ext = PhpExtractor::new();
942 let production_files = vec![prod_file.to_string_lossy().into_owned()];
943 let mut test_sources = HashMap::new();
944 test_sources.insert(
945 test_file.to_string_lossy().into_owned(),
946 "<?php\nclass UserTest extends TestCase {}".to_string(),
947 );
948
949 let mappings =
950 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
951
952 assert!(!mappings.is_empty(), "expected at least one mapping");
953 let user_mapping = mappings
954 .iter()
955 .find(|m| m.production_file.contains("User.php"))
956 .expect("expected User.php in mappings");
957 assert!(
958 !user_mapping.test_files.is_empty(),
959 "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
960 );
961 }
962
963 #[test]
968 fn php_e2e_02_import_match() {
969 let dir = tempfile::tempdir().expect("failed to create tempdir");
974 let services_dir = dir.path().join("app").join("Services");
975 std::fs::create_dir_all(&services_dir).unwrap();
976 let test_dir = dir.path().join("tests");
977 std::fs::create_dir_all(&test_dir).unwrap();
978
979 let prod_file = services_dir.join("OrderService.php");
980 std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
981
982 let test_file = test_dir.join("ServiceTest.php");
983 let test_source =
984 "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
985 std::fs::write(&test_file, test_source).unwrap();
986
987 let ext = PhpExtractor::new();
988 let production_files = vec![prod_file.to_string_lossy().into_owned()];
989 let mut test_sources = HashMap::new();
990 test_sources.insert(
991 test_file.to_string_lossy().into_owned(),
992 test_source.to_string(),
993 );
994
995 let mappings =
996 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
997
998 let order_mapping = mappings
999 .iter()
1000 .find(|m| m.production_file.contains("OrderService.php"))
1001 .expect("expected OrderService.php in mappings");
1002 assert!(
1003 !order_mapping.test_files.is_empty(),
1004 "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
1005 );
1006 }
1007
1008 #[test]
1012 fn php_e2e_03_helper_exclusion() {
1013 let dir = tempfile::tempdir().expect("failed to create tempdir");
1017 let src_dir = dir.path().join("src");
1018 std::fs::create_dir_all(&src_dir).unwrap();
1019 let test_dir = dir.path().join("tests");
1020 std::fs::create_dir_all(&test_dir).unwrap();
1021
1022 let prod_file = src_dir.join("User.php");
1023 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1024
1025 let test_case_file = test_dir.join("TestCase.php");
1027 std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
1028
1029 let ext = PhpExtractor::new();
1030 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1031 let mut test_sources = HashMap::new();
1032 test_sources.insert(
1033 test_case_file.to_string_lossy().into_owned(),
1034 "<?php\nabstract class TestCase {}".to_string(),
1035 );
1036
1037 let mappings =
1038 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1039
1040 let user_mapping = mappings
1042 .iter()
1043 .find(|m| m.production_file.contains("User.php"));
1044 if let Some(mapping) = user_mapping {
1045 assert!(
1046 mapping.test_files.is_empty()
1047 || !mapping
1048 .test_files
1049 .iter()
1050 .any(|t| t.contains("TestCase.php")),
1051 "TestCase.php should not be mapped as a test file for User.php"
1052 );
1053 }
1054 }
1055
1056 #[test]
1060 fn php_fw_01_laravel_framework_self_test() {
1061 let dir = tempfile::tempdir().expect("failed to create tempdir");
1066 let src_dir = dir.path().join("src").join("Illuminate").join("Http");
1067 std::fs::create_dir_all(&src_dir).unwrap();
1068 let test_dir = dir.path().join("tests").join("Http");
1069 std::fs::create_dir_all(&test_dir).unwrap();
1070
1071 let prod_file = src_dir.join("Request.php");
1072 std::fs::write(
1073 &prod_file,
1074 "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1075 )
1076 .unwrap();
1077
1078 let test_file = test_dir.join("RequestTest.php");
1079 let test_source =
1080 "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1081 std::fs::write(&test_file, test_source).unwrap();
1082
1083 let ext = PhpExtractor::new();
1084 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1085 let mut test_sources = HashMap::new();
1086 test_sources.insert(
1087 test_file.to_string_lossy().into_owned(),
1088 test_source.to_string(),
1089 );
1090
1091 let mappings =
1092 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1093
1094 let request_mapping = mappings
1095 .iter()
1096 .find(|m| m.production_file.contains("Request.php"))
1097 .expect("expected Request.php in mappings");
1098 assert!(
1099 request_mapping
1100 .test_files
1101 .iter()
1102 .any(|t| t.contains("RequestTest.php")),
1103 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1104 request_mapping.test_files
1105 );
1106 }
1107
1108 #[test]
1112 fn php_fw_02_normal_app_illuminate_filtered() {
1113 let dir = tempfile::tempdir().expect("failed to create tempdir");
1119 let app_dir = dir.path().join("app").join("Models");
1120 std::fs::create_dir_all(&app_dir).unwrap();
1121 let test_dir = dir.path().join("tests");
1122 std::fs::create_dir_all(&test_dir).unwrap();
1123
1124 let prod_file = app_dir.join("User.php");
1125 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1126
1127 let test_file = test_dir.join("OrderTest.php");
1129 let test_source =
1130 "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1131 std::fs::write(&test_file, test_source).unwrap();
1132
1133 let ext = PhpExtractor::new();
1134 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1135 let mut test_sources = HashMap::new();
1136 test_sources.insert(
1137 test_file.to_string_lossy().into_owned(),
1138 test_source.to_string(),
1139 );
1140
1141 let mappings =
1142 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1143
1144 let user_mapping = mappings
1146 .iter()
1147 .find(|m| m.production_file.contains("User.php"))
1148 .expect("expected User.php in mappings");
1149 assert!(
1150 !user_mapping
1151 .test_files
1152 .iter()
1153 .any(|t| t.contains("OrderTest.php")),
1154 "Illuminate import should be filtered when no local source exists"
1155 );
1156 }
1157
1158 #[test]
1162 fn php_fw_03_phpunit_still_external() {
1163 let dir = tempfile::tempdir().expect("failed to create tempdir");
1168 let src_dir = dir.path().join("src");
1169 std::fs::create_dir_all(&src_dir).unwrap();
1170 let test_dir = dir.path().join("tests");
1171 std::fs::create_dir_all(&test_dir).unwrap();
1172
1173 let prod_file = src_dir.join("Calculator.php");
1174 std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1175
1176 let test_file = test_dir.join("OtherTest.php");
1178 let test_source =
1179 "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1180 std::fs::write(&test_file, test_source).unwrap();
1181
1182 let ext = PhpExtractor::new();
1183 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1184 let mut test_sources = HashMap::new();
1185 test_sources.insert(
1186 test_file.to_string_lossy().into_owned(),
1187 test_source.to_string(),
1188 );
1189
1190 let mappings =
1191 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1192
1193 let calc_mapping = mappings
1194 .iter()
1195 .find(|m| m.production_file.contains("Calculator.php"))
1196 .expect("expected Calculator.php in mappings");
1197 assert!(
1198 !calc_mapping
1199 .test_files
1200 .iter()
1201 .any(|t| t.contains("OtherTest.php")),
1202 "PHPUnit import should not create a mapping to Calculator.php"
1203 );
1204 }
1205
1206 #[test]
1210 fn php_fw_04_symfony_self_test() {
1211 let dir = tempfile::tempdir().expect("failed to create tempdir");
1217 let src_dir = dir
1218 .path()
1219 .join("src")
1220 .join("Symfony")
1221 .join("Component")
1222 .join("HttpFoundation");
1223 std::fs::create_dir_all(&src_dir).unwrap();
1224 let test_dir = dir.path().join("tests").join("HttpFoundation");
1225 std::fs::create_dir_all(&test_dir).unwrap();
1226
1227 let prod_file = src_dir.join("Request.php");
1228 std::fs::write(
1229 &prod_file,
1230 "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1231 )
1232 .unwrap();
1233
1234 let test_file = test_dir.join("RequestTest.php");
1235 let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1236 std::fs::write(&test_file, test_source).unwrap();
1237
1238 let ext = PhpExtractor::new();
1239 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1240 let mut test_sources = HashMap::new();
1241 test_sources.insert(
1242 test_file.to_string_lossy().into_owned(),
1243 test_source.to_string(),
1244 );
1245
1246 let mappings =
1247 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1248
1249 let request_mapping = mappings
1250 .iter()
1251 .find(|m| m.production_file.contains("Request.php"))
1252 .expect("expected Request.php in mappings");
1253 assert!(
1254 request_mapping
1255 .test_files
1256 .iter()
1257 .any(|t| t.contains("RequestTest.php")),
1258 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1259 request_mapping.test_files
1260 );
1261 }
1262
1263 #[test]
1267 fn php_helper_06_fixtures_dir() {
1268 assert!(is_non_sut_helper("tests/Fixtures/SomeHelper.php", false));
1272 }
1273
1274 #[test]
1278 fn php_helper_07_fixtures_nested() {
1279 assert!(is_non_sut_helper("tests/Fixtures/nested/Stub.php", false));
1283 }
1284
1285 #[test]
1289 fn php_helper_08_stubs_dir() {
1290 assert!(is_non_sut_helper("tests/Stubs/UserStub.php", false));
1294 }
1295
1296 #[test]
1300 fn php_helper_09_stubs_nested() {
1301 assert!(is_non_sut_helper("tests/Stubs/nested/FakeRepo.php", false));
1305 }
1306
1307 #[test]
1311 fn php_helper_10_non_test_stubs() {
1312 assert!(!is_non_sut_helper("app/Stubs/Template.php", false));
1316 }
1317
1318 #[test]
1322 fn php_psr4_01_composer_json_resolution() {
1323 let dir = tempfile::tempdir().expect("failed to create tempdir");
1330 let custom_src_dir = dir.path().join("custom_src").join("Models");
1331 std::fs::create_dir_all(&custom_src_dir).unwrap();
1332 let test_dir = dir.path().join("tests");
1333 std::fs::create_dir_all(&test_dir).unwrap();
1334
1335 let composer_json = r#"{"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}"#;
1337 std::fs::write(dir.path().join("composer.json"), composer_json).unwrap();
1338
1339 let prod_file = custom_src_dir.join("Order.php");
1340 std::fs::write(
1341 &prod_file,
1342 "<?php\nnamespace MyApp\\Models;\nclass Order {}",
1343 )
1344 .unwrap();
1345
1346 let test_file = test_dir.join("OrderTest.php");
1347 let test_source = "<?php\nuse MyApp\\Models\\Order;\nclass OrderTest extends TestCase {}";
1348 std::fs::write(&test_file, test_source).unwrap();
1349
1350 let ext = PhpExtractor::new();
1351 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1352 let mut test_sources = HashMap::new();
1353 test_sources.insert(
1354 test_file.to_string_lossy().into_owned(),
1355 test_source.to_string(),
1356 );
1357
1358 let mappings =
1359 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1360
1361 let order_mapping = mappings
1362 .iter()
1363 .find(|m| m.production_file.contains("Order.php"))
1364 .expect("expected Order.php in mappings");
1365 assert!(
1366 order_mapping
1367 .test_files
1368 .iter()
1369 .any(|t| t.contains("OrderTest.php")),
1370 "expected OrderTest.php to be mapped to Order.php via PSR-4 composer.json resolution, got: {:?}",
1371 order_mapping.test_files
1372 );
1373 }
1374
1375 #[test]
1379 fn php_cli_01_dispatch() {
1380 let dir = tempfile::tempdir().expect("failed to create tempdir");
1384 let ext = PhpExtractor::new();
1385 let production_files: Vec<String> = vec![];
1386 let test_sources: HashMap<String, String> = HashMap::new();
1387 let mappings =
1388 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1389 assert!(mappings.is_empty());
1390 }
1391}