Skip to main content

perl_lsp_diagnostics/lints/
missing_module.rs

1//! Missing module detection lint
2//!
3//! Detects `use Module` statements where the module cannot be resolved
4//! in the workspace or configured include paths.
5//!
6//! # Diagnostic codes
7//!
8//! | Code  | Severity | Description                        |
9//! |-------|----------|------------------------------------|
10//! | PL701 | Warning  | Module not found in include paths  |
11
12use 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
18/// Perl core modules that ship with every Perl installation.
19///
20/// This list prevents false positives when `use_system_inc` is false.
21/// Conservative (under-includes) — a missed detection is better than
22/// a false positive that erodes diagnostic trust.
23pub const CORE_MODULES: &[&str] = &[
24    // Pragmas (no-network, no-filesystem)
25    "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    // Core stdlib — compiled into Perl or always available
64    "POSIX",
65    "Carp",
66    "Scalar::Util",
67    "List::Util",
68    // Note: List::MoreUtils is NOT a core module (it is a CPAN distribution).
69    // It intentionally does NOT appear here so that missing installations are detected.
70    "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
157/// Check for use statements whose modules cannot be resolved.
158///
159/// Walks the AST to collect all `use Module` statements. For each non-pragma,
160/// non-digit, non-core module, attempts to resolve via the provided resolver.
161/// Emits PL701 Warning if resolution returns `false`.
162///
163/// # Arguments
164///
165/// * `node` — Root AST node to walk
166/// * `source` — Source text (used for context; not searched directly here)
167/// * `resolver` — Callback: `fn(module_name: &str) -> bool`. Return `true` if
168///   the module is found (workspace or configured include paths).
169/// * `diagnostics` — Output vector; new diagnostics are pushed here
170///
171/// # Skipped inputs
172///
173/// - Version-only `use` statements: `use 5.010;` `use v5.38;`
174/// - All entries in `CORE_MODULES`
175/// - `use if` form (module field is "if"; treated as pragma)
176pub 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        // Strip embedded version — "Foo::Bar 1.23" → "Foo::Bar"
194        let module_str =
195            raw_module.split_once(' ').map(|(name, _)| name).unwrap_or(raw_module.as_str());
196
197        // Skip empty module names — these come from parser error-recovery nodes
198        // (NodeKind::Use { module: String::new(), .. }) and would produce false positives.
199        if module_str.is_empty() {
200            continue;
201        }
202
203        // Skip version-only use: `use 5.010;` or `use v5.38;`
204        if module_str.chars().next().is_some_and(|c| c.is_ascii_digit() || c == 'v') {
205            continue;
206        }
207
208        // Skip core modules (prevents false positives when system @INC is disabled)
209        if CORE_MODULES.contains(&module_str) {
210            continue;
211        }
212
213        // Skip if the resolver finds the module
214        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        // "use Foo::Bar 1.23;" — should resolve "Foo::Bar", not "Foo::Bar 1.23"
295        let source = "use Foo::Bar 1.23;\n";
296        let ast = must(Parser::new(source).parse());
297        let mut diags = vec![];
298        // resolver_finds_foo only returns true for "Foo::Bar" (bare, no version)
299        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    // --- edge cases ---
355
356    /// `use if COND, 'Module'` stores module="if" in the AST.
357    /// "if" is in CORE_MODULES so it must never emit PL701.
358    #[test]
359    fn use_if_conditional_not_flagged() {
360        // The parser stores module = "if" for the `use if` form.
361        // CORE_MODULES contains "if", so no diagnostic should fire.
362        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    /// `List::MoreUtils` is a CPAN module, not a Perl core module.
374    /// It must NOT be silently skipped — PL701 should fire when the resolver
375    /// cannot find it.
376    #[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    /// Resolver returning `false` never causes a panic or double-borrow even when
392    /// called many times in one pass (validates the closure is re-entrant safe).
393    #[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    /// An empty module string comes from parser error-recovery nodes.
417    /// It must be silently skipped — no PL701 and no panic.
418    #[test]
419    fn empty_module_string_is_silently_skipped() {
420        use perl_parser_core::ast::{Node, NodeKind, SourceLocation};
421        // Construct a Program node wrapping a Use node with an empty module name.
422        // This simulates what the parser emits during error recovery.
423        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    /// Suggestion text must contain the module name so the user knows what to install.
441    #[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    /// File with a syntax error followed by a valid `use` — the lint should still
456    /// fire on the missing module, not crash on the partial AST.
457    #[test]
458    fn broken_file_with_valid_use_still_emits_pl701() {
459        // parse_with_recovery tolerates syntax errors; the Use node for Missing::Mod
460        // should still be present and trigger PL701.
461        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        // Must not panic; if the Use node was recovered, we get PL701.
467        // If recovery omitted it entirely we get 0. Either is acceptable — but not a panic.
468        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}