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 if file_name == "Kernel.php" {
130 return true;
131 }
132
133 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
144const 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 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
198impl 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; 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 if method_idx == Some(cap.index) {
249 method_node = Some(cap.node);
250 }
251
252 if function_idx == Some(cap.index) {
254 is_exported = true;
255 }
256 }
257
258 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 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 let fs_path = raw.replace('\\', "/");
306
307 if is_external_namespace(&fs_path, None) {
309 continue;
310 }
311
312 let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
315 if parts.len() < 2 {
316 continue;
320 }
321
322 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 Vec::new()
342 }
343
344 fn source_extensions(&self) -> &[&str] {
345 &["php"]
346 }
347
348 fn index_file_names(&self) -> &[&str] {
349 &[]
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
366impl PhpExtractor {
371 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 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 let mut mappings =
429 exspec_core::observe::map_test_files(self, production_files, &test_file_list);
430
431 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 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 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 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 for symbol in _symbols {
481 let file_name = format!("{symbol}.php");
482
483 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 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 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
544fn 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 true
562}
563
564#[cfg(test)]
569mod tests {
570 use super::*;
571 use std::collections::HashMap;
572
573 #[test]
577 fn php_stem_01_test_suffix() {
578 assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
582 }
583
584 #[test]
588 fn php_stem_02_pest_suffix() {
589 assert_eq!(test_stem("tests/user_test.php"), Some("user"));
593 }
594
595 #[test]
599 fn php_stem_03_nested() {
600 assert_eq!(
604 test_stem("tests/Unit/OrderServiceTest.php"),
605 Some("OrderService")
606 );
607 }
608
609 #[test]
613 fn php_stem_04_non_test() {
614 assert_eq!(test_stem("src/User.php"), None);
618 }
619
620 #[test]
624 fn php_stem_05_prod_stem() {
625 assert_eq!(production_stem("src/User.php"), Some("User"));
629 }
630
631 #[test]
635 fn php_stem_06_prod_nested() {
636 assert_eq!(production_stem("src/Models/User.php"), Some("User"));
640 }
641
642 #[test]
646 fn php_stem_07_test_not_prod() {
647 assert_eq!(production_stem("tests/UserTest.php"), None);
651 }
652
653 #[test]
657 fn php_helper_01_test_case() {
658 assert!(is_non_sut_helper("tests/TestCase.php", false));
662 }
663
664 #[test]
668 fn php_helper_02_factory() {
669 assert!(is_non_sut_helper("tests/UserFactory.php", false));
673 }
674
675 #[test]
679 fn php_helper_03_production() {
680 assert!(!is_non_sut_helper("src/User.php", false));
684 }
685
686 #[test]
690 fn php_helper_04_test_trait() {
691 assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
695 }
696
697 #[test]
701 fn php_helper_05_bootstrap() {
702 assert!(is_non_sut_helper("bootstrap/app.php", false));
706 }
707
708 #[test]
712 fn php_func_01_public_method() {
713 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 #[test]
727 fn php_func_02_private_method() {
728 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 #[test]
742 fn php_func_03_class_method() {
743 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 #[test]
757 fn php_func_04_top_level_function() {
758 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 #[test]
773 fn php_imp_01_app_models() {
774 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 #[test]
792 fn php_imp_02_app_services() {
793 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 #[test]
811 fn php_imp_03_external_phpunit() {
812 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 #[test]
828 fn php_imp_04_external_illuminate() {
829 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 #[test]
845 fn php_e2e_01_stem_match() {
846 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 #[test]
885 fn php_e2e_02_import_match() {
886 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 #[test]
929 fn php_e2e_03_helper_exclusion() {
930 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 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 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 #[test]
977 fn php_fw_01_laravel_framework_self_test() {
978 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 #[test]
1029 fn php_fw_02_normal_app_illuminate_filtered() {
1030 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 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 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 #[test]
1079 fn php_fw_03_phpunit_still_external() {
1080 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 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 #[test]
1127 fn php_fw_04_symfony_self_test() {
1128 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 #[test]
1184 fn php_cli_01_dispatch() {
1185 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}