Skip to main content

perl_lsp_code_actions/enhanced/
mod.rs

1//! Enhanced code actions with additional refactorings
2//!
3//! This module extends the base code actions with more sophisticated refactorings,
4//! including extract variable, extract subroutine, loop conversion, and import management.
5//!
6//! # Architecture
7//!
8//! Enhanced actions are organized into focused submodules:
9//!
10//! - **extract_variable**: Extract selected expression into a named variable
11//! - **extract_subroutine**: Extract code block into a new subroutine
12//! - **loop_conversion**: Convert between loop styles (for/foreach/while)
13//! - **import_management**: Organize and add/remove use statements
14//! - **postfix**: Postfix completion-style actions (e.g., `.if`, `.unless`)
15//! - **error_checking**: Add error handling around expressions
16//! - **helpers**: Shared utilities for text manipulation and position mapping
17//!
18//! # Refactoring Categories
19//!
20//! Actions are categorized following LSP CodeActionKind:
21//!
22//! - **refactor.extract**: Extract variable, extract subroutine
23//! - **refactor.rewrite**: Loop conversion, error wrapping
24//! - **source.organizeImports**: Import management
25//!
26//! # Performance Characteristics
27//!
28//! - **Action generation**: <50ms for typical refactoring suggestions
29//! - **Edit computation**: <100ms for complex multi-location edits
30//! - **Incremental analysis**: Leverages parsed AST for efficient analysis
31
32use crate::types::CodeAction;
33use perl_parser_core::ast::{Node, NodeKind};
34use std::collections::HashSet;
35
36mod error_checking;
37mod extract_subroutine;
38mod extract_variable;
39mod helpers;
40mod import_management;
41mod loop_conversion;
42mod postfix;
43
44use helpers::Helpers;
45
46/// Enhanced code actions provider with additional refactorings
47pub struct EnhancedCodeActionsProvider {
48    source: String,
49    lines: Vec<String>,
50}
51
52impl EnhancedCodeActionsProvider {
53    /// Create a new enhanced code actions provider
54    pub fn new(source: String) -> Self {
55        let lines = source.lines().map(|s| s.to_string()).collect();
56        Self { source, lines }
57    }
58
59    /// Get additional refactoring actions
60    pub fn get_enhanced_refactoring_actions(
61        &self,
62        ast: &Node,
63        range: (usize, usize),
64    ) -> Vec<CodeAction> {
65        let mut actions = Vec::new();
66        // Track (stmt_start, var_name) pairs already emitted to prevent duplicate
67        // extract-variable actions when both a parent and child node overlap the range.
68        let mut extract_var_seen: HashSet<(usize, String)> = HashSet::new();
69
70        // Find all nodes that overlap the range and collect actions
71        self.collect_actions_for_range(ast, range, false, &mut actions, &mut extract_var_seen);
72
73        // Global actions (not node-specific)
74        actions.extend(self.get_global_refactorings(ast));
75
76        actions
77    }
78
79    /// Recursively collect actions for all nodes in range.
80    ///
81    /// `is_control_body` is `true` when the current node is the body block of a
82    /// control-flow construct (`If`, `While`, `For`, `Foreach`, `Subroutine`).
83    /// In that case the node is not offered as "Extract to subroutine" — only
84    /// standalone bare blocks are extractable.
85    fn collect_actions_for_range(
86        &self,
87        node: &Node,
88        range: (usize, usize),
89        is_control_body: bool,
90        actions: &mut Vec<CodeAction>,
91        extract_var_seen: &mut HashSet<(usize, String)>,
92    ) {
93        // Check if this node overlaps the range
94        if node.location.start <= range.1 && node.location.end >= range.0 {
95            let helpers = Helpers::new(&self.source, &self.lines);
96
97            // Extract variable — only emit when the node's end reaches or exceeds the
98            // selection's end. This prevents duplicate actions for nested expressions:
99            // when both a Binary(8..25) and its inner FunctionCall(8..20) overlap a
100            // selection (8..25), the FunctionCall's end (20) is before the selection's
101            // end (25) and is skipped; only the outermost matching node emits an action.
102            // Partial-left overlap (cursor inside expression) is still supported.
103            let node_reaches_selection_end = node.location.end >= range.1;
104            if node_reaches_selection_end && self.is_extractable_expression(node) {
105                let action =
106                    extract_variable::create_extract_variable_action(node, &self.source, &helpers);
107                if let Some(decl) = action.edit.changes.first() {
108                    let key = (decl.location.start, decl.new_text.clone());
109                    if extract_var_seen.insert(key) {
110                        actions.push(action);
111                    }
112                } else {
113                    actions.push(action);
114                }
115            }
116
117            // Convert old-style loops
118            if let Some(action) = loop_conversion::convert_loop_style(node, &self.source) {
119                actions.push(action);
120            }
121
122            // Add error checking
123            if let Some(action) = error_checking::add_error_checking(node, &self.source) {
124                actions.push(action);
125            }
126
127            // Convert to postfix
128            if let Some(action) = postfix::convert_to_postfix(node, &self.source) {
129                actions.push(action);
130            }
131
132            // Extract subroutine — only for standalone blocks, not control-flow bodies
133            if !is_control_body && self.is_extractable_block(node) {
134                actions.push(extract_subroutine::create_extract_subroutine_action(
135                    node,
136                    &self.source,
137                    &helpers,
138                ));
139            }
140        }
141
142        // Recursively check children, flagging control-flow body blocks
143        match &node.kind {
144            NodeKind::Program { statements } => {
145                for stmt in statements {
146                    self.collect_actions_for_range(stmt, range, false, actions, extract_var_seen);
147                }
148            }
149            NodeKind::Block { statements } => {
150                for stmt in statements {
151                    self.collect_actions_for_range(stmt, range, false, actions, extract_var_seen);
152                }
153            }
154            NodeKind::ExpressionStatement { expression } => {
155                self.collect_actions_for_range(expression, range, false, actions, extract_var_seen);
156            }
157            NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
158                self.collect_actions_for_range(condition, range, false, actions, extract_var_seen);
159                self.collect_actions_for_range(
160                    then_branch,
161                    range,
162                    true, // then-body is a control-flow block
163                    actions,
164                    extract_var_seen,
165                );
166                for (cond, branch) in elsif_branches {
167                    self.collect_actions_for_range(cond, range, false, actions, extract_var_seen);
168                    self.collect_actions_for_range(branch, range, true, actions, extract_var_seen);
169                }
170                if let Some(branch) = else_branch {
171                    self.collect_actions_for_range(branch, range, true, actions, extract_var_seen);
172                }
173            }
174            NodeKind::FunctionCall { args, .. } => {
175                for arg in args {
176                    self.collect_actions_for_range(arg, range, false, actions, extract_var_seen);
177                }
178            }
179            NodeKind::Binary { left, right, .. } => {
180                self.collect_actions_for_range(left, range, false, actions, extract_var_seen);
181                self.collect_actions_for_range(right, range, false, actions, extract_var_seen);
182            }
183            NodeKind::Assignment { lhs, rhs, .. } => {
184                self.collect_actions_for_range(lhs, range, false, actions, extract_var_seen);
185                self.collect_actions_for_range(rhs, range, false, actions, extract_var_seen);
186            }
187            NodeKind::VariableDeclaration { variable, initializer, .. } => {
188                self.collect_actions_for_range(variable, range, false, actions, extract_var_seen);
189                if let Some(init) = initializer {
190                    self.collect_actions_for_range(init, range, false, actions, extract_var_seen);
191                }
192            }
193            NodeKind::For { init, condition, update, body, .. } => {
194                if let Some(init) = init {
195                    self.collect_actions_for_range(init, range, false, actions, extract_var_seen);
196                }
197                if let Some(condition) = condition {
198                    self.collect_actions_for_range(
199                        condition,
200                        range,
201                        false,
202                        actions,
203                        extract_var_seen,
204                    );
205                }
206                if let Some(update) = update {
207                    self.collect_actions_for_range(update, range, false, actions, extract_var_seen);
208                }
209                self.collect_actions_for_range(
210                    body,
211                    range,
212                    true, // loop body is a control-flow block
213                    actions,
214                    extract_var_seen,
215                );
216            }
217            NodeKind::Foreach { variable, list, body, continue_block } => {
218                self.collect_actions_for_range(variable, range, false, actions, extract_var_seen);
219                self.collect_actions_for_range(list, range, false, actions, extract_var_seen);
220                self.collect_actions_for_range(body, range, true, actions, extract_var_seen);
221                if let Some(cb) = continue_block {
222                    self.collect_actions_for_range(cb, range, false, actions, extract_var_seen);
223                }
224            }
225            NodeKind::While { condition, body, .. } => {
226                self.collect_actions_for_range(condition, range, false, actions, extract_var_seen);
227                self.collect_actions_for_range(
228                    body,
229                    range,
230                    true, // loop body is a control-flow block
231                    actions,
232                    extract_var_seen,
233                );
234            }
235            NodeKind::MethodCall { object, args, .. } => {
236                self.collect_actions_for_range(object, range, false, actions, extract_var_seen);
237                for arg in args {
238                    self.collect_actions_for_range(arg, range, false, actions, extract_var_seen);
239                }
240            }
241            NodeKind::Subroutine { body, prototype, signature, .. } => {
242                self.collect_actions_for_range(
243                    body,
244                    range,
245                    true, // subroutine body block is not a standalone block
246                    actions,
247                    extract_var_seen,
248                );
249                if let Some(proto) = prototype {
250                    self.collect_actions_for_range(proto, range, false, actions, extract_var_seen);
251                }
252                if let Some(sig) = signature {
253                    self.collect_actions_for_range(sig, range, false, actions, extract_var_seen);
254                }
255            }
256            _ => {}
257        }
258    }
259
260    /// Check if expression is extractable
261    fn is_extractable_expression(&self, node: &Node) -> bool {
262        matches!(
263            &node.kind,
264            NodeKind::FunctionCall { .. }
265                | NodeKind::Binary { .. }
266                | NodeKind::Unary { .. }
267                | NodeKind::MethodCall { .. }
268                | NodeKind::Ternary { .. }
269        )
270    }
271
272    /// Check if block is extractable
273    fn is_extractable_block(&self, node: &Node) -> bool {
274        matches!(&node.kind, NodeKind::Block { .. })
275    }
276
277    /// Get global refactoring actions
278    fn get_global_refactorings(&self, ast: &Node) -> Vec<CodeAction> {
279        let mut actions = Vec::new();
280        let helpers = Helpers::new(&self.source, &self.lines);
281
282        // Add missing imports
283        if let Some(action) = import_management::add_missing_imports(ast, &self.source, &helpers) {
284            actions.push(action);
285        }
286
287        // Organize imports
288        if let Some(action) = import_management::organize_imports(ast, &self.source, &helpers) {
289            actions.push(action);
290        }
291
292        // Add pragmas
293        actions.extend(self.add_recommended_pragmas(&helpers));
294
295        actions
296    }
297
298    /// Add recommended pragmas
299    fn add_recommended_pragmas(&self, helpers: &Helpers<'_>) -> Vec<CodeAction> {
300        use crate::types::{CodeAction, CodeActionEdit, CodeActionKind};
301        use perl_lsp_rename::TextEdit;
302        use perl_parser_core::ast::SourceLocation;
303
304        let mut actions = Vec::new();
305
306        // Check for missing strict and warnings
307        let has_strict = self.source.contains("use strict");
308        let has_warnings = self.source.contains("use warnings");
309
310        if !has_strict || !has_warnings {
311            let mut pragmas = Vec::new();
312            if !has_strict {
313                pragmas.push("use strict;");
314            }
315            if !has_warnings {
316                pragmas.push("use warnings;");
317            }
318
319            let insert_pos = helpers.find_pragma_insert_position();
320
321            actions.push(CodeAction {
322                title: format!("Add missing pragmas ({})", pragmas.join(", ")),
323                kind: CodeActionKind::QuickFix,
324                diagnostics: Vec::new(),
325                edit: CodeActionEdit {
326                    changes: vec![TextEdit {
327                        location: SourceLocation { start: insert_pos, end: insert_pos },
328                        new_text: format!("{}\n", pragmas.join("\n")),
329                    }],
330                },
331                is_preferred: true,
332            });
333        }
334
335        // Add utf8 support if missing
336        if !self.source.contains("use utf8") && helpers.has_non_ascii_content() {
337            let insert_pos = helpers.find_pragma_insert_position();
338
339            actions.push(CodeAction {
340                title: "Add UTF-8 support".to_string(),
341                kind: CodeActionKind::QuickFix,
342                diagnostics: Vec::new(),
343                edit: CodeActionEdit {
344                    changes: vec![TextEdit {
345                        location: SourceLocation { start: insert_pos, end: insert_pos },
346                        new_text: "use utf8;\nuse open qw(:std :utf8);\n".to_string(),
347                    }],
348                },
349                is_preferred: false,
350            });
351        }
352
353        actions
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use perl_parser_core::Parser;
361    use perl_tdd_support::must;
362
363    #[test]
364    fn test_extract_variable() {
365        let source = "my $x = length($string) + 10;";
366        let mut parser = Parser::new(source);
367        let ast = must(parser.parse());
368
369        let provider = EnhancedCodeActionsProvider::new(source.to_string());
370        let actions = provider.get_enhanced_refactoring_actions(&ast, (8, 23)); // Select "length($string)"
371
372        // Debug: print all actions
373        for action in &actions {
374            eprintln!("Action: {}", action.title);
375        }
376
377        assert!(!actions.is_empty(), "Expected at least one action");
378        assert!(
379            actions.iter().any(|a| a.title.contains("Extract")),
380            "Expected an Extract action, got: {:?}",
381            actions.iter().map(|a| &a.title).collect::<Vec<_>>()
382        );
383    }
384
385    #[test]
386    fn test_add_error_checking() {
387        let source = "open my $fh, '<', 'file.txt';";
388        let mut parser = Parser::new(source);
389        let ast = must(parser.parse());
390
391        let provider = EnhancedCodeActionsProvider::new(source.to_string());
392        let actions = provider.get_enhanced_refactoring_actions(&ast, (0, 30));
393
394        assert!(actions.iter().any(|a| a.title.contains("error checking")));
395    }
396
397    #[test]
398    fn test_convert_to_postfix() {
399        let source = "if ($debug) { print \"Debug\\n\"; }";
400        let mut parser = Parser::new(source);
401        let ast = must(parser.parse());
402
403        let provider = EnhancedCodeActionsProvider::new(source.to_string());
404        let actions = provider.get_enhanced_refactoring_actions(&ast, (0, source.len()));
405
406        assert!(actions.iter().any(|a| a.title.contains("postfix")));
407    }
408}
409
410#[cfg(test)]
411mod extract_variable_tests {
412    use super::*;
413    use perl_parser_core::Parser;
414    use perl_tdd_support::must;
415
416    #[test]
417    fn test_extract_hash_access_to_variable() {
418        // Use assignment so hash access is in the RHS, not a print argument
419        let source = "my $x = $hash{$key};";
420        let mut parser = Parser::new(source);
421        let ast = must(parser.parse());
422
423        let provider = EnhancedCodeActionsProvider::new(source.to_string());
424        // Select the range covering $hash{$key} (bytes 8..19)
425        let actions = provider.get_enhanced_refactoring_actions(&ast, (8, 19));
426
427        let extract_actions: Vec<_> =
428            actions.iter().filter(|a| a.title.contains("Extract")).collect();
429
430        assert!(
431            !extract_actions.is_empty(),
432            "Expected an Extract action for hash access, got: {:?}",
433            actions.iter().map(|a| &a.title).collect::<Vec<_>>()
434        );
435
436        // Verify the action produces a declaration with `my $val`
437        let action = &extract_actions[0];
438        let decl_edit = &action.edit.changes[0];
439        assert!(
440            decl_edit.new_text.contains("my $val"),
441            "Expected variable name '$val' for hash access, got: {}",
442            decl_edit.new_text
443        );
444    }
445
446    #[test]
447    fn test_extract_method_call_to_variable() {
448        let source = "print $obj->method();";
449        let mut parser = Parser::new(source);
450        let ast = must(parser.parse());
451
452        let provider = EnhancedCodeActionsProvider::new(source.to_string());
453        // Select the range covering $obj->method()
454        let actions = provider.get_enhanced_refactoring_actions(&ast, (6, 20));
455
456        let extract_actions: Vec<_> =
457            actions.iter().filter(|a| a.title.contains("Extract")).collect();
458
459        assert!(
460            !extract_actions.is_empty(),
461            "Expected an Extract action for method call, got: {:?}",
462            actions.iter().map(|a| &a.title).collect::<Vec<_>>()
463        );
464
465        // Verify the action produces a declaration with `my $result`
466        let action = &extract_actions[0];
467        let decl_edit = &action.edit.changes[0];
468        assert!(
469            decl_edit.new_text.contains("my $result"),
470            "Expected variable name '$result' for method call, got: {}",
471            decl_edit.new_text
472        );
473
474        // Verify the replacement edit uses $result
475        let replace_edit = &action.edit.changes[1];
476        assert!(
477            replace_edit.new_text.contains("$result"),
478            "Expected replacement with '$result', got: {}",
479            replace_edit.new_text
480        );
481    }
482
483    #[test]
484    fn test_extract_method_call_new_suggests_instance() {
485        let source = "my $x = Foo->new();";
486        let mut parser = Parser::new(source);
487        let ast = must(parser.parse());
488
489        let provider = EnhancedCodeActionsProvider::new(source.to_string());
490        let actions = provider.get_enhanced_refactoring_actions(&ast, (8, 18));
491
492        let extract_actions: Vec<_> =
493            actions.iter().filter(|a| a.title.contains("Extract")).collect();
494
495        assert!(
496            !extract_actions.is_empty(),
497            "Expected an Extract action for constructor call, got: {:?}",
498            actions.iter().map(|a| &a.title).collect::<Vec<_>>()
499        );
500
501        // Constructor call ->new() should suggest $instance
502        let action = &extract_actions[0];
503        let decl_edit = &action.edit.changes[0];
504        assert!(
505            decl_edit.new_text.contains("my $instance"),
506            "Expected variable name '$instance' for ->new(), got: {}",
507            decl_edit.new_text
508        );
509    }
510
511    #[test]
512    fn test_extract_variable_edit_structure() {
513        let source = "my $x = $obj->get();";
514        let mut parser = Parser::new(source);
515        let ast = must(parser.parse());
516
517        let provider = EnhancedCodeActionsProvider::new(source.to_string());
518        let actions = provider.get_enhanced_refactoring_actions(&ast, (8, 19));
519
520        let extract_actions: Vec<_> =
521            actions.iter().filter(|a| a.title.contains("Extract")).collect();
522
523        assert!(!extract_actions.is_empty(), "Expected at least one extract action");
524
525        let action = &extract_actions[0];
526        assert_eq!(action.edit.changes.len(), 2, "Expected exactly 2 edits (insert + replace)");
527
528        // First edit: insertion of variable declaration
529        let insert_edit = &action.edit.changes[0];
530        assert!(
531            insert_edit.new_text.starts_with("my $"),
532            "First edit should be a variable declaration"
533        );
534        assert!(insert_edit.new_text.ends_with(";\n"), "Declaration should end with semicolon");
535
536        // Second edit: replacement of expression with variable reference
537        let replace_edit = &action.edit.changes[1];
538        assert!(
539            replace_edit.new_text.starts_with('$'),
540            "Second edit should be a variable reference"
541        );
542    }
543}