use perl_diagnostics_codes::DiagnosticCode;
use perl_lsp_diagnostic_types::{Diagnostic, DiagnosticSeverity};
use perl_parser_core::ast::{Node, NodeKind};
use super::super::walker::walk_node;
pub const CORE_MODULES: &[&str] = &[
"strict",
"warnings",
"utf8",
"feature",
"constant",
"lib",
"base",
"parent",
"Exporter",
"vars",
"subs",
"overload",
"overloading",
"integer",
"bigint",
"bignum",
"bigrat",
"bytes",
"charnames",
"encoding",
"locale",
"mro",
"open",
"ops",
"re",
"sigtrap",
"sort",
"threads",
"threads::shared",
"autodie",
"autouse",
"diagnostics",
"English",
"experimental",
"fields",
"filetest",
"if",
"less",
"POSIX",
"Carp",
"Scalar::Util",
"List::Util",
"File::Basename",
"File::Path",
"File::Spec",
"File::Spec::Functions",
"File::Temp",
"File::Copy",
"File::Find",
"Cwd",
"Data::Dumper",
"Storable",
"Encode",
"IO::File",
"IO::Handle",
"IO::Dir",
"IO::Pipe",
"IO::Select",
"IO::Socket",
"IO::Socket::INET",
"Fcntl",
"UNIVERSAL",
"FindBin",
"Getopt::Long",
"Getopt::Std",
"Time::HiRes",
"Time::Local",
"MIME::Base64",
"Digest::MD5",
"Digest::SHA",
"Socket",
"Sys::Hostname",
"NEXT",
"Tie::Handle",
"Tie::Hash",
"Tie::Scalar",
"Tie::StdHash",
"Tie::StdScalar",
"Tie::Array",
"Tie::StdArray",
"Attribute::Handlers",
"AutoLoader",
"B",
"CPAN",
"Config",
"DB",
"Devel::Peek",
"DynaLoader",
"Errno",
"ExtUtils::MakeMaker",
"Fatal",
"Hash::Util",
"I18N::LangTags",
"MIME::QuotedPrint",
"Math::BigFloat",
"Math::BigInt",
"Math::Complex",
"Math::Trig",
"Module::CoreList",
"Module::Load",
"Net::Ping",
"PerlIO",
"Safe",
"Term::ANSIColor",
"Term::Cap",
"Term::ReadLine",
"Test",
"Test::Builder",
"Test::Harness",
"Test::More",
"Test::Simple",
"Text::Abbrev",
"Text::Balanced",
"Text::ParseWords",
"Text::Tabs",
"Text::Wrap",
"Thread",
"Tie::File",
"Tie::Memoize",
"Tie::RefHash",
"Unicode::Collate",
"Unicode::Normalize",
"Unicode::UCD",
"XSLoader",
"attributes",
"deprecate",
"version",
];
pub fn check_missing_modules<F>(
node: &Node,
_source: &str,
resolver: F,
diagnostics: &mut Vec<Diagnostic>,
) where
F: Fn(&str) -> bool,
{
let mut use_statements: Vec<(String, usize, usize)> = Vec::new();
walk_node(node, &mut |n| {
if let NodeKind::Use { module, .. } = &n.kind {
use_statements.push((module.clone(), n.location.start, n.location.end));
}
});
for (raw_module, start, end) in &use_statements {
let module_str =
raw_module.split_once(' ').map(|(name, _)| name).unwrap_or(raw_module.as_str());
if module_str.is_empty() {
continue;
}
if module_str.chars().next().is_some_and(|c| c.is_ascii_digit() || c == 'v') {
continue;
}
if CORE_MODULES.contains(&module_str) {
continue;
}
if resolver(module_str) {
continue;
}
diagnostics.push(Diagnostic {
range: (*start, *end),
severity: DiagnosticSeverity::Warning,
code: Some(DiagnosticCode::ModuleNotFound.as_str().to_string()),
message: format!(
"Module '{}' not found in workspace or configured include paths",
module_str
),
related_information: vec![],
tags: vec![],
suggestion: Some(format!("Install with: cpanm {} or add to cpanfile", module_str)),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use perl_parser::Parser;
use perl_tdd_support::must;
fn resolver_never_finds(_: &str) -> bool {
false
}
fn resolver_always_finds(_: &str) -> bool {
true
}
fn resolver_finds_foo(m: &str) -> bool {
m == "Foo::Bar"
}
#[test]
fn missing_module_emits_pl701() {
let source = "use Missing::Module;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code.as_deref(), Some("PL701"));
assert!(diags[0].message.contains("Missing::Module"));
}
#[test]
fn found_module_no_diagnostic() {
let source = "use Foo::Bar;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_finds_foo, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn version_only_use_not_flagged() {
for source in &["use 5.010;\n", "use v5.38;\n"] {
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert!(diags.is_empty(), "version-only use should not be flagged: {}", source);
}
}
#[test]
fn core_modules_not_flagged() {
for module in
&["strict", "warnings", "Carp", "POSIX", "Scalar::Util", "FindBin", "File::Basename"]
{
let source = format!("use {};\n", module);
let ast = must(Parser::new(&source).parse());
let mut diags = vec![];
check_missing_modules(&ast, &source, resolver_never_finds, &mut diags);
assert!(diags.is_empty(), "core module {} should not be flagged", module);
}
}
#[test]
fn versioned_module_strips_version_before_lookup() {
let source = "use Foo::Bar 1.23;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_finds_foo, &mut diags);
assert!(diags.is_empty(), "versioned use should strip version before resolver lookup");
}
#[test]
fn diagnostic_range_covers_use_statement() {
let source = "use Missing::Mod;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert_eq!(diags.len(), 1);
let (start, end) = diags[0].range;
assert!(start < end, "range start must be before end");
assert!(end <= source.len(), "range end must be within source");
}
#[test]
fn resolver_always_finds_no_diagnostic() {
let source = "use Anything::AtAll;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_always_finds, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn multiple_missing_modules_emits_multiple_diagnostics() {
let source = "use Missing::One;\nuse Missing::Two;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert_eq!(diags.len(), 2);
assert!(diags.iter().all(|d| d.code.as_deref() == Some("PL701")));
}
#[test]
fn mixed_present_and_missing_only_flags_missing() {
let source = "use Foo::Bar;\nuse Missing::One;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_finds_foo, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("Missing::One"));
}
#[test]
fn severity_is_warning() {
let source = "use Missing::Module;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
}
#[test]
fn use_if_conditional_not_flagged() {
let source = "use if $^O eq 'MSWin32', 'Win32';\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert!(
diags.is_empty(),
"`use if` conditional form must not emit PL701 (got {} diagnostics)",
diags.len()
);
}
#[test]
fn list_more_utils_is_not_core_and_fires_pl701() {
let source = "use List::MoreUtils qw(any all);\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert_eq!(
diags.len(),
1,
"List::MoreUtils is not a core module; PL701 should fire when the resolver cannot find it"
);
assert_eq!(diags[0].code.as_deref(), Some("PL701"));
assert!(diags[0].message.contains("List::MoreUtils"));
}
#[test]
fn resolver_called_multiple_times_is_stable() {
let source = "use A::B;\nuse C::D;\nuse E::F;\nuse G::H;\nuse I::J;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
let call_count = std::cell::Cell::new(0u32);
check_missing_modules(
&ast,
source,
|_| {
call_count.set(call_count.get() + 1);
false
},
&mut diags,
);
assert_eq!(diags.len(), 5, "five distinct missing modules should each emit PL701");
assert_eq!(
call_count.get(),
5,
"resolver should be called exactly once per non-core module"
);
}
#[test]
fn empty_module_string_is_silently_skipped() {
use perl_parser_core::ast::{Node, NodeKind, SourceLocation};
let use_node = Node::new(
NodeKind::Use { module: String::new(), args: vec![], has_filter_risk: false },
SourceLocation { start: 0, end: 4 },
);
let program = Node::new(
NodeKind::Program { statements: vec![use_node] },
SourceLocation { start: 0, end: 4 },
);
let mut diags = vec![];
check_missing_modules(&program, "", resolver_never_finds, &mut diags);
assert!(
diags.is_empty(),
"empty module name from error-recovery must not emit PL701 (got {} diagnostics)",
diags.len()
);
}
#[test]
fn suggestion_contains_module_name() {
let source = "use Some::Package;\n";
let ast = must(Parser::new(source).parse());
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
assert_eq!(diags.len(), 1);
let suggestion = diags[0].suggestion.as_deref().unwrap_or("");
assert!(
suggestion.contains("Some::Package"),
"suggestion should mention the module name; got: {suggestion:?}"
);
}
#[test]
fn broken_file_with_valid_use_still_emits_pl701() {
let source = "my $x = ;\nuse Missing::Mod;\n";
let output = Parser::new(source).parse_with_recovery();
let ast = output.ast;
let mut diags = vec![];
check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
let pl701_count = diags.iter().filter(|d| d.code.as_deref() == Some("PL701")).count();
assert!(pl701_count <= 1, "at most one PL701 for one use statement (got {})", pl701_count);
}
}