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