1use perl_diagnostics_codes::DiagnosticCode;
13use perl_lsp_diagnostic_types::{Diagnostic, DiagnosticSeverity};
14use perl_parser_core::ast::{Node, NodeKind};
15
16use super::super::walker::walk_node;
17
18pub const CORE_MODULES: &[&str] = &[
24 "strict",
26 "warnings",
27 "utf8",
28 "feature",
29 "constant",
30 "lib",
31 "base",
32 "parent",
33 "Exporter",
34 "vars",
35 "subs",
36 "overload",
37 "overloading",
38 "integer",
39 "bigint",
40 "bignum",
41 "bigrat",
42 "bytes",
43 "charnames",
44 "encoding",
45 "locale",
46 "mro",
47 "open",
48 "ops",
49 "re",
50 "sigtrap",
51 "sort",
52 "threads",
53 "threads::shared",
54 "autodie",
55 "autouse",
56 "diagnostics",
57 "English",
58 "experimental",
59 "fields",
60 "filetest",
61 "if",
62 "less",
63 "POSIX",
65 "Carp",
66 "Scalar::Util",
67 "List::Util",
68 "File::Basename",
71 "File::Path",
72 "File::Spec",
73 "File::Spec::Functions",
74 "File::Temp",
75 "File::Copy",
76 "File::Find",
77 "Cwd",
78 "Data::Dumper",
79 "Storable",
80 "Encode",
81 "IO::File",
82 "IO::Handle",
83 "IO::Dir",
84 "IO::Pipe",
85 "IO::Select",
86 "IO::Socket",
87 "IO::Socket::INET",
88 "Fcntl",
89 "UNIVERSAL",
90 "FindBin",
91 "Getopt::Long",
92 "Getopt::Std",
93 "Time::HiRes",
94 "Time::Local",
95 "MIME::Base64",
96 "Digest::MD5",
97 "Digest::SHA",
98 "Socket",
99 "Sys::Hostname",
100 "NEXT",
101 "Tie::Handle",
102 "Tie::Hash",
103 "Tie::Scalar",
104 "Tie::StdHash",
105 "Tie::StdScalar",
106 "Tie::Array",
107 "Tie::StdArray",
108 "Attribute::Handlers",
109 "AutoLoader",
110 "B",
111 "CPAN",
112 "Config",
113 "DB",
114 "Devel::Peek",
115 "DynaLoader",
116 "Errno",
117 "ExtUtils::MakeMaker",
118 "Fatal",
119 "Hash::Util",
120 "I18N::LangTags",
121 "MIME::QuotedPrint",
122 "Math::BigFloat",
123 "Math::BigInt",
124 "Math::Complex",
125 "Math::Trig",
126 "Module::CoreList",
127 "Module::Load",
128 "Net::Ping",
129 "PerlIO",
130 "Safe",
131 "Term::ANSIColor",
132 "Term::Cap",
133 "Term::ReadLine",
134 "Test",
135 "Test::Builder",
136 "Test::Harness",
137 "Test::More",
138 "Test::Simple",
139 "Text::Abbrev",
140 "Text::Balanced",
141 "Text::ParseWords",
142 "Text::Tabs",
143 "Text::Wrap",
144 "Thread",
145 "Tie::File",
146 "Tie::Memoize",
147 "Tie::RefHash",
148 "Unicode::Collate",
149 "Unicode::Normalize",
150 "Unicode::UCD",
151 "XSLoader",
152 "attributes",
153 "deprecate",
154 "version",
155];
156
157pub fn check_missing_modules<F>(
177 node: &Node,
178 _source: &str,
179 resolver: F,
180 diagnostics: &mut Vec<Diagnostic>,
181) where
182 F: Fn(&str) -> bool,
183{
184 let mut use_statements: Vec<(String, usize, usize)> = Vec::new();
185
186 walk_node(node, &mut |n| {
187 if let NodeKind::Use { module, .. } = &n.kind {
188 use_statements.push((module.clone(), n.location.start, n.location.end));
189 }
190 });
191
192 for (raw_module, start, end) in &use_statements {
193 let module_str =
195 raw_module.split_once(' ').map(|(name, _)| name).unwrap_or(raw_module.as_str());
196
197 if module_str.is_empty() {
200 continue;
201 }
202
203 if module_str.chars().next().is_some_and(|c| c.is_ascii_digit() || c == 'v') {
205 continue;
206 }
207
208 if CORE_MODULES.contains(&module_str) {
210 continue;
211 }
212
213 if resolver(module_str) {
215 continue;
216 }
217
218 diagnostics.push(Diagnostic {
219 range: (*start, *end),
220 severity: DiagnosticSeverity::Warning,
221 code: Some(DiagnosticCode::ModuleNotFound.as_str().to_string()),
222 message: format!(
223 "Module '{}' not found in workspace or configured include paths",
224 module_str
225 ),
226 related_information: vec![],
227 tags: vec![],
228 suggestion: Some(format!("Install with: cpanm {} or add to cpanfile", module_str)),
229 });
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use perl_parser::Parser;
237 use perl_tdd_support::must;
238
239 fn resolver_never_finds(_: &str) -> bool {
240 false
241 }
242 fn resolver_always_finds(_: &str) -> bool {
243 true
244 }
245 fn resolver_finds_foo(m: &str) -> bool {
246 m == "Foo::Bar"
247 }
248
249 #[test]
250 fn missing_module_emits_pl701() {
251 let source = "use Missing::Module;\n";
252 let ast = must(Parser::new(source).parse());
253 let mut diags = vec![];
254 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
255 assert_eq!(diags.len(), 1);
256 assert_eq!(diags[0].code.as_deref(), Some("PL701"));
257 assert!(diags[0].message.contains("Missing::Module"));
258 }
259
260 #[test]
261 fn found_module_no_diagnostic() {
262 let source = "use Foo::Bar;\n";
263 let ast = must(Parser::new(source).parse());
264 let mut diags = vec![];
265 check_missing_modules(&ast, source, resolver_finds_foo, &mut diags);
266 assert!(diags.is_empty());
267 }
268
269 #[test]
270 fn version_only_use_not_flagged() {
271 for source in &["use 5.010;\n", "use v5.38;\n"] {
272 let ast = must(Parser::new(source).parse());
273 let mut diags = vec![];
274 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
275 assert!(diags.is_empty(), "version-only use should not be flagged: {}", source);
276 }
277 }
278
279 #[test]
280 fn core_modules_not_flagged() {
281 for module in
282 &["strict", "warnings", "Carp", "POSIX", "Scalar::Util", "FindBin", "File::Basename"]
283 {
284 let source = format!("use {};\n", module);
285 let ast = must(Parser::new(&source).parse());
286 let mut diags = vec![];
287 check_missing_modules(&ast, &source, resolver_never_finds, &mut diags);
288 assert!(diags.is_empty(), "core module {} should not be flagged", module);
289 }
290 }
291
292 #[test]
293 fn versioned_module_strips_version_before_lookup() {
294 let source = "use Foo::Bar 1.23;\n";
296 let ast = must(Parser::new(source).parse());
297 let mut diags = vec![];
298 check_missing_modules(&ast, source, resolver_finds_foo, &mut diags);
300 assert!(diags.is_empty(), "versioned use should strip version before resolver lookup");
301 }
302
303 #[test]
304 fn diagnostic_range_covers_use_statement() {
305 let source = "use Missing::Mod;\n";
306 let ast = must(Parser::new(source).parse());
307 let mut diags = vec![];
308 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
309 assert_eq!(diags.len(), 1);
310 let (start, end) = diags[0].range;
311 assert!(start < end, "range start must be before end");
312 assert!(end <= source.len(), "range end must be within source");
313 }
314
315 #[test]
316 fn resolver_always_finds_no_diagnostic() {
317 let source = "use Anything::AtAll;\n";
318 let ast = must(Parser::new(source).parse());
319 let mut diags = vec![];
320 check_missing_modules(&ast, source, resolver_always_finds, &mut diags);
321 assert!(diags.is_empty());
322 }
323
324 #[test]
325 fn multiple_missing_modules_emits_multiple_diagnostics() {
326 let source = "use Missing::One;\nuse Missing::Two;\n";
327 let ast = must(Parser::new(source).parse());
328 let mut diags = vec![];
329 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
330 assert_eq!(diags.len(), 2);
331 assert!(diags.iter().all(|d| d.code.as_deref() == Some("PL701")));
332 }
333
334 #[test]
335 fn mixed_present_and_missing_only_flags_missing() {
336 let source = "use Foo::Bar;\nuse Missing::One;\n";
337 let ast = must(Parser::new(source).parse());
338 let mut diags = vec![];
339 check_missing_modules(&ast, source, resolver_finds_foo, &mut diags);
340 assert_eq!(diags.len(), 1);
341 assert!(diags[0].message.contains("Missing::One"));
342 }
343
344 #[test]
345 fn severity_is_warning() {
346 let source = "use Missing::Module;\n";
347 let ast = must(Parser::new(source).parse());
348 let mut diags = vec![];
349 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
350 assert_eq!(diags.len(), 1);
351 assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
352 }
353
354 #[test]
359 fn use_if_conditional_not_flagged() {
360 let source = "use if $^O eq 'MSWin32', 'Win32';\n";
363 let ast = must(Parser::new(source).parse());
364 let mut diags = vec![];
365 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
366 assert!(
367 diags.is_empty(),
368 "`use if` conditional form must not emit PL701 (got {} diagnostics)",
369 diags.len()
370 );
371 }
372
373 #[test]
377 fn list_more_utils_is_not_core_and_fires_pl701() {
378 let source = "use List::MoreUtils qw(any all);\n";
379 let ast = must(Parser::new(source).parse());
380 let mut diags = vec![];
381 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
382 assert_eq!(
383 diags.len(),
384 1,
385 "List::MoreUtils is not a core module; PL701 should fire when the resolver cannot find it"
386 );
387 assert_eq!(diags[0].code.as_deref(), Some("PL701"));
388 assert!(diags[0].message.contains("List::MoreUtils"));
389 }
390
391 #[test]
394 fn resolver_called_multiple_times_is_stable() {
395 let source = "use A::B;\nuse C::D;\nuse E::F;\nuse G::H;\nuse I::J;\n";
396 let ast = must(Parser::new(source).parse());
397 let mut diags = vec![];
398 let call_count = std::cell::Cell::new(0u32);
399 check_missing_modules(
400 &ast,
401 source,
402 |_| {
403 call_count.set(call_count.get() + 1);
404 false
405 },
406 &mut diags,
407 );
408 assert_eq!(diags.len(), 5, "five distinct missing modules should each emit PL701");
409 assert_eq!(
410 call_count.get(),
411 5,
412 "resolver should be called exactly once per non-core module"
413 );
414 }
415
416 #[test]
419 fn empty_module_string_is_silently_skipped() {
420 use perl_parser_core::ast::{Node, NodeKind, SourceLocation};
421 let use_node = Node::new(
424 NodeKind::Use { module: String::new(), args: vec![], has_filter_risk: false },
425 SourceLocation { start: 0, end: 4 },
426 );
427 let program = Node::new(
428 NodeKind::Program { statements: vec![use_node] },
429 SourceLocation { start: 0, end: 4 },
430 );
431 let mut diags = vec![];
432 check_missing_modules(&program, "", resolver_never_finds, &mut diags);
433 assert!(
434 diags.is_empty(),
435 "empty module name from error-recovery must not emit PL701 (got {} diagnostics)",
436 diags.len()
437 );
438 }
439
440 #[test]
442 fn suggestion_contains_module_name() {
443 let source = "use Some::Package;\n";
444 let ast = must(Parser::new(source).parse());
445 let mut diags = vec![];
446 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
447 assert_eq!(diags.len(), 1);
448 let suggestion = diags[0].suggestion.as_deref().unwrap_or("");
449 assert!(
450 suggestion.contains("Some::Package"),
451 "suggestion should mention the module name; got: {suggestion:?}"
452 );
453 }
454
455 #[test]
458 fn broken_file_with_valid_use_still_emits_pl701() {
459 let source = "my $x = ;\nuse Missing::Mod;\n";
462 let output = Parser::new(source).parse_with_recovery();
463 let ast = output.ast;
464 let mut diags = vec![];
465 check_missing_modules(&ast, source, resolver_never_finds, &mut diags);
466 let pl701_count = diags.iter().filter(|d| d.code.as_deref() == Some("PL701")).count();
469 assert!(pl701_count <= 1, "at most one PL701 for one use statement (got {})", pl701_count);
470 }
471}