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_server::ls_types::{
6    Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Uri,
7};
8
9use crate::analysis::diagnostics::PHP_LSP_SOURCE;
10use crate::document::ast::ParsedDoc;
11use crate::lang::config::DiagnosticsConfig;
12
13/// Run semantic checks on `doc` against the supplied `AnalysisSession`.
14/// Ingests the current file, runs Pass 2 via `FileAnalyzer`, and returns LSP
15/// diagnostics filtered by `DiagnosticsConfig`.
16///
17/// Used only by `benches/semantic.rs` to measure raw analyzer cost. It is
18/// not on the production request path and skips `DocumentStore`'s salsa
19/// caching, PSR-4 lazy-loading, and `collector_issues` diagnostics — for
20/// that, see `DocumentStore::get_semantic_issues_salsa` and
21/// `compute_open_file_diagnostics`.
22pub fn semantic_diagnostics(
23    uri: &Uri,
24    doc: &ParsedDoc,
25    session: &mir_analyzer::AnalysisSession,
26    cfg: &DiagnosticsConfig,
27) -> Vec<Diagnostic> {
28    if !cfg.enabled {
29        return vec![];
30    }
31    let file: std::sync::Arc<str> = std::sync::Arc::from(uri.as_str());
32    session.ingest_file(file.clone(), doc.source_arc());
33    let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
34    let owned_program = php_ast::owned::to_owned_program(doc.program());
35    let analyzer = mir_analyzer::FileAnalyzer::new(session);
36    let analysis = analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map);
37    let class_issues = session.class_issues(std::slice::from_ref(&file));
38    analysis
39        .issues
40        .into_iter()
41        .chain(class_issues)
42        .filter(|i| !i.suppressed)
43        .filter(|i| issue_passes_filter(i, cfg))
44        .map(to_lsp_diagnostic)
45        .collect()
46}
47
48/// Convert pre-computed raw issues (from `db::semantic::semantic_issues`) into
49/// LSP diagnostics, applying the user's `DiagnosticsConfig` filter. Keeping
50/// filter + conversion outside the salsa query preserves memoization across
51/// config toggles (the user flipping a category must not rerun the analyzer).
52///
53/// `ParseError` issues are always excluded here, regardless of which mir API
54/// produced `issues`: php-lsp already surfaces raw parse errors as
55/// `SyntaxError` diagnostics from its own parser pass (see
56/// `crate::analysis::diagnostics::diagnostics_from_doc`), so this is the one
57/// place every issue source funnels through before becoming a `Diagnostic`.
58pub fn issues_to_diagnostics(
59    issues: &[mir_issues::Issue],
60    _uri: &Uri,
61    cfg: &DiagnosticsConfig,
62) -> Vec<Diagnostic> {
63    if !cfg.enabled {
64        return vec![];
65    }
66    issues
67        .iter()
68        .filter(|i| !matches!(i.kind, mir_issues::IssueKind::ParseError { .. }))
69        .filter(|i| issue_passes_filter(i, cfg))
70        .cloned()
71        .map(to_lsp_diagnostic)
72        .collect()
73}
74
75/// Like [`issues_to_diagnostics`], but additionally holds back
76/// workspace-symbol-dependent issues (see [`issue_needs_workspace_index`])
77/// while `index_ready` is `false`. Skips the extra filter/clone pass
78/// entirely once ready — the common case, well past server startup — so
79/// this costs nothing in steady state.
80pub fn issues_to_diagnostics_gated(
81    issues: &[mir_issues::Issue],
82    uri: &Uri,
83    cfg: &DiagnosticsConfig,
84    index_ready: bool,
85) -> Vec<Diagnostic> {
86    if index_ready {
87        return issues_to_diagnostics(issues, uri, cfg);
88    }
89    let filtered: Vec<mir_issues::Issue> = issues
90        .iter()
91        .filter(|i| !issue_needs_workspace_index(&i.kind))
92        .cloned()
93        .collect();
94    issues_to_diagnostics(&filtered, uri, cfg)
95}
96
97/// Whether resolving this issue required looking up a symbol (class,
98/// function, method, or trait) by name against the workspace-wide symbol
99/// table, as opposed to something derivable from the file's own contents.
100///
101/// Before the initial workspace scan finishes, that table only reflects the
102/// subset of files indexed so far, so these specific kinds can misreport a
103/// symbol declared in a not-yet-scanned file as undefined — see issue #242.
104/// Everything else (return-type mismatches, unreachable code, duplicate
105/// declarations within a file, etc.) is safe to report immediately: it's
106/// checked against the file's own text and can't be invalidated by more of
107/// the workspace becoming known.
108pub fn issue_needs_workspace_index(kind: &mir_issues::IssueKind) -> bool {
109    use mir_issues::IssueKind;
110    matches!(
111        kind,
112        IssueKind::UndefinedFunction { .. }
113            | IssueKind::UndefinedMethod { .. }
114            | IssueKind::UndefinedClass { .. }
115            | IssueKind::UndefinedTrait { .. }
116    )
117}
118
119/// Returns `true` if the mir-analyzer issue is allowed through by the config.
120fn issue_passes_filter(issue: &mir_issues::Issue, cfg: &DiagnosticsConfig) -> bool {
121    use mir_issues::IssueKind;
122    match &issue.kind {
123        IssueKind::UndefinedVariable { .. } | IssueKind::PossiblyUndefinedVariable { .. } => {
124            cfg.undefined_variables
125        }
126        IssueKind::UndefinedFunction { .. } | IssueKind::UndefinedMethod { .. } => {
127            cfg.undefined_functions
128        }
129        IssueKind::UndefinedClass { .. } | IssueKind::UndefinedTrait { .. } => {
130            cfg.undefined_classes
131        }
132        IssueKind::InvalidTraitUse { .. } => cfg.type_errors,
133        IssueKind::TooFewArguments { .. }
134        | IssueKind::TooManyArguments { .. }
135        | IssueKind::InvalidPassByReference { .. }
136        | IssueKind::InvalidNamedArgument { .. } => cfg.arity_errors,
137        // InvalidArgument covers both arity errors and type mismatches in mir-analyzer;
138        // show it if either toggle is on.
139        IssueKind::InvalidArgument { .. } | IssueKind::PossiblyInvalidArgument { .. } => {
140            cfg.arity_errors || cfg.type_errors
141        }
142        IssueKind::InvalidReturnType { .. }
143        | IssueKind::NullMethodCall { .. }
144        | IssueKind::NullPropertyFetch { .. }
145        | IssueKind::NullArrayAccess
146        | IssueKind::NullArgument { .. }
147        | IssueKind::PossiblyNullMethodCall { .. }
148        | IssueKind::PossiblyNullPropertyFetch { .. }
149        | IssueKind::PossiblyNullArrayAccess
150        | IssueKind::PossiblyNullArgument { .. }
151        | IssueKind::NullableReturnStatement { .. }
152        | IssueKind::InvalidPropertyAssignment { .. }
153        | IssueKind::InvalidOperand { .. }
154        | IssueKind::InvalidCast { .. }
155        | IssueKind::AbstractInstantiation { .. }
156        | IssueKind::MixedClone
157        | IssueKind::ReadonlyPropertyAssignment { .. }
158        | IssueKind::ArgumentTypeCoercion { .. } => cfg.type_errors,
159        IssueKind::DeprecatedCall { .. }
160        | IssueKind::DeprecatedMethodCall { .. }
161        | IssueKind::DeprecatedMethod { .. }
162        | IssueKind::DeprecatedClass { .. } => cfg.deprecated_calls,
163        IssueKind::CircularInheritance { .. } => cfg.type_errors,
164        IssueKind::DuplicateClass { .. }
165        | IssueKind::DuplicateInterface { .. }
166        | IssueKind::DuplicateTrait { .. }
167        | IssueKind::DuplicateEnum { .. }
168        | IssueKind::DuplicateFunction { .. } => cfg.duplicate_declarations,
169        IssueKind::UnusedVariable { .. }
170        | IssueKind::UnusedParam { .. }
171        | IssueKind::UnusedMethod { .. }
172        | IssueKind::UnusedProperty { .. }
173        | IssueKind::UnusedFunction { .. } => cfg.unused_symbols,
174        IssueKind::MissingReturnType { .. }
175        | IssueKind::MissingParamType { .. }
176        | IssueKind::MissingPropertyType { .. } => cfg.missing_types,
177        IssueKind::MixedArgument { .. }
178        | IssueKind::MixedAssignment { .. }
179        | IssueKind::MixedMethodCall { .. }
180        | IssueKind::MixedPropertyFetch { .. }
181        | IssueKind::MixedPropertyAssignment { .. }
182        | IssueKind::MixedArrayAccess
183        | IssueKind::MixedArrayOffset
184        | IssueKind::MixedReturnStatement { .. } => cfg.mixed_usage,
185        IssueKind::DocblockTypeContradiction { .. }
186        | IssueKind::UnevaluatedCode { .. }
187        | IssueKind::IfThisIsMismatch { .. } => true,
188        _ => true,
189    }
190}
191
192fn to_lsp_diagnostic(issue: mir_issues::Issue) -> Diagnostic {
193    // mir uses 1-based lines; LSP uses 0-based. Columns are 0-based on both sides.
194    let line = issue.location.line.saturating_sub(1);
195    let col_start = issue.location.col_start as u32;
196    let col_end = issue.location.col_end as u32;
197    Diagnostic {
198        range: Range {
199            start: Position {
200                line,
201                character: col_start,
202            },
203            end: Position {
204                line,
205                character: col_end.max(col_start + 1),
206            },
207        },
208        severity: Some(match issue.severity {
209            mir_issues::Severity::Error => DiagnosticSeverity::ERROR,
210            mir_issues::Severity::Warning => DiagnosticSeverity::WARNING,
211            mir_issues::Severity::Info => DiagnosticSeverity::INFORMATION,
212        }),
213        code: Some(NumberOrString::String(issue.kind.name().to_string())),
214        source: Some(PHP_LSP_SOURCE.to_string()),
215        message: issue.kind.message(),
216        ..Default::default()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn to_lsp_diagnostic_sets_code_to_issue_kind_name() {
226        use mir_issues::{Issue, IssueKind, Location};
227        use std::sync::Arc;
228        use tower_lsp_server::ls_types::NumberOrString;
229
230        let location = Location {
231            file: Arc::from("file:///test.php"),
232            line: 1,
233            line_end: 1,
234            col_start: 0,
235            col_end: 3,
236        };
237        let issue = Issue::new(
238            IssueKind::UndefinedClass {
239                name: "Foo".to_string(),
240            },
241            location,
242        );
243        let diag = to_lsp_diagnostic(issue);
244        assert_eq!(
245            diag.code,
246            Some(NumberOrString::String("UndefinedClass".to_string())),
247            "diagnostic code must be the IssueKind name so code actions can match by type"
248        );
249        assert!(
250            diag.message.contains("Foo"),
251            "diagnostic message should mention the class name"
252        );
253    }
254}