Skip to main content

php_lsp/analysis/
semantic_diagnostics.rs

1/// Semantic diagnostics bridge.
2///
3/// Delegates all analysis to the `mir-analyzer` crate and converts its `Issue`
4/// type into the `tower-lsp` `Diagnostic` type expected by the LSP backend.
5use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Url};
6
7use crate::analysis::diagnostics::PHP_LSP_SOURCE;
8use crate::ast::ParsedDoc;
9use crate::config::DiagnosticsConfig;
10
11/// Run semantic checks on `doc` against the supplied `AnalysisSession`.
12///
13/// Replaces the legacy MirDb-mutating path (pre mir 0.22). The session owns
14/// the workspace MirDb internally; this function ingests the current file,
15/// runs Pass 2 via `FileAnalyzer`, and returns LSP diagnostics filtered by
16/// `DiagnosticsConfig`.
17pub fn semantic_diagnostics(
18    uri: &Url,
19    doc: &ParsedDoc,
20    session: &mir_analyzer::AnalysisSession,
21    cfg: &DiagnosticsConfig,
22) -> Vec<Diagnostic> {
23    if !cfg.enabled {
24        return vec![];
25    }
26    let file: std::sync::Arc<str> = std::sync::Arc::from(uri.as_str());
27    session.ingest_file(file.clone(), doc.source_arc());
28    let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
29    let owned_program = php_ast::owned::to_owned_program(doc.program());
30    let analyzer = mir_analyzer::FileAnalyzer::new(session);
31    let analysis = analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map);
32    let class_issues = session.class_issues(std::slice::from_ref(&file));
33    analysis
34        .issues
35        .into_iter()
36        .chain(class_issues)
37        .filter(|i| !i.suppressed)
38        .filter(|i| issue_passes_filter(i, cfg))
39        .map(to_lsp_diagnostic)
40        .collect()
41}
42
43/// Convert pre-computed raw issues (from `db::semantic::semantic_issues`) into
44/// LSP diagnostics, applying the user's `DiagnosticsConfig` filter. Keeping
45/// filter + conversion outside the salsa query preserves memoization across
46/// config toggles (the user flipping a category must not rerun the analyzer).
47pub fn issues_to_diagnostics(
48    issues: &[mir_issues::Issue],
49    _uri: &Url,
50    cfg: &DiagnosticsConfig,
51) -> Vec<Diagnostic> {
52    if !cfg.enabled {
53        return vec![];
54    }
55    issues
56        .iter()
57        .filter(|i| issue_passes_filter(i, cfg))
58        .cloned()
59        .map(to_lsp_diagnostic)
60        .collect()
61}
62
63/// Returns `true` if the mir-analyzer issue is allowed through by the config.
64fn issue_passes_filter(issue: &mir_issues::Issue, cfg: &DiagnosticsConfig) -> bool {
65    use mir_issues::IssueKind;
66    match &issue.kind {
67        IssueKind::UndefinedVariable { .. } | IssueKind::PossiblyUndefinedVariable { .. } => {
68            cfg.undefined_variables
69        }
70        IssueKind::UndefinedFunction { .. } | IssueKind::UndefinedMethod { .. } => {
71            cfg.undefined_functions
72        }
73        IssueKind::UndefinedClass { .. } | IssueKind::UndefinedTrait { .. } => {
74            cfg.undefined_classes
75        }
76        IssueKind::InvalidTraitUse { .. } => cfg.type_errors,
77        IssueKind::TooFewArguments { .. }
78        | IssueKind::TooManyArguments { .. }
79        | IssueKind::InvalidPassByReference { .. }
80        | IssueKind::InvalidNamedArgument { .. } => cfg.arity_errors,
81        // InvalidArgument covers both arity errors and type mismatches in mir-analyzer;
82        // show it if either toggle is on.
83        IssueKind::InvalidArgument { .. } | IssueKind::PossiblyInvalidArgument { .. } => {
84            cfg.arity_errors || cfg.type_errors
85        }
86        IssueKind::InvalidReturnType { .. }
87        | IssueKind::NullMethodCall { .. }
88        | IssueKind::NullPropertyFetch { .. }
89        | IssueKind::NullArrayAccess
90        | IssueKind::NullArgument { .. }
91        | IssueKind::PossiblyNullMethodCall { .. }
92        | IssueKind::PossiblyNullPropertyFetch { .. }
93        | IssueKind::PossiblyNullArrayAccess
94        | IssueKind::PossiblyNullArgument { .. }
95        | IssueKind::NullableReturnStatement { .. }
96        | IssueKind::InvalidPropertyAssignment { .. }
97        | IssueKind::InvalidOperand { .. }
98        | IssueKind::InvalidCast { .. }
99        | IssueKind::AbstractInstantiation { .. }
100        | IssueKind::MixedClone => cfg.type_errors,
101        IssueKind::DeprecatedCall { .. }
102        | IssueKind::DeprecatedMethodCall { .. }
103        | IssueKind::DeprecatedMethod { .. }
104        | IssueKind::DeprecatedClass { .. } => cfg.deprecated_calls,
105        IssueKind::CircularInheritance { .. } => cfg.type_errors,
106        IssueKind::DuplicateClass { .. }
107        | IssueKind::DuplicateInterface { .. }
108        | IssueKind::DuplicateTrait { .. }
109        | IssueKind::DuplicateEnum { .. }
110        | IssueKind::DuplicateFunction { .. } => cfg.duplicate_declarations,
111        // mir 0.22 unused-symbol warnings. Off by default; opt in via
112        // `diagnostics.unusedSymbols` in initializationOptions.
113        IssueKind::UnusedVariable { .. }
114        | IssueKind::UnusedParam { .. }
115        | IssueKind::UnusedMethod { .. }
116        | IssueKind::UnusedProperty { .. }
117        | IssueKind::UnusedFunction { .. } => cfg.unused_symbols,
118        // mir 0.36 missing-type-annotation lints. Off by default; opt in via
119        // `diagnostics.missingTypes`.
120        IssueKind::MissingReturnType { .. }
121        | IssueKind::MissingParamType { .. }
122        | IssueKind::MissingPropertyType { .. } => cfg.missing_types,
123        // mir 0.36 mixed-type usage lints. Off by default; opt in via
124        // `diagnostics.mixedUsage`.
125        IssueKind::MixedArgument { .. }
126        | IssueKind::MixedAssignment { .. }
127        | IssueKind::MixedMethodCall { .. }
128        | IssueKind::MixedPropertyFetch { .. }
129        | IssueKind::MixedPropertyAssignment { .. }
130        | IssueKind::MixedArrayAccess
131        | IssueKind::MixedArrayOffset => cfg.mixed_usage,
132        _ => true,
133    }
134}
135
136/// Returns true for issue kinds whose location was stored by the collector
137/// (0-indexed columns). Body-analysis issues use `offset_to_line_col` which
138/// is 1-indexed since mir 0.29; collector-stored locations were not changed.
139fn uses_codebase_location(kind: &mir_issues::IssueKind) -> bool {
140    use mir_issues::IssueKind;
141    matches!(
142        kind,
143        IssueKind::CircularInheritance { .. }
144            | IssueKind::InvalidExtendClass { .. }
145            | IssueKind::UnimplementedAbstractMethod { .. }
146            | IssueKind::UnimplementedInterfaceMethod { .. }
147            | IssueKind::FinalMethodOverridden { .. }
148            | IssueKind::OverriddenMethodAccess { .. }
149            | IssueKind::MethodSignatureMismatch { .. }
150            | IssueKind::InvalidTraitUse { .. }
151    )
152}
153
154fn to_lsp_diagnostic(issue: mir_issues::Issue) -> Diagnostic {
155    // mir 0.29+ uses 1-based lines everywhere; LSP uses 0-based.
156    // Columns: body-analysis uses 1-indexed offset_to_line_col; collector-
157    // stored locations (class/trait declarations) remain 0-indexed.
158    let line = issue.location.line.saturating_sub(1);
159    let (col_start, col_end) = if uses_codebase_location(&issue.kind) {
160        (
161            issue.location.col_start as u32,
162            issue.location.col_end as u32,
163        )
164    } else {
165        (
166            issue.location.col_start.saturating_sub(1) as u32,
167            issue.location.col_end.saturating_sub(1) as u32,
168        )
169    };
170    Diagnostic {
171        range: Range {
172            start: Position {
173                line,
174                character: col_start,
175            },
176            end: Position {
177                line,
178                character: col_end.max(col_start + 1),
179            },
180        },
181        severity: Some(match issue.severity {
182            mir_issues::Severity::Error => DiagnosticSeverity::ERROR,
183            mir_issues::Severity::Warning => DiagnosticSeverity::WARNING,
184            mir_issues::Severity::Info => DiagnosticSeverity::INFORMATION,
185        }),
186        code: Some(NumberOrString::String(issue.kind.name().to_string())),
187        source: Some(PHP_LSP_SOURCE.to_string()),
188        message: issue.kind.message(),
189        ..Default::default()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn to_lsp_diagnostic_sets_code_to_issue_kind_name() {
199        use mir_issues::{Issue, IssueKind, Location};
200        use std::sync::Arc;
201        use tower_lsp::lsp_types::NumberOrString;
202
203        let location = Location {
204            file: Arc::from("file:///test.php"),
205            line: 1,
206            line_end: 1,
207            col_start: 0,
208            col_end: 3,
209        };
210        let issue = Issue::new(
211            IssueKind::UndefinedClass {
212                name: "Foo".to_string(),
213            },
214            location,
215        );
216        let diag = to_lsp_diagnostic(issue);
217        assert_eq!(
218            diag.code,
219            Some(NumberOrString::String("UndefinedClass".to_string())),
220            "diagnostic code must be the IssueKind name so code actions can match by type"
221        );
222        assert!(
223            diag.message.contains("Foo"),
224            "diagnostic message should mention the class name"
225        );
226    }
227}