Skip to main content

perl_lsp_diagnostics/
diagnostics.rs

1//! Diagnostics provider for Perl code
2//!
3//! This module provides the core diagnostic generation functionality.
4
5use std::path::Path;
6
7use perl_diagnostics_codes::DiagnosticCode;
8use perl_parser_core::Node;
9use perl_parser_core::error::ParseError;
10use perl_pragma::PragmaTracker;
11use perl_semantic_analyzer::scope_analyzer::ScopeAnalyzer;
12use perl_semantic_analyzer::symbol::SymbolExtractor;
13
14use crate::dedup::deduplicate_diagnostics;
15use crate::lints::common_mistakes::check_common_mistakes;
16use crate::lints::deprecated::check_deprecated_syntax;
17use crate::lints::package_subroutine::{
18    check_duplicate_package, check_duplicate_subroutine, check_missing_package_declaration,
19};
20use crate::lints::printf_format::check_printf_format;
21use crate::lints::security::check_security;
22use crate::lints::strict_warnings::check_strict_warnings;
23use crate::lints::unreachable_code::check_unreachable_code;
24use crate::lints::unused_imports::check_unused_imports;
25use crate::lints::version_compat::check_version_compat;
26use crate::scope::scope_issues_to_diagnostics;
27
28// Re-export diagnostic types from the shared SRP microcrate.
29pub use perl_lsp_diagnostic_types::{Diagnostic, DiagnosticSeverity, RelatedInformation};
30
31/// Diagnostics provider
32///
33/// Analyzes Perl source code and generates diagnostic messages for
34/// parse errors, scope issues, and lint warnings.
35pub struct DiagnosticsProvider {
36    _ast: std::sync::Arc<Node>,
37    _source: String,
38}
39
40impl DiagnosticsProvider {
41    /// Create a new diagnostics provider
42    pub fn new(ast: &std::sync::Arc<Node>, source: String) -> Self {
43        Self { _ast: ast.clone(), _source: source }
44    }
45
46    /// Generate diagnostics for the given AST
47    ///
48    /// Analyzes the AST and parse errors to produce a list of diagnostics
49    /// including parse errors, semantic issues, and lint warnings.
50    ///
51    /// `module_resolver` is an optional callback used by the missing-module lint
52    /// (PL701). When `Some`, it is called with a bare module name and should return
53    /// `true` if the module is resolvable (workspace or configured include paths).
54    /// When `None`, the missing-module lint is skipped entirely.
55    pub fn get_diagnostics(
56        &self,
57        ast: &std::sync::Arc<Node>,
58        parse_errors: &[ParseError],
59        source: &str,
60        module_resolver: Option<&dyn Fn(&str) -> bool>,
61    ) -> Vec<Diagnostic> {
62        self.get_diagnostics_with_path(ast, parse_errors, source, module_resolver, None)
63    }
64
65    /// Generate diagnostics for the given AST with optional source-path context.
66    pub fn get_diagnostics_with_path(
67        &self,
68        ast: &std::sync::Arc<Node>,
69        parse_errors: &[ParseError],
70        source: &str,
71        module_resolver: Option<&dyn Fn(&str) -> bool>,
72        source_path: Option<&Path>,
73    ) -> Vec<Diagnostic> {
74        let mut diagnostics = Vec::new();
75        let source_len = source.len();
76
77        // Convert parse errors to diagnostics
78        for error in parse_errors {
79            let (location, message) = match error {
80                ParseError::UnexpectedToken { location, expected, found } => {
81                    let found_display = format_found_token(found);
82                    let msg = build_enhanced_message(expected, found, &found_display);
83                    (*location, msg)
84                }
85                ParseError::SyntaxError { location, message } => (*location, message.clone()),
86                ParseError::UnexpectedEof => (source.len(), "Unexpected end of input".to_string()),
87                ParseError::LexerError { message } => (0, message.clone()),
88                _ => (0, error.to_string()),
89            };
90
91            let range_start = location.min(source_len);
92            let range_end = range_start.saturating_add(1).min(source_len.saturating_add(1));
93
94            let suggestion = build_parse_error_suggestion(error);
95
96            // Surface the suggestion as relatedInformation for IDE integration
97            let related_information = suggestion
98                .as_ref()
99                .map(|s| {
100                    vec![RelatedInformation {
101                        location: (range_start, range_end),
102                        message: format!("Suggestion: {s}"),
103                    }]
104                })
105                .unwrap_or_default();
106
107            let code = match error {
108                ParseError::UnexpectedEof => DiagnosticCode::UnexpectedEof,
109                ParseError::SyntaxError { .. } => DiagnosticCode::SyntaxError,
110                _ => DiagnosticCode::ParseError,
111            };
112
113            diagnostics.push(Diagnostic {
114                range: (range_start, range_end),
115                severity: DiagnosticSeverity::Error,
116                code: Some(code.as_str().to_string()),
117                message,
118                related_information,
119                tags: Vec::new(),
120                suggestion,
121            });
122        }
123
124        // Run scope analysis to detect undeclared/unused/shadowing issues
125        let pragma_map = PragmaTracker::build(ast);
126        let scope_analyzer = ScopeAnalyzer::new();
127        let scope_issues = scope_analyzer.analyze(ast, source, &pragma_map);
128        diagnostics.extend(scope_issues_to_diagnostics(scope_issues));
129
130        // Detect heredoc anti-patterns
131        let heredoc_diags = crate::heredoc_antipatterns::detect_heredoc_antipatterns(source);
132        diagnostics.extend(heredoc_diags);
133
134        // Run lint checks
135        check_strict_warnings(ast, &mut diagnostics);
136        check_deprecated_syntax(ast, &mut diagnostics);
137        let symbol_table = SymbolExtractor::new_with_source(source).extract(ast);
138        check_common_mistakes(ast, &symbol_table, &mut diagnostics);
139        check_printf_format(ast, &mut diagnostics);
140
141        // Package and subroutine diagnostics (PL200, PL201, PL300)
142        check_missing_package_declaration(ast, source, source_path, &mut diagnostics);
143        check_duplicate_package(ast, &mut diagnostics);
144        check_duplicate_subroutine(ast, &mut diagnostics);
145
146        // Security anti-pattern detection (string eval, two-arg open, backtick exec)
147        check_security(ast, &mut diagnostics);
148
149        // Unused import detection
150        check_unused_imports(ast, source, &mut diagnostics);
151
152        // Version compatibility lint (PL900)
153        check_version_compat(ast, &mut diagnostics);
154
155        // Unreachable code detection (PL406)
156        check_unreachable_code(ast, &mut diagnostics);
157
158        // Missing module lint (PL701) — only when a resolver is provided
159        if let Some(resolver) = module_resolver {
160            crate::lints::missing_module::check_missing_modules(
161                ast,
162                source,
163                resolver,
164                &mut diagnostics,
165            );
166        }
167
168        // Remove duplicate diagnostics before returning
169        deduplicate_diagnostics(&mut diagnostics);
170
171        diagnostics
172    }
173}
174
175fn format_found_token(found: &str) -> String {
176    if found.is_empty() || found == "<EOF>" {
177        "end of input".to_string()
178    } else {
179        format!("`{found}`")
180    }
181}
182
183/// Build an enhanced error message with Perl-specific context.
184fn build_enhanced_message(expected: &str, found: &str, found_display: &str) -> String {
185    let expected_lower = expected.to_lowercase();
186
187    // Missing semicolon
188    if expected.contains(';') || expected_lower.contains("semicolon") {
189        return format!("Missing semicolon after statement. Add `;` here (found {found_display})");
190    }
191
192    // Expected variable after my/our/local/state
193    if expected_lower.contains("variable") {
194        return format!(
195            "Expected a variable like `$foo`, `@bar`, or `%hash` here, found {found_display}"
196        );
197    }
198
199    // Unexpected closing delimiter -- possible mismatch
200    if found == "}" || found == ")" || found == "]" {
201        let opener = match found {
202            "}" => "{",
203            ")" => "(",
204            "]" => "[",
205            _ => "",
206        };
207        return format!(
208            "Unexpected `{found}` -- possible unmatched brace. \
209             Check the opening `{opener}` earlier in this scope"
210        );
211    }
212
213    // Default
214    format!("Expected {expected}, found {found_display}")
215}
216
217/// Build a contextual suggestion for a parse error based on the expected/found tokens.
218///
219/// Each suggestion is designed to be actionable: the user should be able to read
220/// the suggestion and know exactly what to change.
221fn build_parse_error_suggestion(error: &ParseError) -> Option<String> {
222    match error {
223        ParseError::UnexpectedToken { expected, found, .. } => {
224            // Missing semicolon: parser expected ';' or found something when ';' was expected
225            if expected.contains(';') || expected.contains("semicolon") {
226                return Some("Missing semicolon after statement. Add `;` here.".to_string());
227            }
228            // Found ';' when expecting something else often means missing expression
229            if found == ";" {
230                return Some(format!(
231                    "A {expected} is required here -- the statement appears incomplete"
232                ));
233            }
234            // Unexpected closing brace/paren
235            if found == "}" || found == ")" || found == "]" {
236                return Some(format!("Check for a missing {expected} before '{found}'"));
237            }
238            // Missing opening brace after sub/if/while/for
239            if expected.contains('{') || expected.contains("block") {
240                return Some(format!(
241                    "Add an opening '{{' to start the block (found {found})"
242                ));
243            }
244            // Missing closing paren in function call or condition
245            if expected.contains(')') {
246                return Some(
247                    "Add a closing ')' -- there may be an unmatched opening '('".to_string(),
248                );
249            }
250            // Missing closing bracket
251            if expected.contains(']') {
252                return Some(
253                    "Add a closing ']' -- there may be an unmatched opening '['".to_string(),
254                );
255            }
256            // Expected a variable (e.g. after my/our/local/state)
257            if expected.to_lowercase().contains("variable") {
258                return Some(
259                    "Expected a variable like `$foo`, `@bar`, or `%hash` after the declaration keyword".to_string(),
260                );
261            }
262            None
263        }
264        ParseError::UnexpectedEof => Some(
265            "The file ended unexpectedly -- check for unclosed delimiters or missing semicolons"
266                .to_string(),
267        ),
268        ParseError::UnclosedDelimiter { delimiter } => {
269            Some(format!("Add a matching closing '{delimiter}'"))
270        }
271        ParseError::SyntaxError { message, .. } => {
272            // Provide targeted suggestions for known syntax error patterns
273            let msg_lower = message.to_lowercase();
274            if msg_lower.contains("semicolon") || msg_lower.contains("missing ;") {
275                Some("Add a ';' at the end of the statement".to_string())
276            } else if msg_lower.contains("heredoc") {
277                Some(
278                    "Check that the heredoc terminator appears on its own line with no extra whitespace"
279                        .to_string(),
280                )
281            } else {
282                None
283            }
284        }
285        ParseError::LexerError { message } => {
286            let msg_lower = message.to_lowercase();
287            if msg_lower.contains("unterminated") || msg_lower.contains("unclosed") {
288                Some(
289                    "Check for an unclosed string, regex, or heredoc near this position"
290                        .to_string(),
291                )
292            } else if msg_lower.contains("invalid") && msg_lower.contains("character") {
293                Some(
294                    "Remove or replace the invalid character -- Perl source should be valid UTF-8 or the encoding declared with 'use utf8;'"
295                        .to_string(),
296                )
297            } else {
298                None
299            }
300        }
301        ParseError::RecursionLimit => Some(
302            "The code is too deeply nested -- consider refactoring into smaller subroutines"
303                .to_string(),
304        ),
305        ParseError::InvalidNumber { literal } => Some(format!(
306            "'{literal}' is not a valid number -- check for misplaced underscores or invalid digits"
307        )),
308        ParseError::InvalidString => Some(
309            "Check for a missing closing quote or an invalid escape sequence".to_string(),
310        ),
311        ParseError::InvalidRegex { .. } => Some(
312            "Check the regex pattern for unmatched delimiters, invalid quantifiers, or unescaped metacharacters"
313                .to_string(),
314        ),
315        ParseError::NestingTooDeep { .. } => Some(
316            "Reduce nesting depth by extracting inner logic into named subroutines".to_string(),
317        ),
318        ParseError::Cancelled => None,
319        // Recovered errors: the parser inserted a synthetic node and continued.
320        // No user-facing suggestion is needed — the partial AST is still usable.
321        ParseError::Recovered { .. } => None,
322    }
323}