use crate::types::{CodeAction, CodeActionEdit, CodeActionKind};
use perl_lsp_import_management::{
collect_imports, find_imports_range, guess_module_for_function, sort_imports,
};
use perl_lsp_rename::TextEdit;
use perl_parser_core::ast::{Node, SourceLocation};
use super::helpers::Helpers;
pub fn add_missing_imports(ast: &Node, _source: &str, helpers: &Helpers<'_>) -> Option<CodeAction> {
let undefined = find_undefined_functions(ast);
if undefined.is_empty() {
return None;
}
let mut imports = Vec::new();
for func in &undefined {
if let Some(module) = guess_module_for_function(func) {
imports.push(format!("use {};", module));
}
}
if imports.is_empty() {
return None;
}
let insert_pos = helpers.find_import_insert_position();
Some(CodeAction {
title: "Add missing imports".to_string(),
kind: CodeActionKind::QuickFix,
diagnostics: Vec::new(),
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start: insert_pos, end: insert_pos },
new_text: format!("{}\n", imports.join("\n")),
}],
},
is_preferred: false,
})
}
pub fn organize_imports(_ast: &Node, source: &str, helpers: &Helpers<'_>) -> Option<CodeAction> {
let imports = collect_imports(helpers.lines());
if imports.len() <= 1 {
return None;
}
let organized = sort_imports(imports);
if let Some((start, end)) = find_imports_range(source, helpers.lines()) {
return Some(CodeAction {
title: "Organize imports".to_string(),
kind: CodeActionKind::SourceOrganizeImports,
diagnostics: Vec::new(),
edit: CodeActionEdit {
changes: vec![TextEdit {
location: SourceLocation { start, end },
new_text: organized.join("\n") + "\n",
}],
},
is_preferred: false,
});
}
None
}
pub fn find_undefined_functions(ast: &Node) -> Vec<String> {
use perl_builtins::builtin_signatures_phf::is_builtin;
use perl_lsp_import_management::guess_module_for_function;
use perl_parser_core::ast::NodeKind;
use std::collections::HashSet;
let mut function_calls: HashSet<String> = HashSet::new();
let mut defined_subs: HashSet<String> = HashSet::new();
let mut imported_names: HashSet<String> = HashSet::new();
let mut used_modules_no_args: HashSet<String> = HashSet::new();
fn walk(
node: &Node,
calls: &mut HashSet<String>,
defs: &mut HashSet<String>,
imports: &mut HashSet<String>,
bare_uses: &mut HashSet<String>,
) {
match &node.kind {
NodeKind::FunctionCall { name, .. } => {
if !name.contains("::") {
calls.insert(name.clone());
}
}
NodeKind::Subroutine { name: Some(n), .. } => {
defs.insert(n.clone());
}
NodeKind::Use { module, args, .. } => {
let is_module = module.chars().next().is_some_and(|c| c.is_ascii_uppercase());
if is_module {
if args.is_empty() {
bare_uses.insert(module.clone());
} else {
for arg in args {
let first_byte = arg.as_bytes().first().copied().unwrap_or(0);
if first_byte.is_ascii_digit() {
continue;
}
if arg.starts_with('-') {
continue;
}
if arg.starts_with('{') {
continue;
}
if arg.starts_with("qw") {
let content = arg
.trim_start_matches("qw")
.trim_start_matches(|c: char| "([{/<|!".contains(c))
.trim_end_matches(|c: char| ")]}/|!>".contains(c));
for word in content.split_whitespace() {
if !word.is_empty() {
imports.insert(word.to_string());
}
}
} else {
let cleaned = arg.trim_matches(|c: char| c == '\'' || c == '"');
if !cleaned.is_empty() {
imports.insert(cleaned.to_string());
}
}
}
}
}
if module == "constant"
&& let Some(first_arg) = args.first()
{
let cleaned = first_arg.trim_matches(|c: char| c == '\'' || c == '"');
if !cleaned.is_empty()
&& cleaned
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase() || c == '_')
{
defs.insert(cleaned.to_string());
}
}
}
_ => {}
}
node.for_each_child(|child| walk(child, calls, defs, imports, bare_uses));
}
walk(
ast,
&mut function_calls,
&mut defined_subs,
&mut imported_names,
&mut used_modules_no_args,
);
function_calls
.into_iter()
.filter(|name| {
if defined_subs.contains(name) {
return false;
}
if imported_names.contains(name) {
return false;
}
if is_builtin(name) {
return false;
}
if let Some(guessed_module) = guess_module_for_function(name)
&& used_modules_no_args.contains(&guessed_module)
{
return false;
}
true
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use perl_parser_core::Parser;
use perl_tdd_support::must;
#[test]
fn undefined_function_detected() {
let source = "use strict;\nmy $data = decode_json($text);";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(
undef.contains(&"decode_json".to_string()),
"Expected decode_json to be flagged as undefined, got: {:?}",
undef
);
}
#[test]
fn builtin_not_flagged() {
let source = "print 'hello';\nchomp $line;";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(undef.is_empty(), "builtins should not be flagged: {:?}", undef);
}
#[test]
fn defined_sub_not_flagged() {
let source = "sub greet { print 'hi'; }\ngreet();";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(
!undef.contains(&"greet".to_string()),
"defined sub should not be flagged: {:?}",
undef
);
}
#[test]
fn explicitly_imported_function_not_flagged() {
let source = "use JSON qw(decode_json);\nmy $d = decode_json($t);";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(
!undef.contains(&"decode_json".to_string()),
"explicitly imported function should not be flagged: {:?}",
undef
);
}
#[test]
fn bare_use_module_conservative() {
let source = "use JSON;\nmy $d = decode_json($t);";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(
!undef.contains(&"decode_json".to_string()),
"functions from bare-used modules should not be flagged: {:?}",
undef
);
}
#[test]
fn method_call_not_flagged() {
let source = "my $obj = Foo->new();\n$obj->process();";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(undef.is_empty(), "method calls should not be flagged: {:?}", undef);
}
#[test]
fn qualified_call_not_flagged() {
let source = "File::Path::mkpath('/tmp/foo');";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(undef.is_empty(), "qualified calls should not be flagged: {:?}", undef);
}
#[test]
fn empty_source_returns_empty() {
let source = "";
let mut parser = Parser::new(source);
let ast = must(parser.parse());
let undef = find_undefined_functions(&ast);
assert!(undef.is_empty());
}
}