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