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 l1_exclusive: bool,
425 ) -> Vec<FileMapping> {
426 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
427
428 let mut mappings =
430 exspec_core::observe::map_test_files(self, production_files, &test_file_list);
431
432 let canonical_root = match scan_root.canonicalize() {
434 Ok(r) => r,
435 Err(_) => return mappings,
436 };
437 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
438 for (idx, prod) in production_files.iter().enumerate() {
439 if let Ok(canonical) = Path::new(prod).canonicalize() {
440 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
441 }
442 }
443
444 let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
446 .iter()
447 .map(|m| m.test_files.iter().cloned().collect())
448 .collect();
449
450 let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
452 .iter()
453 .flat_map(|s| s.iter().cloned())
454 .collect();
455
456 for (test_file, source) in test_sources {
459 if l1_exclusive && layer1_matched.contains(test_file) {
460 continue;
461 }
462 let raw_specifiers = Self::extract_raw_import_specifiers(source);
463 let specifiers: Vec<(String, Vec<String>)> = raw_specifiers
464 .into_iter()
465 .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
466 .collect();
467 let mut matched_indices = std::collections::HashSet::<usize>::new();
468
469 for (module_path, _symbols) in &specifiers {
470 let parts: Vec<&str> = module_path.splitn(2, '/').collect();
476 let path_without_prefix = if parts.len() == 2 {
477 parts[1]
478 } else {
479 module_path
480 };
481
482 for symbol in _symbols {
491 let file_name = format!("{symbol}.php");
492
493 let common_prefixes = ["src", "app", "lib", ""];
495 for prefix in &common_prefixes {
496 let candidate = if prefix.is_empty() {
497 canonical_root.join(path_without_prefix).join(&file_name)
498 } else {
499 canonical_root
500 .join(prefix)
501 .join(path_without_prefix)
502 .join(&file_name)
503 };
504
505 if let Ok(canonical_candidate) = candidate.canonicalize() {
506 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
507 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
508 matched_indices.insert(idx);
509 }
510 }
511 }
512
513 for prefix in &common_prefixes {
516 let candidate = if prefix.is_empty() {
517 canonical_root.join(module_path).join(&file_name)
518 } else {
519 canonical_root
520 .join(prefix)
521 .join(module_path)
522 .join(&file_name)
523 };
524 if let Ok(canonical_candidate) = candidate.canonicalize() {
525 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
526 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
527 matched_indices.insert(idx);
528 }
529 }
530 }
531 }
532 }
533
534 for idx in matched_indices {
535 if !mappings[idx].test_files.contains(test_file) {
536 mappings[idx].test_files.push(test_file.clone());
537 }
538 }
539 }
540
541 for (i, mapping) in mappings.iter_mut().enumerate() {
544 let has_layer1 = !layer1_tests_per_prod[i].is_empty();
545 if !has_layer1 && !mapping.test_files.is_empty() {
546 mapping.strategy = MappingStrategy::ImportTracing;
547 }
548 }
549
550 mappings
551 }
552}
553
554fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
562 for i in 0..node.child_count() {
563 if let Some(child) = node.child(i) {
564 if child.kind() == "visibility_modifier" {
565 let text = child.utf8_text(source_bytes).unwrap_or("");
566 return text == "public";
567 }
568 }
569 }
570 true
572}
573
574#[cfg(test)]
579mod tests {
580 use super::*;
581 use std::collections::HashMap;
582
583 #[test]
587 fn php_stem_01_test_suffix() {
588 assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
592 }
593
594 #[test]
598 fn php_stem_02_pest_suffix() {
599 assert_eq!(test_stem("tests/user_test.php"), Some("user"));
603 }
604
605 #[test]
609 fn php_stem_03_nested() {
610 assert_eq!(
614 test_stem("tests/Unit/OrderServiceTest.php"),
615 Some("OrderService")
616 );
617 }
618
619 #[test]
623 fn php_stem_04_non_test() {
624 assert_eq!(test_stem("src/User.php"), None);
628 }
629
630 #[test]
634 fn php_stem_05_prod_stem() {
635 assert_eq!(production_stem("src/User.php"), Some("User"));
639 }
640
641 #[test]
645 fn php_stem_06_prod_nested() {
646 assert_eq!(production_stem("src/Models/User.php"), Some("User"));
650 }
651
652 #[test]
656 fn php_stem_07_test_not_prod() {
657 assert_eq!(production_stem("tests/UserTest.php"), None);
661 }
662
663 #[test]
667 fn php_helper_01_test_case() {
668 assert!(is_non_sut_helper("tests/TestCase.php", false));
672 }
673
674 #[test]
678 fn php_helper_02_factory() {
679 assert!(is_non_sut_helper("tests/UserFactory.php", false));
683 }
684
685 #[test]
689 fn php_helper_03_production() {
690 assert!(!is_non_sut_helper("src/User.php", false));
694 }
695
696 #[test]
700 fn php_helper_04_test_trait() {
701 assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
705 }
706
707 #[test]
711 fn php_helper_05_bootstrap() {
712 assert!(is_non_sut_helper("bootstrap/app.php", false));
716 }
717
718 #[test]
722 fn php_func_01_public_method() {
723 let ext = PhpExtractor::new();
727 let source = "<?php\nclass User {\n public function createUser() {}\n}";
728 let fns = ext.extract_production_functions(source, "src/User.php");
729 let f = fns.iter().find(|f| f.name == "createUser").unwrap();
730 assert!(f.is_exported);
731 }
732
733 #[test]
737 fn php_func_02_private_method() {
738 let ext = PhpExtractor::new();
742 let source = "<?php\nclass User {\n private function helper() {}\n}";
743 let fns = ext.extract_production_functions(source, "src/User.php");
744 let f = fns.iter().find(|f| f.name == "helper").unwrap();
745 assert!(!f.is_exported);
746 }
747
748 #[test]
752 fn php_func_03_class_method() {
753 let ext = PhpExtractor::new();
757 let source = "<?php\nclass User {\n public function save() {}\n}";
758 let fns = ext.extract_production_functions(source, "src/User.php");
759 let f = fns.iter().find(|f| f.name == "save").unwrap();
760 assert_eq!(f.class_name, Some("User".to_string()));
761 }
762
763 #[test]
767 fn php_func_04_top_level_function() {
768 let ext = PhpExtractor::new();
772 let source = "<?php\nfunction global_helper() {\n return 42;\n}";
773 let fns = ext.extract_production_functions(source, "src/helpers.php");
774 let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
775 assert!(f.is_exported);
776 assert_eq!(f.class_name, None);
777 }
778
779 #[test]
783 fn php_imp_01_app_models() {
784 let ext = PhpExtractor::new();
788 let source = "<?php\nuse App\\Models\\User;\n";
789 let imports = ext.extract_all_import_specifiers(source);
790 assert!(
791 imports
792 .iter()
793 .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
794 "expected App/Models -> [User], got: {imports:?}"
795 );
796 }
797
798 #[test]
802 fn php_imp_02_app_services() {
803 let ext = PhpExtractor::new();
807 let source = "<?php\nuse App\\Services\\UserService;\n";
808 let imports = ext.extract_all_import_specifiers(source);
809 assert!(
810 imports
811 .iter()
812 .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
813 "expected App/Services -> [UserService], got: {imports:?}"
814 );
815 }
816
817 #[test]
821 fn php_imp_03_external_phpunit() {
822 let ext = PhpExtractor::new();
826 let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
827 let imports = ext.extract_all_import_specifiers(source);
828 assert!(
829 imports.is_empty(),
830 "external PHPUnit should be filtered, got: {imports:?}"
831 );
832 }
833
834 #[test]
838 fn php_imp_04_external_illuminate() {
839 let ext = PhpExtractor::new();
843 let source = "<?php\nuse Illuminate\\Http\\Request;\n";
844 let imports = ext.extract_all_import_specifiers(source);
845 assert!(
846 imports.is_empty(),
847 "external Illuminate should be filtered, got: {imports:?}"
848 );
849 }
850
851 #[test]
855 fn php_e2e_01_stem_match() {
856 let dir = tempfile::tempdir().expect("failed to create tempdir");
861
862 let prod_file = dir.path().join("User.php");
863 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
864
865 let test_file = dir.path().join("UserTest.php");
866 std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
867
868 let ext = PhpExtractor::new();
869 let production_files = vec![prod_file.to_string_lossy().into_owned()];
870 let mut test_sources = HashMap::new();
871 test_sources.insert(
872 test_file.to_string_lossy().into_owned(),
873 "<?php\nclass UserTest extends TestCase {}".to_string(),
874 );
875
876 let mappings =
877 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
878
879 assert!(!mappings.is_empty(), "expected at least one mapping");
880 let user_mapping = mappings
881 .iter()
882 .find(|m| m.production_file.contains("User.php"))
883 .expect("expected User.php in mappings");
884 assert!(
885 !user_mapping.test_files.is_empty(),
886 "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
887 );
888 }
889
890 #[test]
895 fn php_e2e_02_import_match() {
896 let dir = tempfile::tempdir().expect("failed to create tempdir");
901 let services_dir = dir.path().join("app").join("Services");
902 std::fs::create_dir_all(&services_dir).unwrap();
903 let test_dir = dir.path().join("tests");
904 std::fs::create_dir_all(&test_dir).unwrap();
905
906 let prod_file = services_dir.join("OrderService.php");
907 std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
908
909 let test_file = test_dir.join("ServiceTest.php");
910 let test_source =
911 "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
912 std::fs::write(&test_file, test_source).unwrap();
913
914 let ext = PhpExtractor::new();
915 let production_files = vec![prod_file.to_string_lossy().into_owned()];
916 let mut test_sources = HashMap::new();
917 test_sources.insert(
918 test_file.to_string_lossy().into_owned(),
919 test_source.to_string(),
920 );
921
922 let mappings =
923 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
924
925 let order_mapping = mappings
926 .iter()
927 .find(|m| m.production_file.contains("OrderService.php"))
928 .expect("expected OrderService.php in mappings");
929 assert!(
930 !order_mapping.test_files.is_empty(),
931 "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
932 );
933 }
934
935 #[test]
939 fn php_e2e_03_helper_exclusion() {
940 let dir = tempfile::tempdir().expect("failed to create tempdir");
944 let src_dir = dir.path().join("src");
945 std::fs::create_dir_all(&src_dir).unwrap();
946 let test_dir = dir.path().join("tests");
947 std::fs::create_dir_all(&test_dir).unwrap();
948
949 let prod_file = src_dir.join("User.php");
950 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
951
952 let test_case_file = test_dir.join("TestCase.php");
954 std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
955
956 let ext = PhpExtractor::new();
957 let production_files = vec![prod_file.to_string_lossy().into_owned()];
958 let mut test_sources = HashMap::new();
959 test_sources.insert(
960 test_case_file.to_string_lossy().into_owned(),
961 "<?php\nabstract class TestCase {}".to_string(),
962 );
963
964 let mappings =
965 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
966
967 let user_mapping = mappings
969 .iter()
970 .find(|m| m.production_file.contains("User.php"));
971 if let Some(mapping) = user_mapping {
972 assert!(
973 mapping.test_files.is_empty()
974 || !mapping
975 .test_files
976 .iter()
977 .any(|t| t.contains("TestCase.php")),
978 "TestCase.php should not be mapped as a test file for User.php"
979 );
980 }
981 }
982
983 #[test]
987 fn php_fw_01_laravel_framework_self_test() {
988 let dir = tempfile::tempdir().expect("failed to create tempdir");
993 let src_dir = dir.path().join("src").join("Illuminate").join("Http");
994 std::fs::create_dir_all(&src_dir).unwrap();
995 let test_dir = dir.path().join("tests").join("Http");
996 std::fs::create_dir_all(&test_dir).unwrap();
997
998 let prod_file = src_dir.join("Request.php");
999 std::fs::write(
1000 &prod_file,
1001 "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1002 )
1003 .unwrap();
1004
1005 let test_file = test_dir.join("RequestTest.php");
1006 let test_source =
1007 "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1008 std::fs::write(&test_file, test_source).unwrap();
1009
1010 let ext = PhpExtractor::new();
1011 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1012 let mut test_sources = HashMap::new();
1013 test_sources.insert(
1014 test_file.to_string_lossy().into_owned(),
1015 test_source.to_string(),
1016 );
1017
1018 let mappings =
1019 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1020
1021 let request_mapping = mappings
1022 .iter()
1023 .find(|m| m.production_file.contains("Request.php"))
1024 .expect("expected Request.php in mappings");
1025 assert!(
1026 request_mapping
1027 .test_files
1028 .iter()
1029 .any(|t| t.contains("RequestTest.php")),
1030 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1031 request_mapping.test_files
1032 );
1033 }
1034
1035 #[test]
1039 fn php_fw_02_normal_app_illuminate_filtered() {
1040 let dir = tempfile::tempdir().expect("failed to create tempdir");
1046 let app_dir = dir.path().join("app").join("Models");
1047 std::fs::create_dir_all(&app_dir).unwrap();
1048 let test_dir = dir.path().join("tests");
1049 std::fs::create_dir_all(&test_dir).unwrap();
1050
1051 let prod_file = app_dir.join("User.php");
1052 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1053
1054 let test_file = test_dir.join("OrderTest.php");
1056 let test_source =
1057 "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1058 std::fs::write(&test_file, test_source).unwrap();
1059
1060 let ext = PhpExtractor::new();
1061 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1062 let mut test_sources = HashMap::new();
1063 test_sources.insert(
1064 test_file.to_string_lossy().into_owned(),
1065 test_source.to_string(),
1066 );
1067
1068 let mappings =
1069 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1070
1071 let user_mapping = mappings
1073 .iter()
1074 .find(|m| m.production_file.contains("User.php"))
1075 .expect("expected User.php in mappings");
1076 assert!(
1077 !user_mapping
1078 .test_files
1079 .iter()
1080 .any(|t| t.contains("OrderTest.php")),
1081 "Illuminate import should be filtered when no local source exists"
1082 );
1083 }
1084
1085 #[test]
1089 fn php_fw_03_phpunit_still_external() {
1090 let dir = tempfile::tempdir().expect("failed to create tempdir");
1095 let src_dir = dir.path().join("src");
1096 std::fs::create_dir_all(&src_dir).unwrap();
1097 let test_dir = dir.path().join("tests");
1098 std::fs::create_dir_all(&test_dir).unwrap();
1099
1100 let prod_file = src_dir.join("Calculator.php");
1101 std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1102
1103 let test_file = test_dir.join("OtherTest.php");
1105 let test_source =
1106 "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1107 std::fs::write(&test_file, test_source).unwrap();
1108
1109 let ext = PhpExtractor::new();
1110 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1111 let mut test_sources = HashMap::new();
1112 test_sources.insert(
1113 test_file.to_string_lossy().into_owned(),
1114 test_source.to_string(),
1115 );
1116
1117 let mappings =
1118 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1119
1120 let calc_mapping = mappings
1121 .iter()
1122 .find(|m| m.production_file.contains("Calculator.php"))
1123 .expect("expected Calculator.php in mappings");
1124 assert!(
1125 !calc_mapping
1126 .test_files
1127 .iter()
1128 .any(|t| t.contains("OtherTest.php")),
1129 "PHPUnit import should not create a mapping to Calculator.php"
1130 );
1131 }
1132
1133 #[test]
1137 fn php_fw_04_symfony_self_test() {
1138 let dir = tempfile::tempdir().expect("failed to create tempdir");
1144 let src_dir = dir
1145 .path()
1146 .join("src")
1147 .join("Symfony")
1148 .join("Component")
1149 .join("HttpFoundation");
1150 std::fs::create_dir_all(&src_dir).unwrap();
1151 let test_dir = dir.path().join("tests").join("HttpFoundation");
1152 std::fs::create_dir_all(&test_dir).unwrap();
1153
1154 let prod_file = src_dir.join("Request.php");
1155 std::fs::write(
1156 &prod_file,
1157 "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1158 )
1159 .unwrap();
1160
1161 let test_file = test_dir.join("RequestTest.php");
1162 let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1163 std::fs::write(&test_file, test_source).unwrap();
1164
1165 let ext = PhpExtractor::new();
1166 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1167 let mut test_sources = HashMap::new();
1168 test_sources.insert(
1169 test_file.to_string_lossy().into_owned(),
1170 test_source.to_string(),
1171 );
1172
1173 let mappings =
1174 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1175
1176 let request_mapping = mappings
1177 .iter()
1178 .find(|m| m.production_file.contains("Request.php"))
1179 .expect("expected Request.php in mappings");
1180 assert!(
1181 request_mapping
1182 .test_files
1183 .iter()
1184 .any(|t| t.contains("RequestTest.php")),
1185 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1186 request_mapping.test_files
1187 );
1188 }
1189
1190 #[test]
1194 fn php_cli_01_dispatch() {
1195 let dir = tempfile::tempdir().expect("failed to create tempdir");
1199 let ext = PhpExtractor::new();
1200 let production_files: Vec<String> = vec![];
1201 let test_sources: HashMap<String, String> = HashMap::new();
1202 let mappings =
1203 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1204 assert!(mappings.is_empty());
1205 }
1206}