Skip to main content

perl_lsp_code_actions/
code_actions.rs

1//! Code actions and quick fixes for Perl
2//!
3//! This module provides automated fixes for common issues and refactoring actions.
4//!
5//! # LSP Workflow Integration
6//!
7//! Code actions integrate with the Parse → Index → Navigate → Complete → Analyze workflow:
8//!
9//! - **Parse**: AST analysis identifies code patterns requiring fixes or refactoring
10//! - **Index**: Symbol tables provide context for variable and function renaming actions
11//! - **Navigate**: Cross-file analysis enables workspace-wide refactoring operations
12//! - **Complete**: Code action suggestions are refined based on completion context
13//! - **Analyze**: Diagnostic analysis drives automated fix generation and prioritization
14//!
15//! This integration ensures code actions are contextually appropriate and maintain
16//! code correctness across the entire Perl workspace.
17//!
18//! # LSP Client Capabilities
19//!
20//! Requires client support for `textDocument/codeAction` capabilities and
21//! `workspace/workspaceEdit` to apply edits across files.
22//!
23//! # Protocol Compliance
24//!
25//! Implements LSP code action protocol semantics (LSP 3.17+) including
26//! range-based requests, diagnostic filtering, and edit application rules.
27//!
28//! # Performance Characteristics
29//!
30//! - **Action generation**: <50ms for typical code action requests
31//! - **Edit application**: <100ms for complex workspace refactoring
32//! - **Memory usage**: <5MB for action metadata and edit operations
33//! - **Incremental analysis**: Leverages ≤1ms parsing SLO for real-time suggestions
34//!
35//! # Related Modules
36//!
37//! This module integrates with diagnostics and import optimization modules
38//! for import-related code actions.
39//!
40//! # See also
41//!
42//! - [`DiagnosticsProvider`](crate::ide::lsp_compat::diagnostics::DiagnosticsProvider)
43//! - [`crate::ide::lsp_compat::references`]
44//!
45//! # Usage Examples
46//!
47//! ```ignore
48//! use perl_lsp_providers::ide::lsp_compat::code_actions::{CodeActionsProvider, CodeActionKind};
49//! use perl_lsp_providers::ide::lsp_compat::diagnostics::Diagnostic;
50//! use perl_parser_core::Parser;
51//!
52//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
53//! let code = "my $unused_var = 42;";
54//! let provider = CodeActionsProvider::new(code.to_string());
55//! let mut parser = Parser::new(code);
56//! let ast = parser.parse()?;
57//! let diagnostics = vec![]; // Would contain actual diagnostics
58//!
59//! // Generate code actions for diagnostics
60//! let actions = provider.get_code_actions(&ast, (0, code.len()), &diagnostics);
61//! for action in actions {
62//!     println!("Available action: {} ({:?})", action.title, action.kind);
63//! }
64//! # Ok(())
65//! # }
66//! ```
67
68use crate::modernize;
69use crate::quick_fixes;
70use crate::refactors;
71use crate::types::QuickFixDiagnostic;
72
73pub use crate::types::{CodeAction, CodeActionKind};
74
75use perl_diagnostics_codes::DiagnosticCode;
76use perl_lsp_diagnostics::Diagnostic;
77use perl_parser_core::Node;
78
79/// Convert Diagnostic to QuickFixDiagnostic
80///
81/// Since Diagnostic already uses byte offsets, this is a simple copy.
82fn to_quick_fix_diagnostic(diag: &Diagnostic) -> QuickFixDiagnostic {
83    QuickFixDiagnostic { range: diag.range, message: diag.message.clone(), code: diag.code.clone() }
84}
85
86/// Code actions provider
87///
88/// Analyzes Perl source code and provides automated fixes and refactoring
89/// actions for common issues and improvement opportunities.
90pub struct CodeActionsProvider {
91    source: String,
92}
93
94impl CodeActionsProvider {
95    /// Create a new code actions provider
96    pub fn new(source: String) -> Self {
97        Self { source }
98    }
99
100    /// Get code actions for a range
101    pub fn get_code_actions(
102        &self,
103        ast: &Node,
104        range: (usize, usize),
105        diagnostics: &[Diagnostic],
106    ) -> Vec<CodeAction> {
107        let mut actions = Vec::new();
108
109        // Get quick fixes for diagnostics
110        for diagnostic in diagnostics {
111            let qf_diag = to_quick_fix_diagnostic(diagnostic);
112            if let Some(code) = &diagnostic.code {
113                match code.as_str() {
114                    // PL103: Undefined/undeclared variable
115                    c if c == DiagnosticCode::UndefinedVariable.as_str() => {
116                        actions.extend(quick_fixes::fix_undefined_variable(&self.source, &qf_diag));
117                    }
118                    // PL102: Unused variable
119                    c if c == DiagnosticCode::UnusedVariable.as_str() => {
120                        actions.extend(quick_fixes::fix_unused_variable(&self.source, &qf_diag));
121                    }
122                    // PL403: Assignment in condition
123                    c if c == DiagnosticCode::AssignmentInCondition.as_str() => {
124                        actions.extend(quick_fixes::fix_assignment_in_condition(
125                            &self.source,
126                            &qf_diag,
127                        ));
128                    }
129                    // PL100: Missing use strict
130                    c if c == DiagnosticCode::MissingStrict.as_str() => {
131                        actions.extend(quick_fixes::add_use_strict());
132                    }
133                    // PL101: Missing use warnings
134                    c if c == DiagnosticCode::MissingWarnings.as_str() => {
135                        actions.extend(quick_fixes::add_use_warnings());
136                    }
137                    // PL500: Deprecated defined()
138                    c if c == DiagnosticCode::DeprecatedDefined.as_str() => {
139                        actions.extend(quick_fixes::fix_deprecated_defined(&self.source, &qf_diag));
140                    }
141                    // PL404: Numeric comparison with undef
142                    c if c == DiagnosticCode::NumericComparisonWithUndef.as_str() => {
143                        actions.extend(quick_fixes::fix_numeric_undef(&self.source, &qf_diag));
144                    }
145                    // PL109: Unquoted bareword
146                    c if c == DiagnosticCode::UnquotedBareword.as_str() => {
147                        actions.extend(quick_fixes::fix_bareword(&self.source, &qf_diag));
148                    }
149                    // PL001: General parse error (stable code)
150                    // PL002: Syntax error — same quick-fix routing as PL001
151                    c if c == DiagnosticCode::ParseError.as_str()
152                        || c == DiagnosticCode::SyntaxError.as_str() =>
153                    {
154                        actions.extend(quick_fixes::fix_parse_error(&self.source, &qf_diag, c));
155                    }
156                    // parse-error-* subcodes (legacy subtype codes from error classifier)
157                    code if code.starts_with("parse-error-") => {
158                        actions.extend(quick_fixes::fix_parse_error(&self.source, &qf_diag, code));
159                    }
160                    // PL108: Unused parameter
161                    c if c == DiagnosticCode::UnusedParameter.as_str() => {
162                        actions.extend(quick_fixes::fix_unused_parameter(&qf_diag));
163                    }
164                    // PL104: Variable shadowing
165                    c if c == DiagnosticCode::VariableShadowing.as_str() => {
166                        actions.extend(quick_fixes::fix_variable_shadowing(&qf_diag));
167                    }
168                    // PL400: Bareword filehandle
169                    c if c == DiagnosticCode::BarewordFilehandle.as_str() => {
170                        actions.extend(quick_fixes::fix_bareword_filehandle(&qf_diag));
171                    }
172                    // PL401: Two-arg open
173                    c if c == DiagnosticCode::TwoArgOpen.as_str() => {
174                        actions.extend(quick_fixes::fix_two_arg_open(&qf_diag));
175                    }
176                    _ => {}
177                }
178            }
179        }
180
181        // Source-level lints (not diagnostic-driven)
182        // Only suggest shebang fix when the range includes the first line
183        if range.0 == 0 || self.source[..range.0].lines().count() <= 1 {
184            actions.extend(quick_fixes::fix_hardcoded_shebang(&self.source));
185        }
186
187        // Get refactoring actions for selection
188        actions.extend(refactors::get_refactoring_actions(&self.source, ast, range));
189
190        // Get modernization suggestions
191        actions.extend(modernize::get_modernize_actions(&self.source));
192
193        actions
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use perl_lsp_diagnostics::DiagnosticSeverity;
201    use perl_parser_core::Parser;
202    use perl_tdd_support::must;
203
204    /// Create a diagnostic with byte offsets
205    fn make_diagnostic(start: usize, end: usize, code: &str, msg: &str) -> Diagnostic {
206        Diagnostic {
207            range: (start, end),
208            severity: DiagnosticSeverity::Error,
209            code: Some(code.to_string()),
210            message: msg.to_string(),
211            related_information: Vec::new(),
212            tags: Vec::new(),
213            suggestion: None,
214        }
215    }
216
217    #[test]
218    fn test_undefined_variable_fix() {
219        let source = "use strict;\nprint $undefined;";
220        let mut parser = Parser::new(source);
221        let ast = must(parser.parse());
222
223        // Create a synthetic diagnostic for undefined-variable (stable code PL103)
224        // "$undefined" starts at byte offset 18 (after "use strict;\nprint ")
225        let diagnostics = vec![make_diagnostic(
226            18, // start of "$undefined"
227            28, // end of "$undefined"
228            "PL103",
229            "Undefined variable '$undefined'",
230        )];
231
232        let provider = CodeActionsProvider::new(source.to_string());
233        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
234
235        assert!(
236            actions.iter().any(|a| a.title.contains("Declare") || a.title.contains("my")),
237            "Expected action to declare variable, got: {:?}",
238            actions
239        );
240    }
241
242    #[test]
243    fn test_assignment_in_condition_fix() {
244        let source = "if ($x = 5) { }";
245        let mut parser = Parser::new(source);
246        let ast = must(parser.parse());
247
248        // Create a synthetic diagnostic for assignment-in-condition (stable code PL403)
249        // "$x = 5" is at bytes 4-10
250        let diagnostics = vec![make_diagnostic(
251            4,  // start of "$x = 5"
252            10, // end of "$x = 5"
253            "PL403",
254            "Assignment in condition",
255        )];
256
257        let provider = CodeActionsProvider::new(source.to_string());
258        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
259
260        assert!(
261            actions.iter().any(|a| a.title.contains("==")),
262            "Expected action to change to comparison, got: {:?}",
263            actions
264        );
265    }
266
267    #[test]
268    fn test_hardcoded_shebang_suggests_portable() {
269        let source = "#!/usr/bin/perl\nuse strict;\n";
270        let mut parser = Parser::new(source);
271        let ast = must(parser.parse());
272        let diagnostics = vec![];
273
274        let provider = CodeActionsProvider::new(source.to_string());
275        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
276
277        let shebang_actions: Vec<_> =
278            actions.iter().filter(|a| a.title.contains("portable shebang")).collect();
279
280        assert_eq!(shebang_actions.len(), 1, "Expected one shebang action");
281        assert_eq!(shebang_actions[0].edit.changes[0].new_text, "#!/usr/bin/env perl");
282    }
283
284    #[test]
285    fn test_hardcoded_shebang_preserves_flags() {
286        let source = "#!/usr/bin/perl -w\nuse strict;\n";
287        let mut parser = Parser::new(source);
288        let ast = must(parser.parse());
289        let diagnostics = vec![];
290
291        let provider = CodeActionsProvider::new(source.to_string());
292        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
293
294        let shebang_actions: Vec<_> =
295            actions.iter().filter(|a| a.title.contains("portable shebang")).collect();
296
297        assert_eq!(shebang_actions.len(), 1);
298        assert_eq!(shebang_actions[0].edit.changes[0].new_text, "#!/usr/bin/env perl -w");
299    }
300
301    #[test]
302    fn test_env_perl_shebang_not_flagged() {
303        let source = "#!/usr/bin/env perl\nuse strict;\n";
304        let mut parser = Parser::new(source);
305        let ast = must(parser.parse());
306        let diagnostics = vec![];
307
308        let provider = CodeActionsProvider::new(source.to_string());
309        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
310
311        let shebang_actions: Vec<_> =
312            actions.iter().filter(|a| a.title.contains("portable shebang")).collect();
313
314        assert!(shebang_actions.is_empty(), "env perl should not be flagged");
315    }
316
317    #[test]
318    fn test_no_shebang_not_flagged() {
319        let source = "use strict;\nuse warnings;\n";
320        let mut parser = Parser::new(source);
321        let ast = must(parser.parse());
322        let diagnostics = vec![];
323
324        let provider = CodeActionsProvider::new(source.to_string());
325        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
326
327        let shebang_actions: Vec<_> =
328            actions.iter().filter(|a| a.title.contains("portable shebang")).collect();
329
330        assert!(shebang_actions.is_empty(), "No shebang should not be flagged");
331    }
332
333    #[test]
334    fn test_local_bin_perl_shebang() {
335        let source = "#!/usr/local/bin/perl\nuse strict;\n";
336        let mut parser = Parser::new(source);
337        let ast = must(parser.parse());
338        let diagnostics = vec![];
339
340        let provider = CodeActionsProvider::new(source.to_string());
341        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
342
343        let shebang_actions: Vec<_> =
344            actions.iter().filter(|a| a.title.contains("portable shebang")).collect();
345
346        assert_eq!(shebang_actions.len(), 1, "Local bin perl should be flagged");
347        assert_eq!(shebang_actions[0].edit.changes[0].new_text, "#!/usr/bin/env perl");
348    }
349
350    #[test]
351    fn test_shebang_with_taint_flag() {
352        let source = "#!/usr/bin/perl -T\nuse strict;\n";
353        let mut parser = Parser::new(source);
354        let ast = must(parser.parse());
355        let diagnostics = vec![];
356
357        let provider = CodeActionsProvider::new(source.to_string());
358        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
359
360        let shebang_actions: Vec<_> =
361            actions.iter().filter(|a| a.title.contains("portable shebang")).collect();
362
363        assert_eq!(shebang_actions.len(), 1);
364        assert_eq!(shebang_actions[0].edit.changes[0].new_text, "#!/usr/bin/env perl -T");
365    }
366
367    #[test]
368    fn test_bash_shebang_not_flagged() {
369        let source = "#!/bin/bash\necho hello\n";
370        let mut parser = Parser::new(source);
371        let ast = must(parser.parse());
372        let diagnostics = vec![];
373
374        let provider = CodeActionsProvider::new(source.to_string());
375        let actions = provider.get_code_actions(&ast, (0, source.len()), &diagnostics);
376
377        let shebang_actions: Vec<_> =
378            actions.iter().filter(|a| a.title.contains("portable shebang")).collect();
379
380        assert!(shebang_actions.is_empty(), "Non-perl shebang should not be flagged");
381    }
382}