Skip to main content

perl_tdd_support/tdd/
test_runner.rs

1use crate::ast::{Node, NodeKind};
2use serde_json::{Value, json};
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6/// Test item representing a test that can be run
7#[derive(Debug, Clone)]
8pub struct TestItem {
9    /// Unique identifier for the test (typically URI::function_name)
10    pub id: String,
11    /// Human-readable display name for the test
12    pub label: String,
13    /// File URI where the test is located
14    pub uri: String,
15    /// Source location range of the test
16    pub range: TestRange,
17    /// Classification of this test item
18    pub kind: TestKind,
19    /// Nested test items (e.g., functions within a file)
20    pub children: Vec<TestItem>,
21}
22
23/// Source location range for a test item
24#[derive(Debug, Clone)]
25pub struct TestRange {
26    /// Zero-based starting line number
27    pub start_line: u32,
28    /// Zero-based starting character offset within the line
29    pub start_character: u32,
30    /// Zero-based ending line number
31    pub end_line: u32,
32    /// Zero-based ending character offset within the line
33    pub end_character: u32,
34}
35
36/// Classification of test items
37#[derive(Debug, Clone, PartialEq)]
38pub enum TestKind {
39    /// A test file (e.g., .t file)
40    File,
41    /// A test suite containing multiple tests
42    Suite,
43    /// An individual test function or assertion
44    Test,
45}
46
47/// Test result after running a test
48#[derive(Debug, Clone)]
49pub struct TestResult {
50    /// Identifier of the test that was run
51    pub test_id: String,
52    /// Outcome status of the test execution
53    pub status: TestStatus,
54    /// Optional diagnostic message (e.g., error details)
55    pub message: Option<String>,
56    /// Execution time in milliseconds
57    pub duration: Option<u64>,
58}
59
60/// Outcome status of a test execution
61#[derive(Debug, Clone, PartialEq)]
62pub enum TestStatus {
63    /// Test passed successfully
64    Passed,
65    /// Test failed (assertion not met)
66    Failed,
67    /// Test was skipped (not executed)
68    Skipped,
69    /// Test encountered an error (could not run)
70    Errored,
71}
72
73impl TestStatus {
74    /// Convert to string for JSON serialization
75    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
85/// Test Runner for Perl tests
86pub struct TestRunner {
87    /// Source code content of the test file
88    source: String,
89    /// URI of the test file being analyzed
90    uri: String,
91}
92
93impl TestRunner {
94    /// Creates a new test runner for the given source code and file URI.
95    pub fn new(source: String, uri: String) -> Self {
96        Self { source, uri }
97    }
98
99    /// Discover tests in the AST
100    pub fn discover_tests(&self, ast: &Node) -> Vec<TestItem> {
101        let mut tests = Vec::new();
102
103        // Find test functions
104        let mut test_functions = Vec::new();
105        self.find_test_functions_only(ast, &mut test_functions);
106
107        // Check if this is a test file
108        if self.is_test_file(&self.uri) {
109            // Create a file-level test item
110            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            // Return individual test functions
126            tests.extend(test_functions);
127        }
128
129        tests
130    }
131
132    /// Check if a file is a test file
133    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        // Common Perl test file patterns
138        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    /// Find test functions in the AST
146    #[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    /// Find only test functions (not assertions)
154    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                // Visit children
186                self.visit_children_for_test_functions(node, tests);
187            }
188        }
189    }
190
191    /// Visit children nodes for test functions only
192    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    /// Visit nodes looking for test functions
217    #[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                // Still visit the body for nested tests
248                self.visit_node_for_tests(body, tests);
249            }
250
251            // Look for Test::More style tests
252            NodeKind::FunctionCall { name, args } => {
253                if self.is_test_assertion(name) {
254                    // Extract test description if available
255                    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                // Visit arguments
270                for arg in args {
271                    self.visit_node_for_tests(arg, tests);
272                }
273            }
274
275            _ => {
276                // Visit children
277                self.visit_children_for_tests(node, tests);
278            }
279        }
280    }
281
282    /// Check if a function name indicates a test
283    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    /// Check if a function call is a test assertion
292    #[allow(dead_code)]
293    fn is_test_assertion(&self, name: &str) -> bool {
294        // Test::More assertions
295        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    /// Extract test description from arguments
315    #[allow(dead_code)]
316    fn extract_test_description(&self, args: &[Node]) -> Option<String> {
317        // Usually the last argument is the description
318        args.last().and_then(|arg| match &arg.kind {
319            NodeKind::String { value, .. } => Some(value.clone()),
320            _ => None,
321        })
322    }
323
324    /// Visit children nodes for tests
325    #[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    /// Convert node to test range
368    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    /// Get range for entire file
376    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    /// Convert byte offset to line/character position
390    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    /// Run a test and return results
410    pub fn run_test(&self, test_id: &str) -> Vec<TestResult> {
411        let mut results = Vec::new();
412
413        // Extract file path from URI
414        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        // Determine how to run the test
418        if file_path.ends_with(".t") {
419            // Run as a test file
420            results.extend(self.run_test_file(file_path));
421        } else {
422            // Run as a Perl script with prove or perl
423            results.extend(self.run_perl_test(file_path));
424        }
425
426        results
427    }
428
429    /// Run a .t test file
430    fn run_test_file(&self, file_path: &str) -> Vec<TestResult> {
431        let start_time = std::time::Instant::now();
432
433        // SECURITY: For prove, filenames starting with '-' can be interpreted as flags.
434        // prove does not support '--' to separate files from options in all versions/modes.
435        // Prepend ./ to ensure it is treated as a file path if it starts with -.
436        // Absolute paths (starting with /) are safe.
437        let safe_prove_path = if file_path.starts_with('-') {
438            format!("./{}", file_path)
439        } else {
440            file_path.to_string()
441        };
442
443        // Try to run with prove first, fall back to perl
444        let output = Command::new("prove")
445            .arg("-v")
446            .arg(&safe_prove_path)
447            .stdout(Stdio::piped())
448            .stderr(Stdio::piped())
449            .output();
450
451        let output = match output {
452            Ok(out) => out,
453            Err(_) => {
454                // Fall back to running with perl
455                // SECURITY: Use -- to separate options from script file
456                match Command::new("perl")
457                    .arg("--")
458                    .arg(file_path)
459                    .stdout(Stdio::piped())
460                    .stderr(Stdio::piped())
461                    .output()
462                {
463                    Ok(out) => out,
464                    Err(e) => {
465                        return vec![TestResult {
466                            test_id: file_path.to_string(),
467                            status: TestStatus::Errored,
468                            message: Some(format!("Failed to run test: {}", e)),
469                            duration: Some(start_time.elapsed().as_millis() as u64),
470                        }];
471                    }
472                }
473            }
474        };
475
476        let duration = start_time.elapsed().as_millis() as u64;
477
478        // Parse TAP output
479        self.parse_tap_output(
480            &String::from_utf8_lossy(&output.stdout),
481            &String::from_utf8_lossy(&output.stderr),
482            output.status.success(),
483            duration,
484            file_path,
485        )
486    }
487
488    /// Run a Perl script as a test
489    fn run_perl_test(&self, file_path: &str) -> Vec<TestResult> {
490        let start_time = std::time::Instant::now();
491
492        // SECURITY: Use -- to separate options from script file
493        let output = match Command::new("perl")
494            .arg("-Ilib")
495            .arg("--")
496            .arg(file_path)
497            .stdout(Stdio::piped())
498            .stderr(Stdio::piped())
499            .output()
500        {
501            Ok(out) => out,
502            Err(e) => {
503                return vec![TestResult {
504                    test_id: file_path.to_string(),
505                    status: TestStatus::Errored,
506                    message: Some(format!("Failed to run test: {}", e)),
507                    duration: Some(start_time.elapsed().as_millis() as u64),
508                }];
509            }
510        };
511
512        let duration = start_time.elapsed().as_millis() as u64;
513        let stdout = String::from_utf8_lossy(&output.stdout);
514        let stderr = String::from_utf8_lossy(&output.stderr);
515
516        vec![TestResult {
517            test_id: file_path.to_string(),
518            status: if output.status.success() { TestStatus::Passed } else { TestStatus::Failed },
519            message: if !stderr.is_empty() {
520                Some(stderr.to_string())
521            } else if !stdout.is_empty() {
522                Some(stdout.to_string())
523            } else {
524                None
525            },
526            duration: Some(duration),
527        }]
528    }
529
530    /// Parse TAP (Test Anything Protocol) output
531    fn parse_tap_output(
532        &self,
533        stdout: &str,
534        stderr: &str,
535        success: bool,
536        duration: u64,
537        test_id: &str,
538    ) -> Vec<TestResult> {
539        let mut results = Vec::new();
540        let mut _test_count = 0;
541
542        // Parse TAP output line by line
543        for line in stdout.lines() {
544            if line.starts_with("ok ") {
545                _test_count += 1;
546                let test_name = line.splitn(3, ' ').nth(2).unwrap_or("test");
547                results.push(TestResult {
548                    test_id: format!("{}::{}", test_id, test_name),
549                    status: TestStatus::Passed,
550                    message: None,
551                    duration: None,
552                });
553            } else if line.starts_with("not ok ") {
554                _test_count += 1;
555                let test_name = line.splitn(3, ' ').nth(2).unwrap_or("test");
556                results.push(TestResult {
557                    test_id: format!("{}::{}", test_id, test_name),
558                    status: TestStatus::Failed,
559                    message: Some(line.to_string()),
560                    duration: None,
561                });
562            }
563        }
564
565        // If no individual test results, create one for the whole file
566        if results.is_empty() {
567            results.push(TestResult {
568                test_id: test_id.to_string(),
569                status: if success { TestStatus::Passed } else { TestStatus::Failed },
570                message: if !stderr.is_empty() { Some(stderr.to_string()) } else { None },
571                duration: Some(duration),
572            });
573        }
574
575        results
576    }
577}
578
579/// Convert TestItem to JSON for LSP
580impl TestItem {
581    /// Serializes this test item to a JSON value for LSP communication.
582    pub fn to_json(&self) -> Value {
583        json!({
584            "id": self.id,
585            "label": self.label,
586            "uri": self.uri,
587            "range": {
588                "start": {
589                    "line": self.range.start_line,
590                    "character": self.range.start_character
591                },
592                "end": {
593                    "line": self.range.end_line,
594                    "character": self.range.end_character
595                }
596            },
597            "canResolveChildren": !self.children.is_empty(),
598            "children": self.children.iter().map(|c| c.to_json()).collect::<Vec<_>>()
599        })
600    }
601}
602
603/// Convert TestResult to JSON for LSP
604impl TestResult {
605    /// Serializes this test result to a JSON value for LSP communication.
606    pub fn to_json(&self) -> Value {
607        let mut result = json!({
608            "testId": self.test_id,
609            "state": match self.status {
610                TestStatus::Passed => "passed",
611                TestStatus::Failed => "failed",
612                TestStatus::Skipped => "skipped",
613                TestStatus::Errored => "errored",
614            }
615        });
616
617        if let Some(message) = &self.message {
618            result["message"] = json!({
619                "message": message
620            });
621        }
622
623        if let Some(duration) = self.duration {
624            result["duration"] = json!(duration);
625        }
626
627        result
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::parser::Parser;
635
636    #[test]
637    fn test_discover_test_functions() {
638        let code = r#"
639sub test_basic {
640    ok(1, "Basic test");
641}
642
643sub helper_function {
644    # Not a test
645}
646
647sub test_another_thing {
648    is($result, 42, "The answer");
649}
650"#;
651
652        let mut parser = Parser::new(code);
653        if let Ok(ast) = parser.parse() {
654            let runner = TestRunner::new(code.to_string(), "file:///test.pl".to_string());
655            let tests = runner.discover_tests(&ast);
656
657            // Debug: print tests found
658            eprintln!("Found {} tests", tests.len());
659            for test in &tests {
660                eprintln!("Test: {} (kind: {:?})", test.label, test.kind);
661                for child in &test.children {
662                    eprintln!("  Child: {}", child.label);
663                }
664            }
665
666            // Should find at least 1 test (file or functions)
667            assert!(!tests.is_empty());
668
669            // Should have found test functions
670            let test_functions: Vec<&str> = tests
671                .iter()
672                .filter(|t| t.kind == TestKind::Test && t.label.starts_with("test_"))
673                .map(|t| t.label.as_str())
674                .collect();
675
676            eprintln!("Test functions: {:?}", test_functions);
677            assert!(test_functions.contains(&"test_basic"));
678            assert!(test_functions.contains(&"test_another_thing"));
679        }
680    }
681
682    #[test]
683    fn test_discover_test_assertions() {
684        let code = r#"
685use Test::More;
686
687ok(1, "First test");
688is($x, 5, "X should be 5");
689like($string, qr/pattern/, "String matches");
690
691done_testing();
692"#;
693
694        let mut parser = Parser::new(code);
695        if let Ok(ast) = parser.parse() {
696            let runner = TestRunner::new(code.to_string(), "file:///test.t".to_string());
697            let tests = runner.discover_tests(&ast);
698
699            // Should find test file with assertions
700            assert!(!tests.is_empty());
701
702            // Should have discovered individual assertions
703            let all_tests: Vec<&TestItem> = tests
704                .iter()
705                .flat_map(|t| {
706                    let mut items = vec![t];
707                    items.extend(&t.children);
708                    items
709                })
710                .collect();
711
712            // Debug: print all tests
713            eprintln!("All tests found:");
714            for test in &all_tests {
715                eprintln!("  Test: {} (kind: {:?})", test.label, test.kind);
716            }
717
718            // Should have found the test file
719            assert!(!tests.is_empty());
720            assert_eq!(tests[0].kind, TestKind::File);
721        }
722    }
723
724    #[test]
725    fn test_is_test_file() {
726        let runner = TestRunner::new("".to_string(), "".to_string());
727
728        assert!(runner.is_test_file("file:///t/basic.t"));
729        assert!(runner.is_test_file("file:///tests/foo_test.pl"));
730        assert!(runner.is_test_file("file:///MyTest.pl"));
731        assert!(runner.is_test_file("file:///test_something.pl"));
732
733        assert!(!runner.is_test_file("file:///lib/Module.pm"));
734        assert!(!runner.is_test_file("file:///script.pl"));
735    }
736}