Skip to main content

perl_tdd_support/tdd/
tdd_basic.rs

1//! Basic TDD workflow support for LSP
2//!
3//! Simplified TDD implementation focused on core red-green-refactor cycle
4
5use crate::ast::{Node, NodeKind};
6
7/// Diagnostic produced during a TDD cycle (independent of `lsp_types`).
8///
9/// Used internally to track uncovered lines and code quality issues without
10/// pulling in the full LSP dependency.
11#[derive(Debug, Clone)]
12pub struct Diagnostic {
13    /// Byte offset range (start line, end line) - 0-indexed
14    pub range: (usize, usize),
15    /// Severity level
16    pub severity: DiagnosticSeverity,
17    /// Optional diagnostic code
18    pub code: Option<String>,
19    /// Human-readable message
20    pub message: String,
21    /// Related diagnostic information
22    pub related_information: Vec<String>,
23    /// Diagnostic tags
24    pub tags: Vec<String>,
25}
26
27/// Severity level for a TDD-cycle [`Diagnostic`].
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum DiagnosticSeverity {
30    /// A hard failure that must be resolved before the cycle can proceed.
31    Error,
32    /// A potential problem that should be addressed.
33    Warning,
34    /// Informational note, no action required.
35    Information,
36    /// Low-priority suggestion.
37    Hint,
38}
39
40/// Basic test generator
41pub struct TestGenerator {
42    framework: String,
43}
44
45impl TestGenerator {
46    /// Create a generator targeting `framework` (e.g. `"Test::More"` or `"Test2::V0"`).
47    pub fn new(framework: &str) -> Self {
48        Self { framework: framework.to_string() }
49    }
50
51    /// Generate basic test for a subroutine
52    pub fn generate_test(&self, name: &str, params: usize) -> String {
53        let args = (0..params).map(|i| format!("'arg{}'", i + 1)).collect::<Vec<_>>().join(", ");
54
55        match self.framework.as_str() {
56            "Test2::V0" => {
57                format!(
58                    "use Test2::V0;\n\n\
59                     subtest '{}' => sub {{\n    \
60                     my $result = {}({});\n    \
61                     ok($result, 'Function returns value');\n\
62                     }};\n\n\
63                     done_testing();\n",
64                    name, name, args
65                )
66            }
67            _ => {
68                // Default to Test::More
69                format!(
70                    "use Test::More;\n\n\
71                     subtest '{}' => sub {{\n    \
72                     my $result = {}({});\n    \
73                     ok(defined $result, 'Function returns defined value');\n\
74                     }};\n\n\
75                     done_testing();\n",
76                    name, name, args
77                )
78            }
79        }
80    }
81
82    /// Find all subroutines in AST
83    pub fn find_subroutines(&self, node: &Node) -> Vec<SubroutineInfo> {
84        let mut subs = Vec::new();
85        self.find_subroutines_recursive(node, &mut subs);
86        subs
87    }
88
89    fn find_subroutines_recursive(&self, node: &Node, subs: &mut Vec<SubroutineInfo>) {
90        match &node.kind {
91            NodeKind::Subroutine { name, signature, .. } => {
92                subs.push(SubroutineInfo {
93                    name: name.clone().unwrap_or_else(|| "anonymous".to_string()),
94                    param_count: signature
95                        .as_ref()
96                        .map(|s| {
97                            if let NodeKind::Signature { parameters } = &s.kind {
98                                parameters.len()
99                            } else {
100                                0
101                            }
102                        })
103                        .unwrap_or(0),
104                });
105            }
106            NodeKind::Program { statements } => {
107                for stmt in statements {
108                    self.find_subroutines_recursive(stmt, subs);
109                }
110            }
111            NodeKind::Block { statements } => {
112                for stmt in statements {
113                    self.find_subroutines_recursive(stmt, subs);
114                }
115            }
116            NodeKind::If { then_branch, elsif_branches, else_branch, .. } => {
117                self.find_subroutines_recursive(then_branch, subs);
118                for (_, branch) in elsif_branches {
119                    self.find_subroutines_recursive(branch, subs);
120                }
121                if let Some(branch) = else_branch {
122                    self.find_subroutines_recursive(branch, subs);
123                }
124            }
125            NodeKind::While { body, continue_block, .. }
126            | NodeKind::For { body, continue_block, .. } => {
127                self.find_subroutines_recursive(body, subs);
128                if let Some(cont) = continue_block {
129                    self.find_subroutines_recursive(cont, subs);
130                }
131            }
132            NodeKind::Foreach { body, .. }
133            | NodeKind::Given { body, .. }
134            | NodeKind::When { body, .. }
135            | NodeKind::Default { body } => {
136                self.find_subroutines_recursive(body, subs);
137            }
138            NodeKind::Package { block, .. } => {
139                if let Some(blk) = block {
140                    self.find_subroutines_recursive(blk, subs);
141                }
142            }
143            NodeKind::Class { body, .. } => {
144                self.find_subroutines_recursive(body, subs);
145            }
146            _ => {
147                // Other node types don't contain subroutines
148            }
149        }
150    }
151}
152
153/// Metadata about a subroutine discovered in an AST.
154#[derive(Debug, Clone)]
155pub struct SubroutineInfo {
156    /// The subroutine name (`"anonymous"` for unnamed subs).
157    pub name: String,
158    /// Number of declared parameters in the signature.
159    pub param_count: usize,
160}
161
162/// Basic refactoring analyzer
163pub struct RefactoringAnalyzer {
164    max_complexity: usize,
165    max_lines: usize,
166    max_params: usize,
167}
168
169impl Default for RefactoringAnalyzer {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl RefactoringAnalyzer {
176    /// Create an analyzer with default thresholds (complexity ≤ 10, lines ≤ 50, params ≤ 5).
177    pub fn new() -> Self {
178        Self { max_complexity: 10, max_lines: 50, max_params: 5 }
179    }
180
181    /// Analyze code and suggest refactorings
182    pub fn analyze(&self, node: &Node, source: &str) -> Vec<RefactoringSuggestion> {
183        let mut suggestions = Vec::new();
184        self.analyze_recursive(node, source, &mut suggestions);
185        suggestions
186    }
187
188    fn analyze_recursive(
189        &self,
190        node: &Node,
191        source: &str,
192        suggestions: &mut Vec<RefactoringSuggestion>,
193    ) {
194        match &node.kind {
195            NodeKind::Subroutine { name, signature, body, .. } => {
196                let sub_name = name.clone().unwrap_or_else(|| "anonymous".to_string());
197
198                // Check parameter count
199                let param_count = signature
200                    .as_ref()
201                    .map(|s| {
202                        if let NodeKind::Signature { parameters } = &s.kind {
203                            parameters.len()
204                        } else {
205                            0
206                        }
207                    })
208                    .unwrap_or(0);
209                if param_count > self.max_params {
210                    suggestions.push(RefactoringSuggestion {
211                        title: format!("Too many parameters in {}", sub_name),
212                        description: format!(
213                            "Function has {} parameters, consider using a hash",
214                            param_count
215                        ),
216                        category: RefactoringCategory::TooManyParameters,
217                    });
218                }
219
220                // Check complexity
221                let complexity = self.calculate_complexity(body);
222                if complexity > self.max_complexity {
223                    suggestions.push(RefactoringSuggestion {
224                        title: format!("High complexity in {}", sub_name),
225                        description: format!(
226                            "Cyclomatic complexity is {}, consider breaking into smaller functions",
227                            complexity
228                        ),
229                        category: RefactoringCategory::HighComplexity,
230                    });
231                }
232
233                // Check length
234                let lines = self.count_lines(body, source);
235                if lines > self.max_lines {
236                    suggestions.push(RefactoringSuggestion {
237                        title: format!("Long method: {}", sub_name),
238                        description: format!(
239                            "Method has {} lines, consider breaking into smaller functions",
240                            lines
241                        ),
242                        category: RefactoringCategory::LongMethod,
243                    });
244                }
245
246                // Recurse into body
247                self.analyze_recursive(body, source, suggestions);
248            }
249            NodeKind::Program { statements } | NodeKind::Block { statements } => {
250                for stmt in statements {
251                    self.analyze_recursive(stmt, source, suggestions);
252                }
253            }
254            NodeKind::If { then_branch, elsif_branches, else_branch, .. } => {
255                self.analyze_recursive(then_branch, source, suggestions);
256                for (_, branch) in elsif_branches {
257                    self.analyze_recursive(branch, source, suggestions);
258                }
259                if let Some(branch) = else_branch {
260                    self.analyze_recursive(branch, source, suggestions);
261                }
262            }
263            NodeKind::While { body, .. }
264            | NodeKind::For { body, .. }
265            | NodeKind::Foreach { body, .. }
266            | NodeKind::Given { body, .. }
267            | NodeKind::When { body, .. }
268            | NodeKind::Default { body }
269            | NodeKind::Class { body, .. } => {
270                self.analyze_recursive(body, source, suggestions);
271            }
272            NodeKind::Package { block, .. } => {
273                if let Some(blk) = block {
274                    self.analyze_recursive(blk, source, suggestions);
275                }
276            }
277            _ => {
278                // Other node types don't need analysis
279            }
280        }
281    }
282
283    fn calculate_complexity(&self, node: &Node) -> usize {
284        let mut complexity = 1;
285        self.count_decision_points(node, &mut complexity);
286        complexity
287    }
288
289    fn count_decision_points(&self, node: &Node, complexity: &mut usize) {
290        match &node.kind {
291            NodeKind::If { elsif_branches, .. } => {
292                *complexity += 1 + elsif_branches.len();
293            }
294            NodeKind::While { .. } | NodeKind::For { .. } | NodeKind::Foreach { .. } => {
295                *complexity += 1;
296            }
297            NodeKind::Binary { op, left, right } => {
298                if op == "&&" || op == "||" || op == "and" || op == "or" {
299                    *complexity += 1;
300                }
301                self.count_decision_points(left, complexity);
302                self.count_decision_points(right, complexity);
303                return;
304            }
305            _ => {}
306        }
307
308        // Recurse into children
309        match &node.kind {
310            NodeKind::Program { statements } | NodeKind::Block { statements } => {
311                for stmt in statements {
312                    self.count_decision_points(stmt, complexity);
313                }
314            }
315            NodeKind::If { then_branch, elsif_branches, else_branch, .. } => {
316                self.count_decision_points(then_branch, complexity);
317                for (_, branch) in elsif_branches {
318                    self.count_decision_points(branch, complexity);
319                }
320                if let Some(branch) = else_branch {
321                    self.count_decision_points(branch, complexity);
322                }
323            }
324            NodeKind::While { body, .. }
325            | NodeKind::For { body, .. }
326            | NodeKind::Foreach { body, .. }
327            | NodeKind::Given { body, .. }
328            | NodeKind::When { body, .. }
329            | NodeKind::Default { body }
330            | NodeKind::Class { body, .. } => {
331                self.count_decision_points(body, complexity);
332            }
333            _ => {}
334        }
335    }
336
337    fn count_lines(&self, node: &Node, source: &str) -> usize {
338        let start = node.location.start;
339        let end = node.location.end.min(source.len());
340
341        if start >= end {
342            return 0;
343        }
344
345        source[start..end].lines().count()
346    }
347}
348
349/// A single refactoring suggestion produced by [`RefactoringAnalyzer`].
350#[derive(Debug, Clone)]
351pub struct RefactoringSuggestion {
352    /// Short one-line title suitable for a menu item or notification.
353    pub title: String,
354    /// Longer explanation of why the refactoring is recommended.
355    pub description: String,
356    /// The kind of refactoring this suggestion belongs to.
357    pub category: RefactoringCategory,
358}
359
360/// Category of refactoring opportunity identified by [`RefactoringAnalyzer`].
361#[derive(Debug, Clone, PartialEq)]
362pub enum RefactoringCategory {
363    /// Subroutine has more parameters than the configured maximum.
364    TooManyParameters,
365    /// Cyclomatic complexity exceeds the configured threshold.
366    HighComplexity,
367    /// Subroutine body is longer than the configured line limit.
368    LongMethod,
369}
370
371/// Simple TDD workflow state
372#[derive(Debug, Clone, PartialEq)]
373pub enum TddState {
374    Red,
375    Green,
376    Refactor,
377    Idle,
378}
379
380/// TDD workflow manager
381pub struct TddWorkflow {
382    state: TddState,
383    generator: TestGenerator,
384    analyzer: RefactoringAnalyzer,
385}
386
387impl TddWorkflow {
388    /// Create a workflow manager using `framework` for test generation (e.g. `"Test::More"`).
389    pub fn new(framework: &str) -> Self {
390        Self {
391            state: TddState::Idle,
392            generator: TestGenerator::new(framework),
393            analyzer: RefactoringAnalyzer::new(),
394        }
395    }
396
397    /// Start TDD cycle
398    pub fn start_cycle(&mut self, test_name: &str) -> TddResult {
399        self.state = TddState::Red;
400        TddResult {
401            state: self.state.clone(),
402            message: format!("Starting TDD cycle for '{}'", test_name),
403        }
404    }
405
406    /// Run tests and update state
407    pub fn run_tests(&mut self, success: bool) -> TddResult {
408        self.state = if success { TddState::Green } else { TddState::Red };
409
410        TddResult {
411            state: self.state.clone(),
412            message: if success {
413                "Tests passing, ready to refactor".to_string()
414            } else {
415                "Tests failing, fix implementation".to_string()
416            },
417        }
418    }
419
420    /// Move to refactor phase
421    pub fn start_refactor(&mut self) -> TddResult {
422        self.state = TddState::Refactor;
423        TddResult {
424            state: self.state.clone(),
425            message: "Refactoring phase - improve code while keeping tests green".to_string(),
426        }
427    }
428
429    /// Complete cycle
430    pub fn complete_cycle(&mut self) -> TddResult {
431        self.state = TddState::Idle;
432        TddResult { state: self.state.clone(), message: "TDD cycle complete".to_string() }
433    }
434
435    /// Generate test for function
436    pub fn generate_test(&self, name: &str, params: usize) -> String {
437        self.generator.generate_test(name, params)
438    }
439
440    /// Analyze code for refactoring
441    pub fn analyze_for_refactoring(&self, ast: &Node, source: &str) -> Vec<RefactoringSuggestion> {
442        self.analyzer.analyze(ast, source)
443    }
444
445    /// Get coverage diagnostics
446    pub fn get_coverage_diagnostics(&self, uncovered_lines: &[usize]) -> Vec<Diagnostic> {
447        uncovered_lines
448            .iter()
449            .map(|&line| Diagnostic {
450                range: (line, line),
451                severity: DiagnosticSeverity::Warning,
452                code: Some("tdd.uncovered".to_string()),
453                message: "Line not covered by tests".to_string(),
454                related_information: vec![],
455                tags: vec![],
456            })
457            .collect()
458    }
459}
460
461/// Result returned from each [`TddWorkflow`] transition method.
462#[derive(Debug, Clone)]
463pub struct TddResult {
464    /// The new TDD state after the transition.
465    pub state: TddState,
466    /// Human-readable description of what happened and what to do next.
467    pub message: String,
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::SourceLocation;
474
475    #[test]
476    fn test_test_generation() {
477        let generator = TestGenerator::new("Test::More");
478        let test = generator.generate_test("add", 2);
479        assert!(test.contains("Test::More"));
480        assert!(test.contains("add"));
481        assert!(test.contains("arg1"));
482        assert!(test.contains("arg2"));
483    }
484
485    #[test]
486    fn test_find_subroutines() {
487        let ast = Node::new(
488            NodeKind::Program {
489                statements: vec![Node::new(
490                    NodeKind::Subroutine {
491                        name: Some("test_func".to_string()),
492                        name_span: None,
493                        prototype: None,
494                        signature: None,
495                        attributes: vec![],
496                        body: Box::new(Node::new(
497                            NodeKind::Block { statements: vec![] },
498                            SourceLocation { start: 0, end: 0 },
499                        )),
500                    },
501                    SourceLocation { start: 0, end: 0 },
502                )],
503            },
504            SourceLocation { start: 0, end: 0 },
505        );
506
507        let generator = TestGenerator::new("Test::More");
508        let subs = generator.find_subroutines(&ast);
509        assert_eq!(subs.len(), 1);
510        assert_eq!(subs[0].name, "test_func");
511    }
512
513    #[test]
514    fn test_refactoring_suggestions() {
515        let analyzer = RefactoringAnalyzer::new();
516
517        // Create a subroutine with too many parameters
518        let ast = Node::new(
519            NodeKind::Subroutine {
520                name: Some("complex".to_string()),
521                name_span: None,
522                prototype: None,
523                signature: Some(Box::new(Node::new(
524                    NodeKind::Signature {
525                        parameters: vec![
526                            Node::new(
527                                NodeKind::MandatoryParameter {
528                                    variable: Box::new(Node::new(
529                                        NodeKind::Variable {
530                                            sigil: "$".to_string(),
531                                            name: "a".to_string(),
532                                        },
533                                        SourceLocation { start: 0, end: 0 },
534                                    )),
535                                },
536                                SourceLocation { start: 0, end: 0 },
537                            ),
538                            Node::new(
539                                NodeKind::MandatoryParameter {
540                                    variable: Box::new(Node::new(
541                                        NodeKind::Variable {
542                                            sigil: "$".to_string(),
543                                            name: "b".to_string(),
544                                        },
545                                        SourceLocation { start: 0, end: 0 },
546                                    )),
547                                },
548                                SourceLocation { start: 0, end: 0 },
549                            ),
550                            Node::new(
551                                NodeKind::MandatoryParameter {
552                                    variable: Box::new(Node::new(
553                                        NodeKind::Variable {
554                                            sigil: "$".to_string(),
555                                            name: "c".to_string(),
556                                        },
557                                        SourceLocation { start: 0, end: 0 },
558                                    )),
559                                },
560                                SourceLocation { start: 0, end: 0 },
561                            ),
562                            Node::new(
563                                NodeKind::MandatoryParameter {
564                                    variable: Box::new(Node::new(
565                                        NodeKind::Variable {
566                                            sigil: "$".to_string(),
567                                            name: "d".to_string(),
568                                        },
569                                        SourceLocation { start: 0, end: 0 },
570                                    )),
571                                },
572                                SourceLocation { start: 0, end: 0 },
573                            ),
574                            Node::new(
575                                NodeKind::MandatoryParameter {
576                                    variable: Box::new(Node::new(
577                                        NodeKind::Variable {
578                                            sigil: "$".to_string(),
579                                            name: "e".to_string(),
580                                        },
581                                        SourceLocation { start: 0, end: 0 },
582                                    )),
583                                },
584                                SourceLocation { start: 0, end: 0 },
585                            ),
586                            Node::new(
587                                NodeKind::MandatoryParameter {
588                                    variable: Box::new(Node::new(
589                                        NodeKind::Variable {
590                                            sigil: "$".to_string(),
591                                            name: "f".to_string(),
592                                        },
593                                        SourceLocation { start: 0, end: 0 },
594                                    )),
595                                },
596                                SourceLocation { start: 0, end: 0 },
597                            ),
598                            // 6 parameters - more than max_params (5)
599                        ],
600                    },
601                    SourceLocation { start: 0, end: 0 },
602                ))),
603                attributes: vec![],
604                body: Box::new(Node::new(
605                    NodeKind::Block { statements: vec![] },
606                    SourceLocation { start: 0, end: 0 },
607                )),
608            },
609            SourceLocation { start: 0, end: 0 },
610        );
611
612        let suggestions = analyzer.analyze(&ast, "sub complex($a, $b, $c, $d, $e, $f) { }");
613        assert!(!suggestions.is_empty());
614        assert!(suggestions.iter().any(|s| s.category == RefactoringCategory::TooManyParameters));
615    }
616
617    #[test]
618    fn test_tdd_workflow() {
619        let mut workflow = TddWorkflow::new("Test::More");
620
621        // Start cycle
622        let _result = workflow.start_cycle("add");
623        assert_eq!(workflow.state, TddState::Red);
624
625        // Run failing tests
626        let _result = workflow.run_tests(false);
627        assert_eq!(workflow.state, TddState::Red);
628
629        // Run passing tests
630        let _result = workflow.run_tests(true);
631        assert_eq!(workflow.state, TddState::Green);
632
633        // Start refactoring
634        let _result = workflow.start_refactor();
635        assert_eq!(workflow.state, TddState::Refactor);
636
637        // Complete cycle
638        let _result = workflow.complete_cycle();
639        assert_eq!(workflow.state, TddState::Idle);
640    }
641}