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
15#[derive(Debug, Clone, PartialEq)]
21pub struct Route {
22 pub http_method: String,
23 pub path: String,
24 pub handler_name: String,
25 pub class_name: String,
26 pub file: String,
27 pub line: usize,
28}
29
30const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
31static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
32
33const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
34static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35
36const EXTENDS_CLASS_QUERY: &str = include_str!("../queries/extends_class.scm");
37static EXTENDS_CLASS_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
38
39fn php_language() -> tree_sitter::Language {
40 tree_sitter_php::LANGUAGE_PHP.into()
41}
42
43fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
44 lock.get_or_init(|| Query::new(&php_language(), source).expect("invalid query"))
45}
46
47pub fn test_stem(path: &str) -> Option<&str> {
57 let file_name = Path::new(path).file_name()?.to_str()?;
58 let stem = file_name.strip_suffix(".php")?;
60
61 if let Some(rest) = stem.strip_suffix("Test") {
63 if !rest.is_empty() {
64 return Some(rest);
65 }
66 }
67
68 if let Some(rest) = stem.strip_suffix("_test") {
70 if !rest.is_empty() {
71 return Some(rest);
72 }
73 }
74
75 None
76}
77
78pub fn production_stem(path: &str) -> Option<&str> {
83 if test_stem(path).is_some() {
85 return None;
86 }
87
88 let file_name = Path::new(path).file_name()?.to_str()?;
89 let stem = file_name.strip_suffix(".php")?;
90
91 if stem.is_empty() {
92 return None;
93 }
94
95 Some(stem)
96}
97
98pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
100 if is_known_production {
102 return false;
103 }
104
105 let normalized = file_path.replace('\\', "/");
106 let file_name = Path::new(&normalized)
107 .file_name()
108 .and_then(|f| f.to_str())
109 .unwrap_or("");
110
111 if file_name == "TestCase.php" {
113 return true;
114 }
115
116 if file_name.ends_with("Factory.php") {
118 let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
119 if in_tests {
120 return true;
121 }
122 }
123
124 if file_name.starts_with("Abstract") && file_name.ends_with(".php") {
126 let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
127 if in_tests {
128 return true;
129 }
130 }
131
132 let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
134 if in_tests
135 && file_name.ends_with(".php")
136 && (file_name.starts_with("Trait") || file_name.ends_with("Trait.php"))
137 {
138 return true;
139 }
140
141 if normalized.contains("/tests/Traits/") || normalized.starts_with("tests/Traits/") {
143 return true;
144 }
145
146 let lower = normalized.to_lowercase();
148 if (lower.contains("/tests/fixtures/") || lower.starts_with("tests/fixtures/"))
149 || (lower.contains("/tests/stubs/") || lower.starts_with("tests/stubs/"))
150 {
151 return true;
152 }
153
154 if file_name == "Kernel.php" {
156 return true;
157 }
158
159 if file_name == "bootstrap.php" {
161 return true;
162 }
163 if normalized.starts_with("bootstrap/") || normalized.contains("/bootstrap/") {
164 return true;
165 }
166
167 false
168}
169
170pub fn load_psr4_prefixes(scan_root: &Path) -> HashMap<String, String> {
178 let composer_path = scan_root.join("composer.json");
179 let content = match std::fs::read_to_string(&composer_path) {
180 Ok(s) => s,
181 Err(_) => return HashMap::new(),
182 };
183 let value: serde_json::Value = match serde_json::from_str(&content) {
184 Ok(v) => v,
185 Err(_) => return HashMap::new(),
186 };
187
188 let mut result = HashMap::new();
189
190 for section in &["autoload", "autoload-dev"] {
192 if let Some(psr4) = value
193 .get(section)
194 .and_then(|a| a.get("psr-4"))
195 .and_then(|p| p.as_object())
196 {
197 for (ns, dir) in psr4 {
198 let ns_key = ns.trim_end_matches('\\').to_string();
200 let dir_val = dir.as_str().unwrap_or("").trim_end_matches('/').to_string();
202 if !ns_key.is_empty() {
203 result.insert(ns_key, dir_val);
204 }
205 }
206 }
207 }
208
209 result
210}
211
212const EXTERNAL_NAMESPACES: &[&str] = &[
218 "PHPUnit",
219 "Illuminate",
220 "Symfony",
221 "Doctrine",
222 "Mockery",
223 "Carbon",
224 "Pest",
225 "Laravel",
226 "Monolog",
227 "Psr",
228 "GuzzleHttp",
229 "League",
230 "Ramsey",
231 "Spatie",
232 "Nette",
233 "Webmozart",
234 "PhpParser",
235 "SebastianBergmann",
236];
237
238fn is_external_namespace(namespace: &str, scan_root: Option<&Path>) -> bool {
239 let first_segment = namespace.split('/').next().unwrap_or("");
240 let is_known_external = EXTERNAL_NAMESPACES
241 .iter()
242 .any(|&ext| first_segment.eq_ignore_ascii_case(ext));
243
244 if !is_known_external {
245 return false;
246 }
247
248 if let Some(root) = scan_root {
251 for prefix in &["src", "app", "lib", ""] {
252 let candidate = if prefix.is_empty() {
253 root.join(first_segment)
254 } else {
255 root.join(prefix).join(first_segment)
256 };
257 if candidate.is_dir() {
258 return false;
259 }
260 }
261 }
262
263 true
264}
265
266const LARAVEL_HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "any"];
272
273type PrefixGroup = (String, usize, usize);
275
276fn collect_prefix_groups(node: tree_sitter::Node, src: &[u8]) -> Vec<PrefixGroup> {
279 let mut groups = Vec::new();
280 collect_prefix_groups_recursive(node, src, &mut groups);
281 groups
282}
283
284fn collect_prefix_groups_recursive(
285 node: tree_sitter::Node,
286 src: &[u8],
287 groups: &mut Vec<PrefixGroup>,
288) {
289 if node.kind() == "member_call_expression" {
291 if let Some((prefix, body_start, body_end)) = try_extract_prefix_group(node, src) {
292 groups.push((prefix, body_start, body_end));
293 }
294 }
295 let mut cursor = node.walk();
297 for child in node.named_children(&mut cursor) {
298 collect_prefix_groups_recursive(child, src, groups);
299 }
300}
301
302fn try_extract_prefix_group(node: tree_sitter::Node, src: &[u8]) -> Option<(String, usize, usize)> {
306 let name_node = node.child_by_field_name("name")?;
311 let method_name = name_node.utf8_text(src).ok()?;
312 if method_name != "group" {
313 return None;
314 }
315
316 let object_node = node.child_by_field_name("object")?;
318
319 let prefix = extract_prefix_from_chain(object_node, src)?;
321
322 let args_node = node.child_by_field_name("arguments")?;
324 let body_range = find_closure_body_range(args_node, src)?;
325
326 Some((prefix, body_range.0, body_range.1))
327}
328
329fn extract_prefix_from_chain(node: tree_sitter::Node, src: &[u8]) -> Option<String> {
334 match node.kind() {
335 "scoped_call_expression" => {
336 let method_node = node.child_by_field_name("name")?;
338 let method = method_node.utf8_text(src).ok()?;
339 if method == "prefix" {
340 let args = node.child_by_field_name("arguments")?;
341 extract_first_string_arg(args, src)
342 } else {
343 None
344 }
345 }
346 "member_call_expression" => {
347 let object_node = node.child_by_field_name("object")?;
350 extract_prefix_from_chain(object_node, src)
351 }
352 _ => None,
353 }
354}
355
356fn find_closure_body_range(args_node: tree_sitter::Node, _src: &[u8]) -> Option<(usize, usize)> {
359 let mut cursor = args_node.walk();
361 for child in args_node.named_children(&mut cursor) {
362 if let Some(range) = closure_node_range(child) {
364 return Some(range);
365 }
366 if child.kind() == "argument" {
368 let mut ac = child.walk();
369 for grandchild in child.named_children(&mut ac) {
370 if let Some(range) = closure_node_range(grandchild) {
371 return Some(range);
372 }
373 }
374 }
375 }
376 None
377}
378
379fn closure_node_range(node: tree_sitter::Node) -> Option<(usize, usize)> {
380 match node.kind() {
381 "anonymous_function" | "arrow_function" | "closure_expression" => {
382 Some((node.start_byte(), node.end_byte()))
383 }
384 _ => None,
385 }
386}
387
388fn extract_first_string_arg(args_node: tree_sitter::Node, src: &[u8]) -> Option<String> {
390 let mut cursor = args_node.walk();
391 for child in args_node.named_children(&mut cursor) {
392 if child.kind() == "encapsed_string" || child.kind() == "string" {
393 let raw = child.utf8_text(src).ok()?;
394 return Some(strip_php_string_quotes(raw));
395 }
396 if child.kind() == "argument" {
398 let mut child_cursor = child.walk();
399 for grandchild in child.named_children(&mut child_cursor) {
400 if grandchild.kind() == "encapsed_string" || grandchild.kind() == "string" {
401 let raw = grandchild.utf8_text(src).ok()?;
402 return Some(strip_php_string_quotes(raw));
403 }
404 }
405 }
406 }
407 None
408}
409
410fn strip_php_string_quotes(s: &str) -> String {
412 let s = s.trim();
413 if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
414 s[1..s.len() - 1].to_string()
415 } else {
416 s.to_string()
417 }
418}
419
420fn collect_routes(
422 node: tree_sitter::Node,
423 src: &[u8],
424 file_path: &str,
425 prefix_groups: &[PrefixGroup],
426 routes: &mut Vec<Route>,
427) {
428 if node.kind() == "scoped_call_expression" {
430 if let Some(route) = try_extract_route(node, src, file_path, prefix_groups) {
431 routes.push(route);
432 }
433 }
434
435 let mut cursor = node.walk();
436 for child in node.named_children(&mut cursor) {
437 collect_routes(child, src, file_path, prefix_groups, routes);
438 }
439}
440
441fn try_extract_route(
443 node: tree_sitter::Node,
444 src: &[u8],
445 file_path: &str,
446 prefix_groups: &[PrefixGroup],
447) -> Option<Route> {
448 let scope_node = node.child_by_field_name("scope")?;
450 let scope = scope_node.utf8_text(src).ok()?;
451 if scope != "Route" {
452 return None;
453 }
454
455 let method_node = node.child_by_field_name("name")?;
457 let method = method_node.utf8_text(src).ok()?;
458 if !LARAVEL_HTTP_METHODS.contains(&method) {
459 return None;
460 }
461
462 let http_method = method.to_uppercase();
463 let line = node.start_position().row + 1;
464 let byte_offset = node.start_byte();
465
466 let args_node = node.child_by_field_name("arguments")?;
468 let (path_raw, handler_name, class_name) = extract_route_args(args_node, src)?;
469
470 let prefix = resolve_prefix(byte_offset, prefix_groups);
472
473 let path = if prefix.is_empty() {
475 path_raw
476 } else {
477 let path_part = path_raw.trim_start_matches('/');
479 format!("{prefix}/{path_part}")
480 };
481
482 Some(Route {
483 http_method,
484 path,
485 handler_name,
486 class_name,
487 file: file_path.to_string(),
488 line,
489 })
490}
491
492fn extract_route_args(
494 args_node: tree_sitter::Node,
495 src: &[u8],
496) -> Option<(String, String, String)> {
497 let args: Vec<tree_sitter::Node> = {
498 let mut cursor = args_node.walk();
499 args_node
500 .named_children(&mut cursor)
501 .filter(|n| n.kind() == "argument" || is_value_node(n.kind()))
502 .collect()
503 };
504
505 let values: Vec<tree_sitter::Node> = args
507 .iter()
508 .flat_map(|n| {
509 if n.kind() == "argument" {
510 let mut c = n.walk();
511 n.named_children(&mut c).collect::<Vec<_>>()
512 } else {
513 vec![*n]
514 }
515 })
516 .collect();
517
518 if values.is_empty() {
519 return None;
520 }
521
522 let path_node = values.first()?;
524 let path_raw = path_node.utf8_text(src).ok()?;
525 let path = strip_php_string_quotes(path_raw);
526
527 let handler_name;
529 let class_name;
530
531 if let Some(handler_node) = values.get(1) {
532 match handler_node.kind() {
533 "array_creation_expression" => {
534 let (cls, method) = extract_controller_array(*handler_node, src);
536 class_name = cls;
537 handler_name = method;
538 }
539 "closure_expression" | "arrow_function" | "anonymous_class" => {
540 class_name = String::new();
541 handler_name = String::new();
542 }
543 _ => {
544 class_name = String::new();
545 handler_name = String::new();
546 }
547 }
548 } else {
549 class_name = String::new();
550 handler_name = String::new();
551 }
552
553 Some((path, handler_name, class_name))
554}
555
556fn is_value_node(kind: &str) -> bool {
557 matches!(
558 kind,
559 "encapsed_string"
560 | "string"
561 | "array_creation_expression"
562 | "closure_expression"
563 | "arrow_function"
564 | "anonymous_class"
565 | "name"
566 )
567}
568
569fn extract_controller_array(array_node: tree_sitter::Node, src: &[u8]) -> (String, String) {
571 let mut cursor = array_node.walk();
572 let elements: Vec<tree_sitter::Node> = array_node
573 .named_children(&mut cursor)
574 .filter(|n| n.kind() == "array_element_initializer")
575 .collect();
576
577 let mut class_name = String::new();
578 let mut method_name = String::new();
579
580 if let Some(elem0) = elements.first() {
582 let mut ec = elem0.walk();
583 for child in elem0.named_children(&mut ec) {
584 if child.kind() == "class_constant_access_expression" {
585 if let Some(scope) = child.child_by_field_name("class") {
587 class_name = scope.utf8_text(src).unwrap_or("").to_string();
588 if let Some(last) = class_name.rsplit('\\').next() {
590 class_name = last.to_string();
591 }
592 } else {
593 let mut cc = child.walk();
595 let first_child_text: Option<String> = child
596 .named_children(&mut cc)
597 .next()
598 .and_then(|n| n.utf8_text(src).ok())
599 .map(|s| s.to_string());
600 drop(cc);
601 if let Some(raw) = first_child_text {
602 if let Some(last) = raw.rsplit('\\').next() {
603 class_name = last.to_string();
604 }
605 }
606 }
607 break;
608 }
609 }
610 }
611
612 if let Some(elem1) = elements.get(1) {
614 let mut ec = elem1.walk();
615 for child in elem1.named_children(&mut ec) {
616 if child.kind() == "encapsed_string" || child.kind() == "string" {
617 let raw = child.utf8_text(src).unwrap_or("");
618 method_name = strip_php_string_quotes(raw);
619 break;
620 }
621 }
622 }
623
624 (class_name, method_name)
625}
626
627fn resolve_prefix(byte_offset: usize, groups: &[PrefixGroup]) -> String {
630 let mut containing: Vec<&PrefixGroup> = groups
632 .iter()
633 .filter(|(_, start, end)| byte_offset > *start && byte_offset < *end)
634 .collect();
635
636 if containing.is_empty() {
637 return String::new();
638 }
639
640 containing.sort_by_key(|(_, start, _)| *start);
642
643 containing
645 .iter()
646 .map(|(p, _, _)| p.as_str())
647 .collect::<Vec<_>>()
648 .join("/")
649}
650
651impl ObserveExtractor for PhpExtractor {
656 fn extract_production_functions(
657 &self,
658 source: &str,
659 file_path: &str,
660 ) -> Vec<ProductionFunction> {
661 let mut parser = Self::parser();
662 let tree = match parser.parse(source, None) {
663 Some(t) => t,
664 None => return Vec::new(),
665 };
666 let source_bytes = source.as_bytes();
667 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
668
669 let name_idx = query.capture_index_for_name("name");
670 let class_name_idx = query.capture_index_for_name("class_name");
671 let method_name_idx = query.capture_index_for_name("method_name");
672 let function_idx = query.capture_index_for_name("function");
673 let method_idx = query.capture_index_for_name("method");
674
675 let mut cursor = QueryCursor::new();
676 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
677 let mut result = Vec::new();
678
679 while let Some(m) = matches.next() {
680 let mut fn_name: Option<String> = None;
681 let mut class_name: Option<String> = None;
682 let mut line: usize = 1;
683 let mut is_exported = true; let mut method_node: Option<tree_sitter::Node> = None;
685
686 for cap in m.captures {
687 let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
688 let node_line = cap.node.start_position().row + 1;
689
690 if name_idx == Some(cap.index) {
691 fn_name = Some(text);
692 line = node_line;
693 } else if class_name_idx == Some(cap.index) {
694 class_name = Some(text);
695 } else if method_name_idx == Some(cap.index) {
696 fn_name = Some(text);
697 line = node_line;
698 }
699
700 if method_idx == Some(cap.index) {
702 method_node = Some(cap.node);
703 }
704
705 if function_idx == Some(cap.index) {
707 is_exported = true;
708 }
709 }
710
711 if let Some(method) = method_node {
713 is_exported = has_public_visibility(method, source_bytes);
714 }
715
716 if let Some(name) = fn_name {
717 result.push(ProductionFunction {
718 name,
719 file: file_path.to_string(),
720 line,
721 class_name,
722 is_exported,
723 });
724 }
725 }
726
727 result
728 }
729
730 fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
731 Vec::new()
733 }
734
735 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
736 let mut parser = Self::parser();
737 let tree = match parser.parse(source, None) {
738 Some(t) => t,
739 None => return Vec::new(),
740 };
741 let source_bytes = source.as_bytes();
742 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
743
744 let namespace_path_idx = query.capture_index_for_name("namespace_path");
745
746 let mut cursor = QueryCursor::new();
747 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
748
749 let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
750
751 while let Some(m) = matches.next() {
752 for cap in m.captures {
753 if namespace_path_idx != Some(cap.index) {
754 continue;
755 }
756 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
757 let fs_path = raw.replace('\\', "/");
759
760 if is_external_namespace(&fs_path, None) {
762 continue;
763 }
764
765 let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
768 if parts.len() < 2 {
769 continue;
773 }
774
775 if let Some(last_slash) = fs_path.rfind('/') {
777 let module_path = &fs_path[..last_slash];
778 let symbol = &fs_path[last_slash + 1..];
779 if !module_path.is_empty() && !symbol.is_empty() {
780 result_map
781 .entry(module_path.to_string())
782 .or_default()
783 .push(symbol.to_string());
784 }
785 }
786 }
787 }
788
789 result_map.into_iter().collect()
790 }
791
792 fn extract_barrel_re_exports(&self, _source: &str, _file_path: &str) -> Vec<BarrelReExport> {
793 Vec::new()
795 }
796
797 fn source_extensions(&self) -> &[&str] {
798 &["php"]
799 }
800
801 fn index_file_names(&self) -> &[&str] {
802 &[]
804 }
805
806 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
807 production_stem(path)
808 }
809
810 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
811 test_stem(path)
812 }
813
814 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
815 is_non_sut_helper(file_path, is_known_production)
816 }
817}
818
819impl PhpExtractor {
824 pub fn extract_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
826 if source.is_empty() {
827 return Vec::new();
828 }
829
830 let mut parser = Self::parser();
831 let tree = match parser.parse(source, None) {
832 Some(t) => t,
833 None => return Vec::new(),
834 };
835 let source_bytes = source.as_bytes();
836
837 let prefix_groups = collect_prefix_groups(tree.root_node(), source_bytes);
839
840 let mut routes = Vec::new();
842 collect_routes(
843 tree.root_node(),
844 source_bytes,
845 file_path,
846 &prefix_groups,
847 &mut routes,
848 );
849
850 routes
851 }
852
853 fn extract_raw_import_specifiers(source: &str) -> Vec<(String, Vec<String>)> {
856 let mut parser = Self::parser();
857 let tree = match parser.parse(source, None) {
858 Some(t) => t,
859 None => return Vec::new(),
860 };
861 let source_bytes = source.as_bytes();
862 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
863
864 let namespace_path_idx = query.capture_index_for_name("namespace_path");
865
866 let mut cursor = QueryCursor::new();
867 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
868
869 let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
870
871 while let Some(m) = matches.next() {
872 for cap in m.captures {
873 if namespace_path_idx != Some(cap.index) {
874 continue;
875 }
876 let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
877 let fs_path = raw.replace('\\', "/");
878
879 let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
880 if parts.len() < 2 {
881 continue;
882 }
883
884 if let Some(last_slash) = fs_path.rfind('/') {
885 let module_path = &fs_path[..last_slash];
886 let symbol = &fs_path[last_slash + 1..];
887 if !module_path.is_empty() && !symbol.is_empty() {
888 result_map
889 .entry(module_path.to_string())
890 .or_default()
891 .push(symbol.to_string());
892 }
893 }
894 }
895 }
896
897 result_map.into_iter().collect()
898 }
899
900 pub fn extract_parent_class_imports(
905 source: &str,
906 test_dir: &str,
907 ) -> Vec<(String, Vec<String>)> {
908 let mut parser = Self::parser();
910 let tree = match parser.parse(source, None) {
911 Some(t) => t,
912 None => return Vec::new(),
913 };
914 let source_bytes = source.as_bytes();
915 let query = cached_query(&EXTENDS_CLASS_QUERY_CACHE, EXTENDS_CLASS_QUERY);
916
917 let parent_class_idx = query.capture_index_for_name("parent_class");
918
919 let mut cursor = QueryCursor::new();
920 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
921
922 let mut parent_class_name: Option<String> = None;
923 while let Some(m) = matches.next() {
924 for cap in m.captures {
925 if parent_class_idx == Some(cap.index) {
926 let name = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
927 if !name.is_empty() {
928 parent_class_name = Some(name);
929 break;
930 }
931 }
932 }
933 if parent_class_name.is_some() {
934 break;
935 }
936 }
937
938 let parent_name = match parent_class_name {
939 Some(n) => n,
940 None => return Vec::new(),
941 };
942
943 let parent_file_name = format!("{parent_name}.php");
945 let parent_path = Path::new(test_dir).join(&parent_file_name);
946
947 let parent_source = match std::fs::read_to_string(&parent_path) {
949 Ok(s) => s,
950 Err(_) => return Vec::new(),
951 };
952
953 Self::extract_raw_import_specifiers(&parent_source)
955 }
956
957 pub fn map_test_files_with_imports(
959 &self,
960 production_files: &[String],
961 test_sources: &HashMap<String, String>,
962 scan_root: &Path,
963 l1_exclusive: bool,
964 ) -> Vec<FileMapping> {
965 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
966
967 let mut mappings =
969 exspec_core::observe::map_test_files(self, production_files, &test_file_list);
970
971 let canonical_root = match scan_root.canonicalize() {
973 Ok(r) => r,
974 Err(_) => return mappings,
975 };
976 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
977 for (idx, prod) in production_files.iter().enumerate() {
978 if let Ok(canonical) = Path::new(prod).canonicalize() {
979 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
980 }
981 }
982
983 let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
985 .iter()
986 .map(|m| m.test_files.iter().cloned().collect())
987 .collect();
988
989 let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
991 .iter()
992 .flat_map(|s| s.iter().cloned())
993 .collect();
994
995 let psr4_prefixes = load_psr4_prefixes(scan_root);
997
998 for (test_file, source) in test_sources {
1001 if l1_exclusive && layer1_matched.contains(test_file) {
1002 continue;
1003 }
1004 let raw_specifiers = Self::extract_raw_import_specifiers(source);
1005 let parent_dir = Path::new(test_file.as_str())
1007 .parent()
1008 .map(|p| p.to_string_lossy().into_owned())
1009 .unwrap_or_default();
1010 let parent_specifiers = Self::extract_parent_class_imports(source, &parent_dir);
1011 let combined: Vec<(String, Vec<String>)> = raw_specifiers
1012 .into_iter()
1013 .chain(parent_specifiers.into_iter())
1014 .collect();
1015 let specifiers: Vec<(String, Vec<String>)> = combined
1016 .into_iter()
1017 .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
1018 .collect();
1019 let mut matched_indices = std::collections::HashSet::<usize>::new();
1020
1021 for (module_path, _symbols) in &specifiers {
1022 let parts: Vec<&str> = module_path.splitn(2, '/').collect();
1028 let first_segment = parts[0];
1029 let path_without_prefix = if parts.len() == 2 {
1030 parts[1]
1031 } else {
1032 module_path.as_str()
1033 };
1034
1035 let psr4_dir = psr4_prefixes.get(first_segment);
1038
1039 for symbol in _symbols {
1048 let file_name = format!("{symbol}.php");
1049
1050 if let Some(psr4_base) = psr4_dir {
1053 let candidate = canonical_root
1054 .join(psr4_base)
1055 .join(path_without_prefix)
1056 .join(&file_name);
1057 if let Ok(canonical_candidate) = candidate.canonicalize() {
1058 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
1059 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
1060 matched_indices.insert(idx);
1061 }
1062 }
1063 }
1064
1065 let common_prefixes = ["src", "app", "lib", ""];
1067 for prefix in &common_prefixes {
1068 let candidate = if prefix.is_empty() {
1069 canonical_root.join(path_without_prefix).join(&file_name)
1070 } else {
1071 canonical_root
1072 .join(prefix)
1073 .join(path_without_prefix)
1074 .join(&file_name)
1075 };
1076
1077 if let Ok(canonical_candidate) = candidate.canonicalize() {
1078 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
1079 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
1080 matched_indices.insert(idx);
1081 }
1082 }
1083 }
1084
1085 for prefix in &common_prefixes {
1088 let candidate = if prefix.is_empty() {
1089 canonical_root.join(module_path).join(&file_name)
1090 } else {
1091 canonical_root
1092 .join(prefix)
1093 .join(module_path)
1094 .join(&file_name)
1095 };
1096 if let Ok(canonical_candidate) = candidate.canonicalize() {
1097 let candidate_str = canonical_candidate.to_string_lossy().into_owned();
1098 if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
1099 matched_indices.insert(idx);
1100 }
1101 }
1102 }
1103 }
1104 }
1105
1106 for idx in matched_indices {
1107 if !mappings[idx].test_files.contains(test_file) {
1108 mappings[idx].test_files.push(test_file.clone());
1109 }
1110 }
1111 }
1112
1113 for (i, mapping) in mappings.iter_mut().enumerate() {
1116 let has_layer1 = !layer1_tests_per_prod[i].is_empty();
1117 if !has_layer1 && !mapping.test_files.is_empty() {
1118 mapping.strategy = MappingStrategy::ImportTracing;
1119 }
1120 }
1121
1122 mappings
1123 }
1124}
1125
1126fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
1134 for i in 0..node.child_count() {
1135 if let Some(child) = node.child(i) {
1136 if child.kind() == "visibility_modifier" {
1137 let text = child.utf8_text(source_bytes).unwrap_or("");
1138 return text == "public";
1139 }
1140 }
1141 }
1142 true
1144}
1145
1146#[cfg(test)]
1151mod tests {
1152 use super::*;
1153 use std::collections::HashMap;
1154
1155 #[test]
1159 fn php_stem_01_test_suffix() {
1160 assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
1164 }
1165
1166 #[test]
1170 fn php_stem_02_pest_suffix() {
1171 assert_eq!(test_stem("tests/user_test.php"), Some("user"));
1175 }
1176
1177 #[test]
1181 fn php_stem_03_nested() {
1182 assert_eq!(
1186 test_stem("tests/Unit/OrderServiceTest.php"),
1187 Some("OrderService")
1188 );
1189 }
1190
1191 #[test]
1195 fn php_stem_04_non_test() {
1196 assert_eq!(test_stem("src/User.php"), None);
1200 }
1201
1202 #[test]
1206 fn php_stem_05_prod_stem() {
1207 assert_eq!(production_stem("src/User.php"), Some("User"));
1211 }
1212
1213 #[test]
1217 fn php_stem_06_prod_nested() {
1218 assert_eq!(production_stem("src/Models/User.php"), Some("User"));
1222 }
1223
1224 #[test]
1228 fn php_stem_07_test_not_prod() {
1229 assert_eq!(production_stem("tests/UserTest.php"), None);
1233 }
1234
1235 #[test]
1239 fn php_helper_01_test_case() {
1240 assert!(is_non_sut_helper("tests/TestCase.php", false));
1244 }
1245
1246 #[test]
1250 fn php_helper_02_factory() {
1251 assert!(is_non_sut_helper("tests/UserFactory.php", false));
1255 }
1256
1257 #[test]
1261 fn php_helper_03_production() {
1262 assert!(!is_non_sut_helper("src/User.php", false));
1266 }
1267
1268 #[test]
1272 fn php_helper_04_test_trait() {
1273 assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
1277 }
1278
1279 #[test]
1283 fn php_helper_05_bootstrap() {
1284 assert!(is_non_sut_helper("bootstrap/app.php", false));
1288 }
1289
1290 #[test]
1294 fn php_func_01_public_method() {
1295 let ext = PhpExtractor::new();
1299 let source = "<?php\nclass User {\n public function createUser() {}\n}";
1300 let fns = ext.extract_production_functions(source, "src/User.php");
1301 let f = fns.iter().find(|f| f.name == "createUser").unwrap();
1302 assert!(f.is_exported);
1303 }
1304
1305 #[test]
1309 fn php_func_02_private_method() {
1310 let ext = PhpExtractor::new();
1314 let source = "<?php\nclass User {\n private function helper() {}\n}";
1315 let fns = ext.extract_production_functions(source, "src/User.php");
1316 let f = fns.iter().find(|f| f.name == "helper").unwrap();
1317 assert!(!f.is_exported);
1318 }
1319
1320 #[test]
1324 fn php_func_03_class_method() {
1325 let ext = PhpExtractor::new();
1329 let source = "<?php\nclass User {\n public function save() {}\n}";
1330 let fns = ext.extract_production_functions(source, "src/User.php");
1331 let f = fns.iter().find(|f| f.name == "save").unwrap();
1332 assert_eq!(f.class_name, Some("User".to_string()));
1333 }
1334
1335 #[test]
1339 fn php_func_04_top_level_function() {
1340 let ext = PhpExtractor::new();
1344 let source = "<?php\nfunction global_helper() {\n return 42;\n}";
1345 let fns = ext.extract_production_functions(source, "src/helpers.php");
1346 let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
1347 assert!(f.is_exported);
1348 assert_eq!(f.class_name, None);
1349 }
1350
1351 #[test]
1355 fn php_imp_01_app_models() {
1356 let ext = PhpExtractor::new();
1360 let source = "<?php\nuse App\\Models\\User;\n";
1361 let imports = ext.extract_all_import_specifiers(source);
1362 assert!(
1363 imports
1364 .iter()
1365 .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
1366 "expected App/Models -> [User], got: {imports:?}"
1367 );
1368 }
1369
1370 #[test]
1374 fn php_imp_02_app_services() {
1375 let ext = PhpExtractor::new();
1379 let source = "<?php\nuse App\\Services\\UserService;\n";
1380 let imports = ext.extract_all_import_specifiers(source);
1381 assert!(
1382 imports
1383 .iter()
1384 .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
1385 "expected App/Services -> [UserService], got: {imports:?}"
1386 );
1387 }
1388
1389 #[test]
1393 fn php_imp_03_external_phpunit() {
1394 let ext = PhpExtractor::new();
1398 let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
1399 let imports = ext.extract_all_import_specifiers(source);
1400 assert!(
1401 imports.is_empty(),
1402 "external PHPUnit should be filtered, got: {imports:?}"
1403 );
1404 }
1405
1406 #[test]
1410 fn php_imp_04_external_illuminate() {
1411 let ext = PhpExtractor::new();
1415 let source = "<?php\nuse Illuminate\\Http\\Request;\n";
1416 let imports = ext.extract_all_import_specifiers(source);
1417 assert!(
1418 imports.is_empty(),
1419 "external Illuminate should be filtered, got: {imports:?}"
1420 );
1421 }
1422
1423 #[test]
1427 fn php_e2e_01_stem_match() {
1428 let dir = tempfile::tempdir().expect("failed to create tempdir");
1433
1434 let prod_file = dir.path().join("User.php");
1435 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1436
1437 let test_file = dir.path().join("UserTest.php");
1438 std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
1439
1440 let ext = PhpExtractor::new();
1441 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1442 let mut test_sources = HashMap::new();
1443 test_sources.insert(
1444 test_file.to_string_lossy().into_owned(),
1445 "<?php\nclass UserTest extends TestCase {}".to_string(),
1446 );
1447
1448 let mappings =
1449 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1450
1451 assert!(!mappings.is_empty(), "expected at least one mapping");
1452 let user_mapping = mappings
1453 .iter()
1454 .find(|m| m.production_file.contains("User.php"))
1455 .expect("expected User.php in mappings");
1456 assert!(
1457 !user_mapping.test_files.is_empty(),
1458 "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
1459 );
1460 }
1461
1462 #[test]
1467 fn php_e2e_02_import_match() {
1468 let dir = tempfile::tempdir().expect("failed to create tempdir");
1473 let services_dir = dir.path().join("app").join("Services");
1474 std::fs::create_dir_all(&services_dir).unwrap();
1475 let test_dir = dir.path().join("tests");
1476 std::fs::create_dir_all(&test_dir).unwrap();
1477
1478 let prod_file = services_dir.join("OrderService.php");
1479 std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
1480
1481 let test_file = test_dir.join("ServiceTest.php");
1482 let test_source =
1483 "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
1484 std::fs::write(&test_file, test_source).unwrap();
1485
1486 let ext = PhpExtractor::new();
1487 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1488 let mut test_sources = HashMap::new();
1489 test_sources.insert(
1490 test_file.to_string_lossy().into_owned(),
1491 test_source.to_string(),
1492 );
1493
1494 let mappings =
1495 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1496
1497 let order_mapping = mappings
1498 .iter()
1499 .find(|m| m.production_file.contains("OrderService.php"))
1500 .expect("expected OrderService.php in mappings");
1501 assert!(
1502 !order_mapping.test_files.is_empty(),
1503 "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
1504 );
1505 }
1506
1507 #[test]
1511 fn php_e2e_03_helper_exclusion() {
1512 let dir = tempfile::tempdir().expect("failed to create tempdir");
1516 let src_dir = dir.path().join("src");
1517 std::fs::create_dir_all(&src_dir).unwrap();
1518 let test_dir = dir.path().join("tests");
1519 std::fs::create_dir_all(&test_dir).unwrap();
1520
1521 let prod_file = src_dir.join("User.php");
1522 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1523
1524 let test_case_file = test_dir.join("TestCase.php");
1526 std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
1527
1528 let ext = PhpExtractor::new();
1529 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1530 let mut test_sources = HashMap::new();
1531 test_sources.insert(
1532 test_case_file.to_string_lossy().into_owned(),
1533 "<?php\nabstract class TestCase {}".to_string(),
1534 );
1535
1536 let mappings =
1537 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1538
1539 let user_mapping = mappings
1541 .iter()
1542 .find(|m| m.production_file.contains("User.php"));
1543 if let Some(mapping) = user_mapping {
1544 assert!(
1545 mapping.test_files.is_empty()
1546 || !mapping
1547 .test_files
1548 .iter()
1549 .any(|t| t.contains("TestCase.php")),
1550 "TestCase.php should not be mapped as a test file for User.php"
1551 );
1552 }
1553 }
1554
1555 #[test]
1559 fn php_fw_01_laravel_framework_self_test() {
1560 let dir = tempfile::tempdir().expect("failed to create tempdir");
1565 let src_dir = dir.path().join("src").join("Illuminate").join("Http");
1566 std::fs::create_dir_all(&src_dir).unwrap();
1567 let test_dir = dir.path().join("tests").join("Http");
1568 std::fs::create_dir_all(&test_dir).unwrap();
1569
1570 let prod_file = src_dir.join("Request.php");
1571 std::fs::write(
1572 &prod_file,
1573 "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1574 )
1575 .unwrap();
1576
1577 let test_file = test_dir.join("RequestTest.php");
1578 let test_source =
1579 "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1580 std::fs::write(&test_file, test_source).unwrap();
1581
1582 let ext = PhpExtractor::new();
1583 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1584 let mut test_sources = HashMap::new();
1585 test_sources.insert(
1586 test_file.to_string_lossy().into_owned(),
1587 test_source.to_string(),
1588 );
1589
1590 let mappings =
1591 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1592
1593 let request_mapping = mappings
1594 .iter()
1595 .find(|m| m.production_file.contains("Request.php"))
1596 .expect("expected Request.php in mappings");
1597 assert!(
1598 request_mapping
1599 .test_files
1600 .iter()
1601 .any(|t| t.contains("RequestTest.php")),
1602 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1603 request_mapping.test_files
1604 );
1605 }
1606
1607 #[test]
1611 fn php_fw_02_normal_app_illuminate_filtered() {
1612 let dir = tempfile::tempdir().expect("failed to create tempdir");
1618 let app_dir = dir.path().join("app").join("Models");
1619 std::fs::create_dir_all(&app_dir).unwrap();
1620 let test_dir = dir.path().join("tests");
1621 std::fs::create_dir_all(&test_dir).unwrap();
1622
1623 let prod_file = app_dir.join("User.php");
1624 std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1625
1626 let test_file = test_dir.join("OrderTest.php");
1628 let test_source =
1629 "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1630 std::fs::write(&test_file, test_source).unwrap();
1631
1632 let ext = PhpExtractor::new();
1633 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1634 let mut test_sources = HashMap::new();
1635 test_sources.insert(
1636 test_file.to_string_lossy().into_owned(),
1637 test_source.to_string(),
1638 );
1639
1640 let mappings =
1641 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1642
1643 let user_mapping = mappings
1645 .iter()
1646 .find(|m| m.production_file.contains("User.php"))
1647 .expect("expected User.php in mappings");
1648 assert!(
1649 !user_mapping
1650 .test_files
1651 .iter()
1652 .any(|t| t.contains("OrderTest.php")),
1653 "Illuminate import should be filtered when no local source exists"
1654 );
1655 }
1656
1657 #[test]
1661 fn php_fw_03_phpunit_still_external() {
1662 let dir = tempfile::tempdir().expect("failed to create tempdir");
1667 let src_dir = dir.path().join("src");
1668 std::fs::create_dir_all(&src_dir).unwrap();
1669 let test_dir = dir.path().join("tests");
1670 std::fs::create_dir_all(&test_dir).unwrap();
1671
1672 let prod_file = src_dir.join("Calculator.php");
1673 std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1674
1675 let test_file = test_dir.join("OtherTest.php");
1677 let test_source =
1678 "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1679 std::fs::write(&test_file, test_source).unwrap();
1680
1681 let ext = PhpExtractor::new();
1682 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1683 let mut test_sources = HashMap::new();
1684 test_sources.insert(
1685 test_file.to_string_lossy().into_owned(),
1686 test_source.to_string(),
1687 );
1688
1689 let mappings =
1690 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1691
1692 let calc_mapping = mappings
1693 .iter()
1694 .find(|m| m.production_file.contains("Calculator.php"))
1695 .expect("expected Calculator.php in mappings");
1696 assert!(
1697 !calc_mapping
1698 .test_files
1699 .iter()
1700 .any(|t| t.contains("OtherTest.php")),
1701 "PHPUnit import should not create a mapping to Calculator.php"
1702 );
1703 }
1704
1705 #[test]
1709 fn php_fw_04_symfony_self_test() {
1710 let dir = tempfile::tempdir().expect("failed to create tempdir");
1716 let src_dir = dir
1717 .path()
1718 .join("src")
1719 .join("Symfony")
1720 .join("Component")
1721 .join("HttpFoundation");
1722 std::fs::create_dir_all(&src_dir).unwrap();
1723 let test_dir = dir.path().join("tests").join("HttpFoundation");
1724 std::fs::create_dir_all(&test_dir).unwrap();
1725
1726 let prod_file = src_dir.join("Request.php");
1727 std::fs::write(
1728 &prod_file,
1729 "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1730 )
1731 .unwrap();
1732
1733 let test_file = test_dir.join("RequestTest.php");
1734 let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1735 std::fs::write(&test_file, test_source).unwrap();
1736
1737 let ext = PhpExtractor::new();
1738 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1739 let mut test_sources = HashMap::new();
1740 test_sources.insert(
1741 test_file.to_string_lossy().into_owned(),
1742 test_source.to_string(),
1743 );
1744
1745 let mappings =
1746 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1747
1748 let request_mapping = mappings
1749 .iter()
1750 .find(|m| m.production_file.contains("Request.php"))
1751 .expect("expected Request.php in mappings");
1752 assert!(
1753 request_mapping
1754 .test_files
1755 .iter()
1756 .any(|t| t.contains("RequestTest.php")),
1757 "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1758 request_mapping.test_files
1759 );
1760 }
1761
1762 #[test]
1766 fn php_helper_06_fixtures_dir() {
1767 assert!(is_non_sut_helper("tests/Fixtures/SomeHelper.php", false));
1771 }
1772
1773 #[test]
1777 fn php_helper_07_fixtures_nested() {
1778 assert!(is_non_sut_helper("tests/Fixtures/nested/Stub.php", false));
1782 }
1783
1784 #[test]
1788 fn php_helper_08_stubs_dir() {
1789 assert!(is_non_sut_helper("tests/Stubs/UserStub.php", false));
1793 }
1794
1795 #[test]
1799 fn php_helper_09_stubs_nested() {
1800 assert!(is_non_sut_helper("tests/Stubs/nested/FakeRepo.php", false));
1804 }
1805
1806 #[test]
1810 fn php_helper_10_non_test_stubs() {
1811 assert!(!is_non_sut_helper("app/Stubs/Template.php", false));
1815 }
1816
1817 #[test]
1821 fn php_psr4_01_composer_json_resolution() {
1822 let dir = tempfile::tempdir().expect("failed to create tempdir");
1829 let custom_src_dir = dir.path().join("custom_src").join("Models");
1830 std::fs::create_dir_all(&custom_src_dir).unwrap();
1831 let test_dir = dir.path().join("tests");
1832 std::fs::create_dir_all(&test_dir).unwrap();
1833
1834 let composer_json = r#"{"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}"#;
1836 std::fs::write(dir.path().join("composer.json"), composer_json).unwrap();
1837
1838 let prod_file = custom_src_dir.join("Order.php");
1839 std::fs::write(
1840 &prod_file,
1841 "<?php\nnamespace MyApp\\Models;\nclass Order {}",
1842 )
1843 .unwrap();
1844
1845 let test_file = test_dir.join("OrderTest.php");
1846 let test_source = "<?php\nuse MyApp\\Models\\Order;\nclass OrderTest extends TestCase {}";
1847 std::fs::write(&test_file, test_source).unwrap();
1848
1849 let ext = PhpExtractor::new();
1850 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1851 let mut test_sources = HashMap::new();
1852 test_sources.insert(
1853 test_file.to_string_lossy().into_owned(),
1854 test_source.to_string(),
1855 );
1856
1857 let mappings =
1858 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1859
1860 let order_mapping = mappings
1861 .iter()
1862 .find(|m| m.production_file.contains("Order.php"))
1863 .expect("expected Order.php in mappings");
1864 assert!(
1865 order_mapping
1866 .test_files
1867 .iter()
1868 .any(|t| t.contains("OrderTest.php")),
1869 "expected OrderTest.php to be mapped to Order.php via PSR-4 composer.json resolution, got: {:?}",
1870 order_mapping.test_files
1871 );
1872 }
1873
1874 #[test]
1878 fn php_cli_01_dispatch() {
1879 let dir = tempfile::tempdir().expect("failed to create tempdir");
1883 let ext = PhpExtractor::new();
1884 let production_files: Vec<String> = vec![];
1885 let test_sources: HashMap<String, String> = HashMap::new();
1886 let mappings =
1887 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1888 assert!(mappings.is_empty());
1889 }
1890}
1891
1892#[cfg(test)]
1897mod parent_class_tests {
1898 use super::*;
1899 use std::collections::HashMap;
1900
1901 #[test]
1906 fn tc01_parent_imports_propagated_to_child() {
1907 let dir = tempfile::tempdir().expect("failed to create tempdir");
1909 let test_dir = dir.path().join("tests");
1910 std::fs::create_dir_all(&test_dir).unwrap();
1911
1912 let parent_source = r#"<?php
1913namespace App\Tests;
1914use Illuminate\View\Compilers\BladeCompiler;
1915use Illuminate\Container\Container;
1916use PHPUnit\Framework\TestCase;
1917abstract class AbstractBaseTest extends TestCase {}"#;
1918
1919 let parent_file = test_dir.join("AbstractBaseTest.php");
1920 std::fs::write(&parent_file, parent_source).unwrap();
1921
1922 let child_source = r#"<?php
1923namespace App\Tests;
1924class ChildTest extends AbstractBaseTest {
1925 public function testSomething() { $this->assertTrue(true); }
1926}"#;
1927
1928 let parent_imports = PhpExtractor::extract_parent_class_imports(
1930 child_source,
1931 &parent_file.parent().unwrap().to_string_lossy(),
1932 );
1933
1934 assert!(
1938 !parent_imports.is_empty(),
1939 "expected parent Illuminate imports to be propagated, got: {parent_imports:?}"
1940 );
1941 let has_blade = parent_imports
1942 .iter()
1943 .any(|(m, _)| m.contains("BladeCompiler") || m.contains("Compilers"));
1944 let has_container = parent_imports.iter().any(|(m, _)| m.contains("Container"));
1945 assert!(
1946 has_blade || has_container,
1947 "expected BladeCompiler or Container in parent imports, got: {parent_imports:?}"
1948 );
1949 }
1950
1951 #[test]
1956 fn tc02_parent_with_no_production_imports_adds_nothing() {
1957 let dir = tempfile::tempdir().expect("failed to create tempdir");
1959 let test_dir = dir.path().join("tests");
1960 std::fs::create_dir_all(&test_dir).unwrap();
1961 let app_dir = dir.path().join("app").join("Models");
1962 std::fs::create_dir_all(&app_dir).unwrap();
1963
1964 let parent_source = r#"<?php
1965namespace App\Tests;
1966use PHPUnit\Framework\TestCase;
1967abstract class MinimalBaseTest extends TestCase {}"#;
1968
1969 let parent_file = test_dir.join("MinimalBaseTest.php");
1970 std::fs::write(&parent_file, parent_source).unwrap();
1971
1972 let child_source = r#"<?php
1973namespace App\Tests;
1974use App\Models\Order;
1975class OrderTest extends MinimalBaseTest {
1976 public function testOrder() { $this->assertTrue(true); }
1977}"#;
1978
1979 let child_file = test_dir.join("OrderTest.php");
1980 std::fs::write(&child_file, child_source).unwrap();
1981
1982 let prod_file = app_dir.join("Order.php");
1983 std::fs::write(&prod_file, "<?php\nnamespace App\\Models;\nclass Order {}").unwrap();
1984
1985 let ext = PhpExtractor::new();
1986 let production_files = vec![prod_file.to_string_lossy().into_owned()];
1987 let mut test_sources = HashMap::new();
1988 test_sources.insert(
1989 child_file.to_string_lossy().into_owned(),
1990 child_source.to_string(),
1991 );
1992
1993 let mappings =
1995 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1996
1997 let order_mapping = mappings
2000 .iter()
2001 .find(|m| m.production_file.contains("Order.php"))
2002 .expect("expected Order.php in mappings");
2003 assert!(
2004 !order_mapping.test_files.is_empty(),
2005 "expected OrderTest.php to be mapped to Order.php (child's own import), got empty"
2006 );
2007 }
2008
2009 #[test]
2014 fn tc03_external_parent_class_skipped() {
2015 let dir = tempfile::tempdir().expect("failed to create tempdir");
2018 let app_dir = dir.path().join("app").join("Services");
2019 std::fs::create_dir_all(&app_dir).unwrap();
2020 let test_dir = dir.path().join("tests");
2021 std::fs::create_dir_all(&test_dir).unwrap();
2022
2023 let prod_file = app_dir.join("PaymentService.php");
2024 std::fs::write(
2025 &prod_file,
2026 "<?php\nnamespace App\\Services;\nclass PaymentService {}",
2027 )
2028 .unwrap();
2029
2030 let test_source = r#"<?php
2032use PHPUnit\Framework\TestCase;
2033use App\Services\PaymentService;
2034class PaymentServiceTest extends TestCase {
2035 public function testPay() { $this->assertTrue(true); }
2036}"#;
2037 let test_file = test_dir.join("PaymentServiceTest.php");
2038 std::fs::write(&test_file, test_source).unwrap();
2039
2040 let ext = PhpExtractor::new();
2041 let production_files = vec![prod_file.to_string_lossy().into_owned()];
2042 let mut test_sources = HashMap::new();
2043 test_sources.insert(
2044 test_file.to_string_lossy().into_owned(),
2045 test_source.to_string(),
2046 );
2047
2048 let mappings =
2050 ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
2051
2052 let payment_mapping = mappings
2055 .iter()
2056 .find(|m| m.production_file.contains("PaymentService.php"))
2057 .expect("expected PaymentService.php in mappings");
2058 assert!(
2059 payment_mapping
2060 .test_files
2061 .iter()
2062 .any(|t| t.contains("PaymentServiceTest.php")),
2063 "expected PaymentServiceTest.php mapped via own import; got: {:?}",
2064 payment_mapping.test_files
2065 );
2066 }
2068
2069 #[test]
2074 fn tc04_circular_inheritance_no_infinite_loop() {
2075 let dir = tempfile::tempdir().expect("failed to create tempdir");
2077 let test_dir = dir.path().join("tests");
2078 std::fs::create_dir_all(&test_dir).unwrap();
2079
2080 let a_source = r#"<?php
2081namespace App\Tests;
2082use App\Models\Foo;
2083class ATest extends BTest {}"#;
2084
2085 let b_source = r#"<?php
2086namespace App\Tests;
2087use App\Models\Bar;
2088class BTest extends ATest {}"#;
2089
2090 let a_file = test_dir.join("ATest.php");
2091 let b_file = test_dir.join("BTest.php");
2092 std::fs::write(&a_file, a_source).unwrap();
2093 std::fs::write(&b_file, b_source).unwrap();
2094
2095 let result =
2098 PhpExtractor::extract_parent_class_imports(a_source, &test_dir.to_string_lossy());
2099
2100 let _ = result;
2103 }
2104
2105 #[test]
2109 #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
2110 fn tc05_laravel_recall_above_90_percent() {
2111 unimplemented!(
2118 "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
2119 );
2120 }
2121
2122 #[test]
2126 #[ignore = "integration: requires local Laravel ground truth at /tmp/laravel"]
2127 fn tc06_laravel_no_new_false_positives() {
2128 unimplemented!(
2133 "Integration test: run `cargo run -- observe --lang php --format json /tmp/laravel`"
2134 );
2135 }
2136
2137 #[test]
2141 fn tc01_controller_array_syntax() {
2142 let source = r#"<?php
2144use Illuminate\Support\Facades\Route;
2145Route::get('/users', [UserController::class, 'index']);
2146"#;
2147 let extractor = PhpExtractor;
2148
2149 let routes = extractor.extract_routes(source, "routes/web.php");
2151
2152 assert_eq!(routes.len(), 1);
2154 let r = &routes[0];
2155 assert_eq!(r.http_method, "GET");
2156 assert_eq!(r.path, "/users");
2157 assert_eq!(r.handler_name, "index");
2158 assert_eq!(r.class_name, "UserController");
2159 assert_eq!(r.file, "routes/web.php");
2160 }
2161
2162 #[test]
2166 fn tc02_closure_handler() {
2167 let source = r#"<?php
2169use Illuminate\Support\Facades\Route;
2170Route::post('/users', fn () => response()->json(['ok' => true]));
2171"#;
2172 let extractor = PhpExtractor;
2173
2174 let routes = extractor.extract_routes(source, "routes/web.php");
2176
2177 assert_eq!(routes.len(), 1);
2179 let r = &routes[0];
2180 assert_eq!(r.http_method, "POST");
2181 assert_eq!(r.path, "/users");
2182 assert_eq!(r.handler_name, "");
2183 assert_eq!(r.class_name, "");
2184 }
2185
2186 #[test]
2190 fn tc03_prefix_group_depth1() {
2191 let source = r#"<?php
2193use Illuminate\Support\Facades\Route;
2194Route::prefix('admin')->group(function () {
2195 Route::get('/users', [UserController::class, 'index']);
2196});
2197"#;
2198 let extractor = PhpExtractor;
2199
2200 let routes = extractor.extract_routes(source, "routes/web.php");
2202
2203 assert_eq!(routes.len(), 1);
2205 let r = &routes[0];
2206 assert_eq!(r.http_method, "GET");
2207 assert_eq!(r.path, "admin/users");
2208 }
2209
2210 #[test]
2214 fn tc04_nested_prefix_group_depth2() {
2215 let source = r#"<?php
2217use Illuminate\Support\Facades\Route;
2218Route::prefix('api')->group(fn() =>
2219 Route::prefix('v1')->group(fn() =>
2220 Route::get('/users', [UserController::class, 'index'])
2221 )
2222);
2223"#;
2224 let extractor = PhpExtractor;
2225
2226 let routes = extractor.extract_routes(source, "routes/web.php");
2228
2229 assert_eq!(routes.len(), 1);
2231 let r = &routes[0];
2232 assert_eq!(r.http_method, "GET");
2233 assert_eq!(r.path, "api/v1/users");
2234 }
2235
2236 #[test]
2240 fn tc05_middleware_group_no_prefix_effect() {
2241 let source = r#"<?php
2243use Illuminate\Support\Facades\Route;
2244Route::middleware('auth')->group(function () {
2245 Route::get('/dashboard', [DashboardController::class, 'index']);
2246});
2247"#;
2248 let extractor = PhpExtractor;
2249
2250 let routes = extractor.extract_routes(source, "routes/web.php");
2252
2253 assert_eq!(routes.len(), 1);
2255 let r = &routes[0];
2256 assert_eq!(r.http_method, "GET");
2257 assert_eq!(r.path, "/dashboard");
2258 }
2259}