use crate::types::{CodeAction, CodeActionEdit, CodeActionKind, QuickFixDiagnostic};
use perl_ast_utils::{find_declaration_position, get_indent_at};
use perl_diagnostics_codes::DiagnosticCode;
use perl_lsp_rename::TextEdit;
use perl_parser_core::SourceLocation;
pub fn fix_undefined_variable(source: &str, diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let mut actions = Vec::new();
if let Some(var_name) = diagnostic.message.split('\'').nth(1) {
let insert_pos = find_declaration_position(source, diagnostic.range.0);
actions.push(CodeAction {
title: format!("Declare '{}' with 'my'", var_name),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UndefinedVariable.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: insert_pos, end: insert_pos },
new_text: format!("my {};\n", var_name),
}],
},
is_preferred: true,
});
actions.push(CodeAction {
title: format!("Declare '{}' with 'our'", var_name),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UndefinedVariable.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: insert_pos, end: insert_pos },
new_text: format!("our {};\n", var_name),
}],
},
is_preferred: false,
});
}
actions
}
pub fn fix_unused_variable(source: &str, diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let mut actions = Vec::new();
let line_start = source[..diagnostic.range.0].rfind('\n').map(|p| p + 1).unwrap_or(0);
let line_end = source[diagnostic.range.1..]
.find('\n')
.map(|p| diagnostic.range.1 + p)
.unwrap_or(source.len());
actions.push(CodeAction {
title: "Remove unused variable".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UnusedVariable.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: line_start, end: line_end + 1 },
new_text: String::new(),
}],
},
is_preferred: true,
});
if let Some(var_name) = diagnostic.message.split('\'').nth(1) {
actions.push(CodeAction {
title: format!("Rename to '_{}'", var_name),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UnusedVariable.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.1 },
new_text: format!("_{}", var_name),
}],
},
is_preferred: false,
});
}
actions
}
pub fn fix_assignment_in_condition(
source: &str,
diagnostic: &QuickFixDiagnostic,
) -> Vec<CodeAction> {
let mut actions = Vec::new();
let assignment_pos =
source[diagnostic.range.0..diagnostic.range.1].find('=').map(|p| diagnostic.range.0 + p);
if let Some(pos) = assignment_pos {
actions.push(CodeAction {
title: "Change to comparison (==)".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::AssignmentInCondition.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: pos, end: pos + 1 },
new_text: "==".to_string(),
}],
},
is_preferred: true,
});
actions.push(CodeAction {
title: "Keep assignment (add parentheses)".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::AssignmentInCondition.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![
TextEdit {
location: SourceLocation {
start: diagnostic.range.0,
end: diagnostic.range.0,
},
new_text: "(".to_string(),
},
TextEdit {
location: SourceLocation {
start: diagnostic.range.1,
end: diagnostic.range.1,
},
new_text: ")".to_string(),
},
],
},
is_preferred: false,
});
}
actions
}
pub fn add_use_strict() -> Vec<CodeAction> {
vec![CodeAction {
title: "Add 'use strict'".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::MissingStrict.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: 0, end: 0 },
new_text: "use strict;\n".to_string(),
}],
},
is_preferred: true,
}]
}
pub fn add_use_warnings() -> Vec<CodeAction> {
vec![CodeAction {
title: "Add 'use warnings'".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::MissingWarnings.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: 0, end: 0 },
new_text: "use warnings;\n".to_string(),
}],
},
is_preferred: true,
}]
}
pub fn fix_deprecated_defined(source: &str, diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let mut actions = Vec::new();
if let Some(start) = source[diagnostic.range.0..diagnostic.range.1].find("defined") {
let defined_start = diagnostic.range.0 + start;
let arg_start = defined_start + 7;
let arg_text = &source[arg_start..diagnostic.range.1].trim();
actions.push(CodeAction {
title: format!("Replace with 'if ({})'", arg_text),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::DeprecatedDefined.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: defined_start, end: diagnostic.range.1 },
new_text: arg_text.to_string(),
}],
},
is_preferred: true,
});
}
actions
}
pub fn fix_numeric_undef(source: &str, diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let mut actions = Vec::new();
actions.push(CodeAction {
title: "Add defined check".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::NumericComparisonWithUndef.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![
TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.0 },
new_text: "defined(".to_string(),
},
TextEdit {
location: SourceLocation { start: diagnostic.range.1, end: diagnostic.range.1 },
new_text: ")".to_string(),
},
],
},
is_preferred: true,
});
if source[diagnostic.range.0..diagnostic.range.1].contains("==") {
actions.push(CodeAction {
title: "Use defined-or operator (//)".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::NumericComparisonWithUndef.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.1 },
new_text: "// 0".to_string(), }],
},
is_preferred: false,
});
}
actions
}
pub fn fix_bareword(source: &str, diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let mut actions = Vec::new();
let bareword = &source[diagnostic.range.0..diagnostic.range.1];
let is_uppercase = bareword.chars().all(|c| c.is_ascii_uppercase() || c == '_');
actions.push(CodeAction {
title: format!("Quote '{}' with single quotes", bareword),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UnquotedBareword.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.1 },
new_text: format!("'{}'", bareword),
}],
},
is_preferred: true,
});
actions.push(CodeAction {
title: format!("Quote '{}' with double quotes", bareword),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UnquotedBareword.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.1 },
new_text: format!("\"{}\"", bareword),
}],
},
is_preferred: false,
});
if is_uppercase {
let insert_pos = find_declaration_position(source, diagnostic.range.0);
let indent = get_indent_at(source, insert_pos);
actions.push(CodeAction {
title: format!("Declare '{}' as filehandle", bareword),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UnquotedBareword.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: insert_pos, end: insert_pos },
new_text: format!("{}open my ${};\n", indent, bareword),
}],
},
is_preferred: false,
});
}
actions
}
pub fn fix_parse_error(
source: &str,
diagnostic: &QuickFixDiagnostic,
code: &str,
) -> Vec<CodeAction> {
let mut actions = Vec::new();
match code {
"parse-error-missingsemicolon" => {
let line_end = source[diagnostic.range.0..]
.find('\n')
.map(|p| diagnostic.range.0 + p)
.unwrap_or(source.len());
let mut end_pos = line_end;
while end_pos > diagnostic.range.0
&& source.as_bytes()[end_pos - 1].is_ascii_whitespace()
{
end_pos -= 1;
}
actions.push(CodeAction {
title: "Add missing semicolon".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![code.to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: end_pos, end: end_pos },
new_text: ";".to_string(),
}],
},
is_preferred: true,
});
}
"PL001" | "PL002"
if diagnostic.message.to_ascii_lowercase().contains("missing semicolon") =>
{
let at_heredoc = source[diagnostic.range.0..].get(..2).is_some_and(|s| s == "<<");
if !at_heredoc {
let line_end = source[diagnostic.range.0..]
.find('\n')
.map(|p| diagnostic.range.0 + p)
.unwrap_or(source.len());
let mut end_pos = line_end;
while end_pos > diagnostic.range.0
&& source.as_bytes()[end_pos - 1].is_ascii_whitespace()
{
end_pos -= 1;
}
actions.push(CodeAction {
title: "Add missing semicolon".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![code.to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: end_pos, end: end_pos },
new_text: ";".to_string(),
}],
},
is_preferred: true,
});
}
}
"parse-error-unclosedstring" => {
actions.push(CodeAction {
title: "Add closing quote".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![code.to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation {
start: diagnostic.range.1,
end: diagnostic.range.1,
},
new_text: "\"".to_string(),
}],
},
is_preferred: true,
});
}
"parse-error-unclosedparenthesis" => {
actions.push(CodeAction {
title: "Add closing parenthesis".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![code.to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation {
start: diagnostic.range.1,
end: diagnostic.range.1,
},
new_text: ")".to_string(),
}],
},
is_preferred: true,
});
}
"parse-error-unclosedbracket" => {
actions.push(CodeAction {
title: "Add closing bracket".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![code.to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation {
start: diagnostic.range.1,
end: diagnostic.range.1,
},
new_text: "]".to_string(),
}],
},
is_preferred: true,
});
}
"parse-error-unclosedbrace" | "parse-error-unclosedblock" => {
actions.push(CodeAction {
title: "Add closing brace".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![code.to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation {
start: diagnostic.range.1,
end: diagnostic.range.1,
},
new_text: "}".to_string(),
}],
},
is_preferred: true,
});
}
_ => {}
}
actions
}
pub fn fix_unused_parameter(diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let mut actions = Vec::new();
if let Some(param_name) = diagnostic.message.split('\'').nth(1) {
actions.push(CodeAction {
title: format!("Rename to '_{}'", param_name),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::UnusedParameter.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.1 },
new_text: format!("_{}", param_name),
}],
},
is_preferred: true,
});
}
actions
}
pub fn fix_hardcoded_shebang(source: &str) -> Vec<CodeAction> {
let first_line = match source.lines().next() {
Some(line) => line,
None => return Vec::new(),
};
if !first_line.starts_with("#!") {
return Vec::new();
}
if first_line.contains("/env ") || first_line.contains("/env\t") {
return Vec::new();
}
if !first_line.contains("perl") {
return Vec::new();
}
let flags = extract_shebang_flags(first_line);
let new_shebang = if flags.is_empty() {
"#!/usr/bin/env perl".to_string()
} else {
format!("#!/usr/bin/env perl {}", flags)
};
vec![CodeAction {
title: "Use portable shebang (#!/usr/bin/env perl)".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec!["hardcoded-shebang".to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: 0, end: first_line.len() },
new_text: new_shebang,
}],
},
is_preferred: true,
}]
}
fn extract_shebang_flags(shebang_line: &str) -> String {
if let Some(perl_pos) = shebang_line.find("perl") {
let after_perl = &shebang_line[perl_pos + 4..];
let trimmed = after_perl.trim();
if trimmed.is_empty() { String::new() } else { trimmed.to_string() }
} else {
String::new()
}
}
pub fn fix_variable_shadowing(diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let mut actions = Vec::new();
if let Some(var_name) = diagnostic.message.split('\'').nth(1) {
let base_name =
var_name.trim_start_matches('$').trim_start_matches('@').trim_start_matches('%');
let suggestions = vec![
format!("{}_inner", base_name),
format!("{}_local", base_name),
format!("my_{}", base_name),
];
for suggestion in suggestions {
let new_name = if var_name.starts_with('$') {
format!("${}", suggestion)
} else if var_name.starts_with('@') {
format!("@{}", suggestion)
} else if var_name.starts_with('%') {
format!("%{}", suggestion)
} else {
suggestion.clone()
};
actions.push(CodeAction {
title: format!("Rename to '{}'", new_name),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::VariableShadowing.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation {
start: diagnostic.range.0,
end: diagnostic.range.1,
},
new_text: new_name,
}],
},
is_preferred: false,
});
}
}
actions
}
pub fn fix_bareword_filehandle(diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
let fh_name = diagnostic.message.split('\'').nth(1).unwrap_or("FH");
let lexical_name = format!("${}_fh", fh_name.to_lowercase());
vec![CodeAction {
title: format!("Replace bareword filehandle '{}' with lexical '{}'", fh_name, lexical_name),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::BarewordFilehandle.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.1 },
new_text: format!("my {}", lexical_name),
}],
},
is_preferred: true,
}]
}
pub fn fix_two_arg_open(diagnostic: &QuickFixDiagnostic) -> Vec<CodeAction> {
vec![CodeAction {
title: "Convert to three-argument open() for safety".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: vec![DiagnosticCode::TwoArgOpen.as_str().to_string()],
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: diagnostic.range.0, end: diagnostic.range.1 },
new_text: "open(my $fh, '<', $filename)".to_string(),
}],
},
is_preferred: true,
}]
}