1use crate::ast::{Node, NodeKind};
2use serde_json::{Value, json};
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6#[derive(Debug, Clone)]
8pub struct TestItem {
9 pub id: String,
11 pub label: String,
13 pub uri: String,
15 pub range: TestRange,
17 pub kind: TestKind,
19 pub children: Vec<TestItem>,
21}
22
23#[derive(Debug, Clone)]
25pub struct TestRange {
26 pub start_line: u32,
28 pub start_character: u32,
30 pub end_line: u32,
32 pub end_character: u32,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub enum TestKind {
39 File,
41 Suite,
43 Test,
45}
46
47#[derive(Debug, Clone)]
49pub struct TestResult {
50 pub test_id: String,
52 pub status: TestStatus,
54 pub message: Option<String>,
56 pub duration: Option<u64>,
58}
59
60#[derive(Debug, Clone, PartialEq)]
62pub enum TestStatus {
63 Passed,
65 Failed,
67 Skipped,
69 Errored,
71}
72
73impl TestStatus {
74 pub fn as_str(&self) -> &'static str {
76 match self {
77 TestStatus::Passed => "passed",
78 TestStatus::Failed => "failed",
79 TestStatus::Skipped => "skipped",
80 TestStatus::Errored => "errored",
81 }
82 }
83}
84
85pub struct TestRunner {
87 source: String,
89 uri: String,
91}
92
93impl TestRunner {
94 pub fn new(source: String, uri: String) -> Self {
96 Self { source, uri }
97 }
98
99 pub fn discover_tests(&self, ast: &Node) -> Vec<TestItem> {
101 let mut tests = Vec::new();
102
103 let mut test_functions = Vec::new();
105 self.find_test_functions_only(ast, &mut test_functions);
106
107 if self.is_test_file(&self.uri) {
109 let file_item = TestItem {
111 id: self.uri.clone(),
112 label: Path::new(&self.uri)
113 .file_name()
114 .and_then(|s| s.to_str())
115 .unwrap_or("test")
116 .to_string(),
117 uri: self.uri.clone(),
118 range: self.get_file_range(),
119 kind: TestKind::File,
120 children: test_functions,
121 };
122
123 tests.push(file_item);
124 } else {
125 tests.extend(test_functions);
127 }
128
129 tests
130 }
131
132 fn is_test_file(&self, uri: &str) -> bool {
134 let path = Path::new(uri);
135 let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
136
137 file_name.ends_with(".t")
139 || file_name.ends_with("_test.pl")
140 || file_name.ends_with("Test.pl")
141 || file_name.starts_with("test_")
142 || path.components().any(|c| c.as_os_str() == "t" || c.as_os_str() == "tests")
143 }
144
145 #[allow(dead_code)]
147 fn find_test_functions(&self, node: &Node) -> Vec<TestItem> {
148 let mut tests = Vec::new();
149 self.visit_node_for_tests(node, &mut tests);
150 tests
151 }
152
153 fn find_test_functions_only(&self, node: &Node, tests: &mut Vec<TestItem>) {
155 match &node.kind {
156 NodeKind::Program { statements } => {
157 for stmt in statements {
158 self.find_test_functions_only(stmt, tests);
159 }
160 }
161
162 NodeKind::Block { statements } => {
163 for stmt in statements {
164 self.find_test_functions_only(stmt, tests);
165 }
166 }
167
168 NodeKind::Subroutine { name, .. } => {
169 if let Some(func_name) = name {
170 if self.is_test_function(func_name) {
171 let test_item = TestItem {
172 id: format!("{}::{}", self.uri, func_name),
173 label: func_name.clone(),
174 uri: self.uri.clone(),
175 range: self.node_to_range(node),
176 kind: TestKind::Test,
177 children: vec![],
178 };
179 tests.push(test_item);
180 }
181 }
182 }
183
184 _ => {
185 self.visit_children_for_test_functions(node, tests);
187 }
188 }
189 }
190
191 fn visit_children_for_test_functions(&self, node: &Node, tests: &mut Vec<TestItem>) {
193 match &node.kind {
194 NodeKind::If { then_branch, elsif_branches, else_branch, .. } => {
195 self.find_test_functions_only(then_branch, tests);
196 for (_, body) in elsif_branches {
197 self.find_test_functions_only(body, tests);
198 }
199 if let Some(else_b) = else_branch {
200 self.find_test_functions_only(else_b, tests);
201 }
202 }
203 NodeKind::While { body, .. } => {
204 self.find_test_functions_only(body, tests);
205 }
206 NodeKind::For { body, .. } => {
207 self.find_test_functions_only(body, tests);
208 }
209 NodeKind::Foreach { body, .. } => {
210 self.find_test_functions_only(body, tests);
211 }
212 _ => {}
213 }
214 }
215
216 #[allow(dead_code)]
218 fn visit_node_for_tests(&self, node: &Node, tests: &mut Vec<TestItem>) {
219 match &node.kind {
220 NodeKind::Program { statements } => {
221 for stmt in statements {
222 self.visit_node_for_tests(stmt, tests);
223 }
224 }
225
226 NodeKind::Block { statements } => {
227 for stmt in statements {
228 self.visit_node_for_tests(stmt, tests);
229 }
230 }
231
232 NodeKind::Subroutine { name, body, .. } => {
233 if let Some(func_name) = name {
234 if self.is_test_function(func_name) {
235 let test_item = TestItem {
236 id: format!("{}::{}", self.uri, func_name),
237 label: func_name.clone(),
238 uri: self.uri.clone(),
239 range: self.node_to_range(node),
240 kind: TestKind::Test,
241 children: vec![],
242 };
243 tests.push(test_item);
244 }
245 }
246
247 self.visit_node_for_tests(body, tests);
249 }
250
251 NodeKind::FunctionCall { name, args } => {
253 if self.is_test_assertion(name) {
254 let description = self.extract_test_description(args);
256 let label = description.unwrap_or_else(|| name.clone());
257
258 let test_item = TestItem {
259 id: format!("{}::{}::{}", self.uri, name, node.location.start),
260 label,
261 uri: self.uri.clone(),
262 range: self.node_to_range(node),
263 kind: TestKind::Test,
264 children: vec![],
265 };
266 tests.push(test_item);
267 }
268
269 for arg in args {
271 self.visit_node_for_tests(arg, tests);
272 }
273 }
274
275 _ => {
276 self.visit_children_for_tests(node, tests);
278 }
279 }
280 }
281
282 fn is_test_function(&self, name: &str) -> bool {
284 name.starts_with("test_")
285 || name.ends_with("_test")
286 || name.starts_with("Test")
287 || name.ends_with("Test")
288 || name == "test"
289 }
290
291 #[allow(dead_code)]
293 fn is_test_assertion(&self, name: &str) -> bool {
294 matches!(
296 name,
297 "ok" | "is"
298 | "isnt"
299 | "like"
300 | "unlike"
301 | "is_deeply"
302 | "cmp_ok"
303 | "can_ok"
304 | "isa_ok"
305 | "pass"
306 | "fail"
307 | "dies_ok"
308 | "lives_ok"
309 | "throws_ok"
310 | "lives_and"
311 )
312 }
313
314 #[allow(dead_code)]
316 fn extract_test_description(&self, args: &[Node]) -> Option<String> {
317 args.last().and_then(|arg| match &arg.kind {
319 NodeKind::String { value, .. } => Some(value.clone()),
320 _ => None,
321 })
322 }
323
324 #[allow(dead_code)]
326 fn visit_children_for_tests(&self, node: &Node, tests: &mut Vec<TestItem>) {
327 match &node.kind {
328 NodeKind::If { condition, then_branch, elsif_branches, else_branch, .. } => {
329 self.visit_node_for_tests(condition, tests);
330 self.visit_node_for_tests(then_branch, tests);
331 for (cond, body) in elsif_branches {
332 self.visit_node_for_tests(cond, tests);
333 self.visit_node_for_tests(body, tests);
334 }
335 if let Some(else_b) = else_branch {
336 self.visit_node_for_tests(else_b, tests);
337 }
338 }
339 NodeKind::While { condition, body, .. } => {
340 self.visit_node_for_tests(condition, tests);
341 self.visit_node_for_tests(body, tests);
342 }
343 NodeKind::For { init, condition, update, body, .. } => {
344 if let Some(i) = init {
345 self.visit_node_for_tests(i, tests);
346 }
347 if let Some(c) = condition {
348 self.visit_node_for_tests(c, tests);
349 }
350 if let Some(u) = update {
351 self.visit_node_for_tests(u, tests);
352 }
353 self.visit_node_for_tests(body, tests);
354 }
355 NodeKind::Foreach { variable, list, body, continue_block } => {
356 self.visit_node_for_tests(variable, tests);
357 self.visit_node_for_tests(list, tests);
358 self.visit_node_for_tests(body, tests);
359 if let Some(cb) = continue_block {
360 self.visit_node_for_tests(cb, tests);
361 }
362 }
363 _ => {}
364 }
365 }
366
367 fn node_to_range(&self, node: &Node) -> TestRange {
369 let (start_line, start_char) = self.offset_to_position(node.location.start);
370 let (end_line, end_char) = self.offset_to_position(node.location.end);
371
372 TestRange { start_line, start_character: start_char, end_line, end_character: end_char }
373 }
374
375 fn get_file_range(&self) -> TestRange {
377 let lines: Vec<&str> = self.source.lines().collect();
378 let last_line = lines.len().saturating_sub(1) as u32;
379 let last_char = lines.last().map(|l| l.len() as u32).unwrap_or(0);
380
381 TestRange {
382 start_line: 0,
383 start_character: 0,
384 end_line: last_line,
385 end_character: last_char,
386 }
387 }
388
389 fn offset_to_position(&self, offset: usize) -> (u32, u32) {
391 let mut line = 0;
392 let mut col = 0;
393
394 for (i, ch) in self.source.chars().enumerate() {
395 if i >= offset {
396 break;
397 }
398 if ch == '\n' {
399 line += 1;
400 col = 0;
401 } else {
402 col += 1;
403 }
404 }
405
406 (line, col)
407 }
408
409 pub fn run_test(&self, test_id: &str) -> Vec<TestResult> {
411 let mut results = Vec::new();
412
413 let file_path = test_id.split("::").next().unwrap_or(test_id);
415 let file_path = file_path.strip_prefix("file://").unwrap_or(file_path);
416
417 if file_path.ends_with(".t") {
419 results.extend(self.run_test_file(file_path));
421 } else {
422 results.extend(self.run_perl_test(file_path));
424 }
425
426 results
427 }
428
429 fn hermetic_perl_command(&self, perl_binary: &str) -> Command {
442 let mut cmd = Command::new(perl_binary);
443 cmd.env_clear();
444 if let Some(path_val) = std::env::var_os("PATH") {
445 cmd.env("PATH", path_val);
446 }
447 #[cfg(windows)]
448 if let Some(systemroot) = std::env::var_os("SYSTEMROOT") {
449 cmd.env("SYSTEMROOT", systemroot);
450 }
451 cmd
452 }
453
454 fn run_test_file(&self, file_path: &str) -> Vec<TestResult> {
456 let start_time = std::time::Instant::now();
457
458 let safe_prove_path = if file_path.starts_with('-') {
463 format!("./{}", file_path)
464 } else {
465 file_path.to_string()
466 };
467
468 let output = Command::new("prove")
472 .arg("-v")
473 .arg(&safe_prove_path)
474 .stdout(Stdio::piped())
475 .stderr(Stdio::piped())
476 .output();
477
478 let output = match output {
479 Ok(out) => out,
480 Err(_) => {
481 match self
486 .hermetic_perl_command("perl")
487 .arg("--")
488 .arg(file_path)
489 .stdout(Stdio::piped())
490 .stderr(Stdio::piped())
491 .output()
492 {
493 Ok(out) => out,
494 Err(e) => {
495 return vec![TestResult {
496 test_id: file_path.to_string(),
497 status: TestStatus::Errored,
498 message: Some(format!("Failed to run test: {}", e)),
499 duration: Some(start_time.elapsed().as_millis() as u64),
500 }];
501 }
502 }
503 }
504 };
505
506 let duration = start_time.elapsed().as_millis() as u64;
507
508 self.parse_tap_output(
510 &String::from_utf8_lossy(&output.stdout),
511 &String::from_utf8_lossy(&output.stderr),
512 output.status.success(),
513 duration,
514 file_path,
515 )
516 }
517
518 fn run_perl_test(&self, file_path: &str) -> Vec<TestResult> {
520 let start_time = std::time::Instant::now();
521
522 let output = match self
526 .hermetic_perl_command("perl")
527 .arg("-Ilib")
528 .arg("--")
529 .arg(file_path)
530 .stdout(Stdio::piped())
531 .stderr(Stdio::piped())
532 .output()
533 {
534 Ok(out) => out,
535 Err(e) => {
536 return vec![TestResult {
537 test_id: file_path.to_string(),
538 status: TestStatus::Errored,
539 message: Some(format!("Failed to run test: {}", e)),
540 duration: Some(start_time.elapsed().as_millis() as u64),
541 }];
542 }
543 };
544
545 let duration = start_time.elapsed().as_millis() as u64;
546 let stdout = String::from_utf8_lossy(&output.stdout);
547 let stderr = String::from_utf8_lossy(&output.stderr);
548
549 vec![TestResult {
550 test_id: file_path.to_string(),
551 status: if output.status.success() { TestStatus::Passed } else { TestStatus::Failed },
552 message: if !stderr.is_empty() {
553 Some(stderr.to_string())
554 } else if !stdout.is_empty() {
555 Some(stdout.to_string())
556 } else {
557 None
558 },
559 duration: Some(duration),
560 }]
561 }
562
563 fn parse_tap_output(
565 &self,
566 stdout: &str,
567 stderr: &str,
568 success: bool,
569 duration: u64,
570 test_id: &str,
571 ) -> Vec<TestResult> {
572 let mut results = Vec::new();
573 let mut _test_count = 0;
574
575 for line in stdout.lines() {
577 if line.starts_with("ok ") {
578 _test_count += 1;
579 let test_name = line.splitn(3, ' ').nth(2).unwrap_or("test");
580 results.push(TestResult {
581 test_id: format!("{}::{}", test_id, test_name),
582 status: TestStatus::Passed,
583 message: None,
584 duration: None,
585 });
586 } else if line.starts_with("not ok ") {
587 _test_count += 1;
588 let test_name = line.splitn(3, ' ').nth(2).unwrap_or("test");
589 results.push(TestResult {
590 test_id: format!("{}::{}", test_id, test_name),
591 status: TestStatus::Failed,
592 message: Some(line.to_string()),
593 duration: None,
594 });
595 }
596 }
597
598 if results.is_empty() {
600 results.push(TestResult {
601 test_id: test_id.to_string(),
602 status: if success { TestStatus::Passed } else { TestStatus::Failed },
603 message: if !stderr.is_empty() { Some(stderr.to_string()) } else { None },
604 duration: Some(duration),
605 });
606 }
607
608 results
609 }
610}
611
612impl TestItem {
614 pub fn to_json(&self) -> Value {
616 json!({
617 "id": self.id,
618 "label": self.label,
619 "uri": self.uri,
620 "range": {
621 "start": {
622 "line": self.range.start_line,
623 "character": self.range.start_character
624 },
625 "end": {
626 "line": self.range.end_line,
627 "character": self.range.end_character
628 }
629 },
630 "canResolveChildren": !self.children.is_empty(),
631 "children": self.children.iter().map(|c| c.to_json()).collect::<Vec<_>>()
632 })
633 }
634}
635
636impl TestResult {
638 pub fn to_json(&self) -> Value {
640 let mut result = json!({
641 "testId": self.test_id,
642 "state": match self.status {
643 TestStatus::Passed => "passed",
644 TestStatus::Failed => "failed",
645 TestStatus::Skipped => "skipped",
646 TestStatus::Errored => "errored",
647 }
648 });
649
650 if let Some(message) = &self.message {
651 result["message"] = json!({
652 "message": message
653 });
654 }
655
656 if let Some(duration) = self.duration {
657 result["duration"] = json!(duration);
658 }
659
660 result
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::SourceLocation;
668 use crate::parser::Parser;
669 use std::sync::{LazyLock, Mutex};
670
671 static ENV_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
672
673 #[test]
674 fn test_discover_test_functions() {
675 let code = r#"
676sub test_basic {
677 ok(1, "Basic test");
678}
679
680sub helper_function {
681 # Not a test
682}
683
684sub test_another_thing {
685 is($result, 42, "The answer");
686}
687"#;
688
689 let mut parser = Parser::new(code);
690 if let Ok(ast) = parser.parse() {
691 let runner = TestRunner::new(code.to_string(), "file:///test.pl".to_string());
692 let tests = runner.discover_tests(&ast);
693
694 eprintln!("Found {} tests", tests.len());
696 for test in &tests {
697 eprintln!("Test: {} (kind: {:?})", test.label, test.kind);
698 for child in &test.children {
699 eprintln!(" Child: {}", child.label);
700 }
701 }
702
703 assert!(!tests.is_empty());
705
706 let test_functions: Vec<&str> = tests
708 .iter()
709 .filter(|t| t.kind == TestKind::Test && t.label.starts_with("test_"))
710 .map(|t| t.label.as_str())
711 .collect();
712
713 eprintln!("Test functions: {:?}", test_functions);
714 assert!(test_functions.contains(&"test_basic"));
715 assert!(test_functions.contains(&"test_another_thing"));
716 }
717 }
718
719 #[test]
720 fn test_discover_test_assertions() {
721 let code = r#"
722use Test::More;
723
724ok(1, "First test");
725is($x, 5, "X should be 5");
726like($string, qr/pattern/, "String matches");
727
728done_testing();
729"#;
730
731 let mut parser = Parser::new(code);
732 if let Ok(ast) = parser.parse() {
733 let runner = TestRunner::new(code.to_string(), "file:///test.t".to_string());
734 let tests = runner.discover_tests(&ast);
735
736 assert!(!tests.is_empty());
738
739 let all_tests: Vec<&TestItem> = tests
741 .iter()
742 .flat_map(|t| {
743 let mut items = vec![t];
744 items.extend(&t.children);
745 items
746 })
747 .collect();
748
749 eprintln!("All tests found:");
751 for test in &all_tests {
752 eprintln!(" Test: {} (kind: {:?})", test.label, test.kind);
753 }
754
755 assert!(!tests.is_empty());
757 assert_eq!(tests[0].kind, TestKind::File);
758 }
759 }
760
761 #[test]
762 fn test_is_test_file() {
763 let runner = TestRunner::new("".to_string(), "".to_string());
764
765 assert!(runner.is_test_file("file:///t/basic.t"));
766 assert!(runner.is_test_file("file:///tests/foo_test.pl"));
767 assert!(runner.is_test_file("file:///MyTest.pl"));
768 assert!(runner.is_test_file("file:///test_something.pl"));
769
770 assert!(!runner.is_test_file("file:///lib/Module.pm"));
771 assert!(!runner.is_test_file("file:///script.pl"));
772 }
773
774 #[test]
775 fn test_status_strings_cover_all_variants() -> Result<(), Box<dyn std::error::Error>> {
776 assert_eq!(TestStatus::Passed.as_str(), "passed");
777 assert_eq!(TestStatus::Failed.as_str(), "failed");
778 assert_eq!(TestStatus::Skipped.as_str(), "skipped");
779 assert_eq!(TestStatus::Errored.as_str(), "errored");
780 Ok(())
781 }
782
783 #[test]
784 fn test_item_json_includes_ranges_and_children() -> Result<(), Box<dyn std::error::Error>> {
785 let child = TestItem {
786 id: "file:///suite.t::child".to_string(),
787 label: "child".to_string(),
788 uri: "file:///suite.t".to_string(),
789 range: TestRange { start_line: 3, start_character: 4, end_line: 3, end_character: 16 },
790 kind: TestKind::Test,
791 children: vec![],
792 };
793 let item = TestItem {
794 id: "file:///suite.t".to_string(),
795 label: "suite.t".to_string(),
796 uri: "file:///suite.t".to_string(),
797 range: TestRange { start_line: 0, start_character: 0, end_line: 5, end_character: 1 },
798 kind: TestKind::File,
799 children: vec![child],
800 };
801
802 let json = item.to_json();
803
804 assert_eq!(json["id"], "file:///suite.t");
805 assert_eq!(json["label"], "suite.t");
806 assert_eq!(json["range"]["end"]["line"], 5);
807 assert_eq!(json["canResolveChildren"], true);
808 assert_eq!(json["children"][0]["id"], "file:///suite.t::child");
809 assert_eq!(json["children"][0]["range"]["start"]["character"], 4);
810 Ok(())
811 }
812
813 #[test]
814 fn test_result_json_covers_message_and_duration() -> Result<(), Box<dyn std::error::Error>> {
815 let result = TestResult {
816 test_id: "file:///suite.t::case".to_string(),
817 status: TestStatus::Errored,
818 message: Some("boom".to_string()),
819 duration: Some(42),
820 };
821
822 let json = result.to_json();
823
824 assert_eq!(json["testId"], "file:///suite.t::case");
825 assert_eq!(json["state"], "errored");
826 assert_eq!(json["message"]["message"], "boom");
827 assert_eq!(json["duration"], 42);
828 Ok(())
829 }
830
831 #[test]
832 fn tap_parser_reports_individual_passes_and_failures() -> Result<(), Box<dyn std::error::Error>>
833 {
834 let runner = TestRunner::new(String::new(), String::new());
835
836 let results = runner.parse_tap_output(
837 "1..3
838ok 1 - loads
839not ok 2 - rejects invalid input
840ok 3
841",
842 "ignored when individual TAP records exist",
843 false,
844 99,
845 "t/sample.t",
846 );
847
848 assert_eq!(results.len(), 3);
849 assert_eq!(results[0].test_id, "t/sample.t::- loads");
850 assert_eq!(results[0].status, TestStatus::Passed);
851 assert_eq!(results[1].test_id, "t/sample.t::2 - rejects invalid input");
852 assert_eq!(results[1].status, TestStatus::Failed);
853 assert_eq!(results[1].message.as_deref(), Some("not ok 2 - rejects invalid input"));
854 assert_eq!(results[2].test_id, "t/sample.t::test");
855 assert_eq!(results[2].duration, None);
856 Ok(())
857 }
858
859 #[test]
860 fn tap_parser_falls_back_to_file_result_with_stderr() -> Result<(), Box<dyn std::error::Error>>
861 {
862 let runner = TestRunner::new(String::new(), String::new());
863
864 let results = runner.parse_tap_output("", "syntax error", false, 17, "script.pl");
865
866 assert_eq!(results.len(), 1);
867 assert_eq!(results[0].test_id, "script.pl");
868 assert_eq!(results[0].status, TestStatus::Failed);
869 assert_eq!(results[0].message.as_deref(), Some("syntax error"));
870 assert_eq!(results[0].duration, Some(17));
871 Ok(())
872 }
873
874 #[test]
875 fn discover_tests_nests_file_children_and_computes_ranges()
876 -> Result<(), Box<dyn std::error::Error>> {
877 let source = "use Test::More;
878sub test_nested {
879 ok(1);
880}
881";
882 let body = node(NodeKind::Block { statements: vec![] }, 36, 46);
883 let subroutine = node(
884 NodeKind::Subroutine {
885 name: Some("test_nested".to_string()),
886 name_span: Some(SourceLocation { start: 20, end: 31 }),
887 prototype: None,
888 signature: None,
889 attributes: vec![],
890 body: Box::new(body),
891 },
892 16,
893 46,
894 );
895 let ast = node(NodeKind::Program { statements: vec![subroutine] }, 0, source.len());
896 let runner = TestRunner::new(source.to_string(), "file:///project/t/sample.t".to_string());
897
898 let tests = runner.discover_tests(&ast);
899
900 assert_eq!(tests.len(), 1);
901 assert_eq!(tests[0].kind, TestKind::File);
902 assert_eq!(tests[0].label, "sample.t");
903 assert_eq!(tests[0].range.end_line, 3);
904 assert_eq!(tests[0].range.end_character, 1);
905 assert_eq!(tests[0].children.len(), 1);
906 assert_eq!(tests[0].children[0].label, "test_nested");
907 assert_eq!(tests[0].children[0].range.start_line, 1);
908 assert_eq!(tests[0].children[0].range.start_character, 0);
909 assert_eq!(tests[0].children[0].range.end_line, 3);
910 assert_eq!(tests[0].children[0].range.end_character, 1);
911 Ok(())
912 }
913
914 #[test]
915 fn visit_children_for_tests_walks_if_with_keyword_metadata()
916 -> Result<(), Box<dyn std::error::Error>> {
917 let runner = TestRunner::new("is($got, $want);".to_string(), "file:///suite.t".to_string());
918 let string = |value: &str, start| {
919 node(
920 NodeKind::String { value: value.to_string(), interpolated: false },
921 start,
922 start + value.len() + 2,
923 )
924 };
925 let call = |name: &str, start| {
926 node(
927 NodeKind::FunctionCall {
928 name: name.to_string(),
929 args: vec![string("case", start + name.len() + 1)],
930 },
931 start,
932 start + name.len() + 8,
933 )
934 };
935 let node = node(
936 NodeKind::If {
937 condition: Box::new(node(NodeKind::Number { value: "1".to_string() }, 0, 1)),
938 then_branch: Box::new(call("is", 2)),
939 elsif_branches: vec![],
940 else_branch: Some(Box::new(call("ok", 12))),
941 keyword: Some("unless".to_string()),
942 },
943 0,
944 20,
945 );
946 let mut tests = Vec::new();
947
948 runner.visit_children_for_tests(&node, &mut tests);
949
950 assert_eq!(tests.len(), 2);
951 Ok(())
952 }
953
954 #[test]
955 fn assertion_discovery_uses_string_description_or_call_name()
956 -> Result<(), Box<dyn std::error::Error>> {
957 let source = "ok($value, 'truthy');
958pass();
959";
960 let described = node(
961 NodeKind::FunctionCall {
962 name: "ok".to_string(),
963 args: vec![
964 node(
965 NodeKind::Variable { sigil: "$".to_string(), name: "value".to_string() },
966 3,
967 9,
968 ),
969 node(
970 NodeKind::String { value: "truthy".to_string(), interpolated: false },
971 11,
972 19,
973 ),
974 ],
975 },
976 0,
977 20,
978 );
979 let unnamed =
980 node(NodeKind::FunctionCall { name: "pass".to_string(), args: vec![] }, 21, 27);
981 let ast = node(NodeKind::Program { statements: vec![described, unnamed] }, 0, source.len());
982 let runner =
983 TestRunner::new(source.to_string(), "file:///project/lib/Module.pm".to_string());
984
985 let tests = runner.find_test_functions(&ast);
986
987 assert_eq!(tests.len(), 2);
988 assert_eq!(tests[0].label, "truthy");
989 assert_eq!(tests[0].id, "file:///project/lib/Module.pm::ok::0");
990 assert_eq!(tests[1].label, "pass");
991 assert_eq!(tests[1].id, "file:///project/lib/Module.pm::pass::21");
992 Ok(())
993 }
994
995 fn node(kind: NodeKind, start: usize, end: usize) -> Node {
996 Node::new(kind, SourceLocation { start, end })
997 }
998
999 #[test]
1009 #[allow(unsafe_code)]
1011 fn hermetic_perl_command_strips_perl5lib() {
1012 let perl = which_perl();
1013 let Some(perl) = perl else { return };
1014 let Ok(_env_guard) = ENV_MUTEX.lock() else { return };
1015
1016 let runner = TestRunner::new("".to_string(), "".to_string());
1017
1018 let poison = "/hermetic-test-poison-perl5lib";
1020 unsafe { std::env::set_var("PERL5LIB", poison) };
1022 let mut cmd = runner.hermetic_perl_command(&perl);
1023 cmd.args(["-e", "print $ENV{PERL5LIB} // 'UNSET'"]);
1024 cmd.stdout(std::process::Stdio::piped());
1025 cmd.stderr(std::process::Stdio::piped());
1026 let out = cmd.output();
1027 unsafe { std::env::remove_var("PERL5LIB") };
1029
1030 let out = match out {
1031 Ok(o) => o,
1032 Err(_) => return, };
1034 let stdout = String::from_utf8_lossy(&out.stdout);
1035 assert_eq!(
1036 stdout.trim(),
1037 "UNSET",
1038 "PERL5LIB must be stripped by hermetic_perl_command; got: {stdout:?}",
1039 );
1040 }
1041
1042 #[test]
1045 #[allow(unsafe_code)]
1047 fn hermetic_perl_command_strips_perl5opt() {
1048 let perl = which_perl();
1049 let Some(perl) = perl else { return };
1050 let Ok(_env_guard) = ENV_MUTEX.lock() else { return };
1051
1052 let runner = TestRunner::new("".to_string(), "".to_string());
1053
1054 unsafe { std::env::set_var("PERL5OPT", "-Mstrict") };
1056 let mut cmd = runner.hermetic_perl_command(&perl);
1057 cmd.args(["-e", "print $ENV{PERL5OPT} // 'UNSET'"]);
1058 cmd.stdout(std::process::Stdio::piped());
1059 cmd.stderr(std::process::Stdio::piped());
1060 let out = cmd.output();
1061 unsafe { std::env::remove_var("PERL5OPT") };
1063
1064 let out = match out {
1065 Ok(o) => o,
1066 Err(_) => return,
1067 };
1068 let stdout = String::from_utf8_lossy(&out.stdout);
1069 assert_eq!(
1070 stdout.trim(),
1071 "UNSET",
1072 "PERL5OPT must be stripped by hermetic_perl_command; got: {stdout:?}",
1073 );
1074 }
1075
1076 #[test]
1079 fn hermetic_perl_command_preserves_path() {
1080 let runner = TestRunner::new("".to_string(), "".to_string());
1081 let _ = runner.hermetic_perl_command("perl");
1085 }
1086
1087 fn which_perl() -> Option<String> {
1090 let path_env = std::env::var_os("PATH")?;
1091 for dir in std::env::split_paths(&path_env) {
1092 let candidate = dir.join("perl");
1093 if candidate.is_file() {
1094 return Some(candidate.to_string_lossy().into_owned());
1095 }
1096 let candidate_exe = dir.join("perl.exe");
1098 if candidate_exe.is_file() {
1099 return Some(candidate_exe.to_string_lossy().into_owned());
1100 }
1101 }
1102 None
1103 }
1104}