Skip to main content

php_lsp/
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 php_ast::StmtKind;
6use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Url};
7
8use crate::ast::{ParsedDoc, SourceView};
9use crate::config::DiagnosticsConfig;
10use crate::diagnostics::PHP_LSP_SOURCE;
11
12/// Run semantic checks on `doc` against the supplied `AnalysisSession`.
13///
14/// Replaces the legacy MirDb-mutating path (pre mir 0.22). The session owns
15/// the workspace MirDb internally; this function ingests the current file,
16/// runs Pass 2 via `FileAnalyzer`, and returns LSP diagnostics filtered by
17/// `DiagnosticsConfig`.
18pub fn semantic_diagnostics(
19    uri: &Url,
20    doc: &ParsedDoc,
21    session: &mir_analyzer::AnalysisSession,
22    cfg: &DiagnosticsConfig,
23) -> Vec<Diagnostic> {
24    if !cfg.enabled {
25        return vec![];
26    }
27    let file: std::sync::Arc<str> = std::sync::Arc::from(uri.as_str());
28    session.ingest_file(file.clone(), doc.source_arc());
29    let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
30    let analyzer = mir_analyzer::FileAnalyzer::new(session);
31    let analysis = analyzer.analyze(file.clone(), doc.source(), doc.program(), &source_map);
32    let class_issues = session.class_issues_for(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(|i| to_lsp_diagnostic(i, uri))
40        .collect()
41}
42
43/// Backward-compat alias kept for benchmarks. mir 0.22's session-based API
44/// no longer distinguishes "rebuild" vs "no-rebuild"; the session always
45/// updates incrementally via `ingest_file`.
46pub fn semantic_diagnostics_no_rebuild(
47    uri: &Url,
48    doc: &ParsedDoc,
49    session: &mir_analyzer::AnalysisSession,
50    cfg: &DiagnosticsConfig,
51) -> Vec<Diagnostic> {
52    semantic_diagnostics(uri, doc, session, cfg)
53}
54
55/// Convert pre-computed raw issues (from `db::semantic::semantic_issues`) into
56/// LSP diagnostics, applying the user's `DiagnosticsConfig` filter. Keeping
57/// filter + conversion outside the salsa query preserves memoization across
58/// config toggles (the user flipping a category must not rerun the analyzer).
59pub fn issues_to_diagnostics(
60    issues: &[mir_issues::Issue],
61    uri: &Url,
62    cfg: &DiagnosticsConfig,
63) -> Vec<Diagnostic> {
64    if !cfg.enabled {
65        return vec![];
66    }
67    issues
68        .iter()
69        .filter(|i| issue_passes_filter(i, cfg))
70        .cloned()
71        .map(|i| to_lsp_diagnostic(i, uri))
72        .collect()
73}
74
75/// Returns `true` if the mir-analyzer issue is allowed through by the config.
76fn issue_passes_filter(issue: &mir_issues::Issue, cfg: &DiagnosticsConfig) -> bool {
77    use mir_issues::IssueKind;
78    match &issue.kind {
79        IssueKind::UndefinedVariable { .. } | IssueKind::PossiblyUndefinedVariable { .. } => {
80            cfg.undefined_variables
81        }
82        IssueKind::UndefinedFunction { .. } | IssueKind::UndefinedMethod { .. } => {
83            cfg.undefined_functions
84        }
85        IssueKind::UndefinedClass { .. } => cfg.undefined_classes,
86        IssueKind::TooFewArguments { .. }
87        | IssueKind::TooManyArguments { .. }
88        | IssueKind::InvalidPassByReference { .. }
89        | IssueKind::InvalidNamedArgument { .. } => cfg.arity_errors,
90        // InvalidArgument covers both arity errors and type mismatches in mir-analyzer;
91        // show it if either toggle is on.
92        IssueKind::InvalidArgument { .. } | IssueKind::PossiblyInvalidArgument { .. } => {
93            cfg.arity_errors || cfg.type_errors
94        }
95        IssueKind::InvalidReturnType { .. }
96        | IssueKind::NullMethodCall { .. }
97        | IssueKind::NullPropertyFetch { .. }
98        | IssueKind::NullArrayAccess
99        | IssueKind::NullArgument { .. }
100        | IssueKind::PossiblyNullMethodCall { .. }
101        | IssueKind::PossiblyNullPropertyFetch { .. }
102        | IssueKind::PossiblyNullArrayAccess
103        | IssueKind::PossiblyNullArgument { .. }
104        | IssueKind::NullableReturnStatement { .. }
105        | IssueKind::InvalidPropertyAssignment { .. }
106        | IssueKind::InvalidOperand { .. }
107        | IssueKind::InvalidCast { .. }
108        | IssueKind::AbstractInstantiation { .. }
109        | IssueKind::MixedClone => cfg.type_errors,
110        IssueKind::DeprecatedCall { .. }
111        | IssueKind::DeprecatedMethodCall { .. }
112        | IssueKind::DeprecatedMethod { .. }
113        | IssueKind::DeprecatedClass { .. } => cfg.deprecated_calls,
114        IssueKind::CircularInheritance { .. } => cfg.type_errors,
115        // mir 0.22 unused-symbol warnings. Off by default; opt in via
116        // `diagnostics.unusedSymbols` in initializationOptions.
117        IssueKind::UnusedVariable { .. }
118        | IssueKind::UnusedParam { .. }
119        | IssueKind::UnusedMethod { .. }
120        | IssueKind::UnusedProperty { .. }
121        | IssueKind::UnusedFunction { .. } => cfg.unused_symbols,
122        _ => true,
123    }
124}
125
126/// Check for duplicate class/function/interface/trait/enum declarations.
127pub fn duplicate_declaration_diagnostics(
128    _source: &str,
129    doc: &ParsedDoc,
130    cfg: &DiagnosticsConfig,
131) -> Vec<Diagnostic> {
132    if !cfg.enabled || !cfg.duplicate_declarations {
133        return vec![];
134    }
135    let sv = doc.view();
136    let mut seen: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
137    let mut diags = Vec::new();
138    collect_duplicate_decls(sv, &doc.program().stmts, "", &mut seen, &mut diags);
139    diags
140}
141
142fn collect_duplicate_decls(
143    sv: SourceView<'_>,
144    stmts: &[php_ast::Stmt<'_, '_>],
145    current_ns: &str,
146    seen: &mut std::collections::HashMap<String, ()>,
147    diags: &mut Vec<Diagnostic>,
148) {
149    // Track the active namespace for unbraced `namespace Foo;` declarations.
150    let mut active_ns = current_ns.to_string();
151
152    for stmt in stmts {
153        let name_and_span: Option<(String, u32)> = match &stmt.kind {
154            StmtKind::Class(c) => c.name.as_ref().map(|n| (n.to_string(), stmt.span.start)),
155            StmtKind::Interface(i) => Some((i.name.to_string(), stmt.span.start)),
156            StmtKind::Trait(t) => Some((t.name.to_string(), stmt.span.start)),
157            StmtKind::Enum(e) => Some((e.name.to_string(), stmt.span.start)),
158            StmtKind::Function(f) => Some((f.name.to_string(), stmt.span.start)),
159            StmtKind::Namespace(ns) => {
160                let ns_name = ns
161                    .name
162                    .as_ref()
163                    .map(|n| n.to_string_repr().to_string())
164                    .unwrap_or_default();
165                match &ns.body {
166                    php_ast::NamespaceBody::Braced(inner) => {
167                        let child_ns = if current_ns.is_empty() {
168                            ns_name
169                        } else {
170                            format!("{}\\{}", current_ns, ns_name)
171                        };
172                        collect_duplicate_decls(sv, inner, &child_ns, seen, diags);
173                    }
174                    php_ast::NamespaceBody::Simple => {
175                        // Unbraced namespace: subsequent siblings belong to this namespace.
176                        active_ns = if current_ns.is_empty() {
177                            ns_name
178                        } else {
179                            format!("{}\\{}", current_ns, ns_name)
180                        };
181                    }
182                }
183                None
184            }
185            _ => None,
186        };
187        if let Some((name, span_start)) = name_and_span {
188            let key = if active_ns.is_empty() {
189                name.clone()
190            } else {
191                format!("{}\\{}", active_ns, name)
192            };
193            if seen.insert(key, ()).is_some() {
194                // Find the byte offset of the actual name by searching forward from span_start.
195                // The span_start points to keywords like "class", "function", etc.,
196                // so we need to find where the identifier name appears.
197                let name_byte_offset = find_name_offset(&sv.source()[span_start as usize..], &name)
198                    .map(|off| span_start + off as u32)
199                    .unwrap_or(span_start);
200
201                let start_pos = sv.position_of(name_byte_offset);
202                // Calculate end position by converting UTF-8 character length to UTF-16 code units
203                let name_utf16_len = name.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
204                let end_pos = Position {
205                    line: start_pos.line,
206                    character: start_pos.character + name_utf16_len,
207                };
208                diags.push(Diagnostic {
209                    range: Range {
210                        start: start_pos,
211                        end: end_pos,
212                    },
213                    severity: Some(DiagnosticSeverity::WARNING),
214                    message: format!(
215                        "Duplicate declaration: `{name}` is already defined in this file"
216                    ),
217                    source: Some(PHP_LSP_SOURCE.to_string()),
218                    ..Default::default()
219                });
220            }
221        }
222    }
223}
224
225/// Find the byte offset of an identifier name within a sv.source() slice.
226/// Searches for word boundary matches (not substring matches).
227fn find_name_offset(source: &str, name: &str) -> Option<usize> {
228    let bytes = source.as_bytes();
229    for i in 0..source.len() {
230        if source[i..].starts_with(name) {
231            // Check word boundary before
232            let before_ok = i == 0 || !is_identifier_char(bytes[i - 1] as char);
233            // Check word boundary after
234            let after_idx = i + name.len();
235            let after_ok =
236                after_idx >= source.len() || !is_identifier_char(bytes[after_idx] as char);
237            if before_ok && after_ok {
238                return Some(i);
239            }
240        }
241    }
242    None
243}
244
245/// Check if a character is valid in a PHP identifier.
246fn is_identifier_char(c: char) -> bool {
247    c.is_alphanumeric() || c == '_'
248}
249
250fn to_lsp_diagnostic(issue: mir_issues::Issue, _uri: &Url) -> Diagnostic {
251    // mir-analyzer uses 1-based line numbers; LSP uses 0-based.
252    let line = issue.location.line.saturating_sub(1);
253    let col_start = issue.location.col_start as u32;
254    let col_end = issue.location.col_end as u32;
255    Diagnostic {
256        range: Range {
257            start: Position {
258                line,
259                character: col_start,
260            },
261            end: Position {
262                line,
263                character: col_end.max(col_start + 1),
264            },
265        },
266        severity: Some(match issue.severity {
267            mir_issues::Severity::Error => DiagnosticSeverity::ERROR,
268            mir_issues::Severity::Warning => DiagnosticSeverity::WARNING,
269            mir_issues::Severity::Info => DiagnosticSeverity::INFORMATION,
270        }),
271        code: Some(NumberOrString::String(issue.kind.name().to_string())),
272        source: Some(PHP_LSP_SOURCE.to_string()),
273        message: issue.kind.message(),
274        ..Default::default()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn duplicate_class_emits_warning() {
284        let src = "<?php\nclass Foo {}\nclass Foo {}";
285        let doc = ParsedDoc::parse(src.to_string());
286        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
287        assert_eq!(
288            diags.len(),
289            1,
290            "expected exactly 1 duplicate warning, got: {:?}",
291            diags
292        );
293        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
294        assert!(
295            diags[0].message.contains("Foo"),
296            "message should mention 'Foo'"
297        );
298    }
299
300    #[test]
301    fn no_duplicate_for_unique_declarations() {
302        let src = "<?php\nclass Foo {}\nclass Bar {}";
303        let doc = ParsedDoc::parse(src.to_string());
304        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
305        assert!(diags.is_empty());
306    }
307
308    #[test]
309    fn namespace_scoped_duplicate_not_flagged() {
310        // Two classes named `Foo` in different namespaces — should produce zero diagnostics.
311        let src = "<?php\nnamespace App\\A {\nclass Foo {}\n}\nnamespace App\\B {\nclass Foo {}\n}";
312        let doc = ParsedDoc::parse(src.to_string());
313        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
314        assert!(
315            diags.is_empty(),
316            "classes with same name in different namespaces should not be flagged, got: {:?}",
317            diags
318        );
319    }
320
321    #[test]
322    fn duplicate_interface_declaration() {
323        // Same interface defined twice in same file — should produce exactly one error.
324        let src = "<?php\ninterface Logger {}\ninterface Logger {}";
325        let doc = ParsedDoc::parse(src.to_string());
326        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
327        assert_eq!(
328            diags.len(),
329            1,
330            "expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
331            diags
332        );
333        assert!(
334            diags[0].message.contains("Logger"),
335            "diagnostic message should mention 'Logger'"
336        );
337        assert_eq!(
338            diags[0].severity,
339            Some(DiagnosticSeverity::WARNING),
340            "duplicate declaration should be a warning"
341        );
342    }
343
344    #[test]
345    fn duplicate_trait_declaration() {
346        // Same trait defined twice in same file — should produce exactly one error.
347        let src = "<?php\ntrait Serializable {}\ntrait Serializable {}";
348        let doc = ParsedDoc::parse(src.to_string());
349        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
350        assert_eq!(
351            diags.len(),
352            1,
353            "expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
354            diags
355        );
356        assert!(
357            diags[0].message.contains("Serializable"),
358            "diagnostic message should mention 'Serializable'"
359        );
360        assert_eq!(
361            diags[0].severity,
362            Some(DiagnosticSeverity::WARNING),
363            "duplicate trait declaration should be a warning"
364        );
365    }
366
367    #[test]
368    fn duplicate_diagnostic_has_warning_severity() {
369        // Duplicate declarations are reported as WARNING by our implementation.
370        // (Note: `duplicate_declaration_diagnostics` emits DiagnosticSeverity::WARNING.)
371        let src = "<?php\nfunction doWork() {}\nfunction doWork() {}";
372        let doc = ParsedDoc::parse(src.to_string());
373        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
374        assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
375        assert_eq!(
376            diags[0].severity,
377            Some(DiagnosticSeverity::WARNING),
378            "duplicate declaration diagnostic should have WARNING severity"
379        );
380    }
381
382    #[test]
383    fn unbraced_namespace_classes_with_same_name_not_flagged() {
384        // Two classes named `Foo` in different unbraced namespaces — should not be a duplicate.
385        let src = "<?php\nnamespace App\\A;\nclass Foo {}\nnamespace App\\B;\nclass Foo {}";
386        let doc = ParsedDoc::parse(src.to_string());
387        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
388        assert!(
389            diags.is_empty(),
390            "classes with same name in different unbraced namespaces should not be flagged, got: {:?}",
391            diags
392        );
393    }
394
395    #[test]
396    fn unbraced_namespace_duplicate_in_same_namespace_is_flagged() {
397        // Two classes named `Foo` in the same unbraced namespace — should produce one warning.
398        let src = "<?php\nnamespace App;\nclass Foo {}\nclass Foo {}";
399        let doc = ParsedDoc::parse(src.to_string());
400        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
401        assert_eq!(
402            diags.len(),
403            1,
404            "expected 1 duplicate-declaration diagnostic, got: {:?}",
405            diags
406        );
407        assert!(diags[0].message.contains("Foo"));
408    }
409
410    #[test]
411    fn duplicate_declaration_range_spans_full_name() {
412        // Duplicate declaration diagnostic range should span the entire name, not just first character.
413        let src = "<?php\nclass Foo {}\nclass Foo {}";
414        let doc = ParsedDoc::parse(src.to_string());
415        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
416        assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
417
418        let d = &diags[0];
419        let range_len = d.range.end.character - d.range.start.character;
420        let expected_len = "Foo".chars().map(|c| c.len_utf16() as u32).sum::<u32>();
421        assert_eq!(
422            range_len, expected_len,
423            "range length {} should match 'Foo' length {}",
424            range_len, expected_len
425        );
426
427        // Verify the range actually points to "Foo", not "class"
428        // "Foo" appears at character position 6 on line 2: "class Foo {}"
429        //                                          012345678...
430        assert_eq!(
431            d.range.start.character, 6,
432            "range should start at 'F' in 'Foo'"
433        );
434        assert_eq!(
435            d.range.end.character, 9,
436            "range should end after 'o' in 'Foo'"
437        );
438    }
439
440    #[test]
441    fn duplicate_function_declaration_range_spans_name() {
442        // Function duplicate should also span the full function name.
443        let src = "<?php\nfunction doWork() {}\nfunction doWork() {}";
444        let doc = ParsedDoc::parse(src.to_string());
445        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
446        assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
447
448        let d = &diags[0];
449        let range_len = d.range.end.character - d.range.start.character;
450        let expected_len = "doWork".chars().map(|c| c.len_utf16() as u32).sum::<u32>();
451        assert_eq!(
452            range_len, expected_len,
453            "range length {} should match 'doWork' length {}",
454            range_len, expected_len
455        );
456
457        // Verify the range points to "doWork", not "function"
458        // "doWork" appears at character position 9 on line 2: "function doWork() {}"
459        //                                              0123456789...
460        assert_eq!(
461            d.range.start.character, 9,
462            "range should start at 'd' in 'doWork'"
463        );
464        assert_eq!(
465            d.range.end.character, 15,
466            "range should end after 'k' in 'doWork'"
467        );
468    }
469
470    #[test]
471    fn duplicate_interface_range_spans_name() {
472        // Interface duplicate should span the full interface name.
473        let src = "<?php\ninterface Logger {}\ninterface Logger {}";
474        let doc = ParsedDoc::parse(src.to_string());
475        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
476        assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
477
478        let d = &diags[0];
479        let range_len = d.range.end.character - d.range.start.character;
480        let expected_len = "Logger".chars().map(|c| c.len_utf16() as u32).sum::<u32>();
481        assert_eq!(
482            range_len, expected_len,
483            "range length {} should match 'Logger' length {}",
484            range_len, expected_len
485        );
486
487        // Verify the range points to "Logger", not "interface"
488        // "Logger" appears at character position 10 on line 2: "interface Logger {}"
489        //                                               01234567890...
490        assert_eq!(
491            d.range.start.character, 10,
492            "range should start at 'L' in 'Logger'"
493        );
494        assert_eq!(
495            d.range.end.character, 16,
496            "range should end after 'r' in 'Logger'"
497        );
498    }
499
500    #[test]
501    fn duplicate_declaration_range_on_correct_line() {
502        // Diagnostic range should be on the correct line.
503        let src = "<?php\nclass Foo {}\n\nclass Foo {}";
504        let doc = ParsedDoc::parse(src.to_string());
505        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
506        assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
507
508        let d = &diags[0];
509        // The second "class Foo" is on line 3 (0-indexed: line 3)
510        assert_eq!(
511            d.range.start.line, 3,
512            "duplicate should be reported on line 3 (0-indexed)"
513        );
514        assert_eq!(
515            d.range.end.line, 3,
516            "range end should be on same line as start"
517        );
518    }
519
520    #[test]
521    fn to_lsp_diagnostic_sets_code_to_issue_kind_name() {
522        use mir_issues::{Issue, IssueKind, Location};
523        use std::sync::Arc;
524        use tower_lsp::lsp_types::{NumberOrString, Url};
525
526        let uri = Url::parse("file:///test.php").unwrap();
527        let location = Location {
528            file: Arc::from("file:///test.php"),
529            line: 1,
530            line_end: 1,
531            col_start: 0,
532            col_end: 3,
533        };
534        let issue = Issue::new(
535            IssueKind::UndefinedClass {
536                name: "Foo".to_string(),
537            },
538            location,
539        );
540        let diag = to_lsp_diagnostic(issue, &uri);
541        assert_eq!(
542            diag.code,
543            Some(NumberOrString::String("UndefinedClass".to_string())),
544            "diagnostic code must be the IssueKind name so code actions can match by type"
545        );
546        assert!(
547            diag.message.contains("Foo"),
548            "diagnostic message should mention the class name"
549        );
550    }
551}