Skip to main content

php_lsp/analysis/
diagnostics.rs

1use std::sync::Arc;
2
3use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
4
5use crate::ast::ParsedDoc;
6
7pub const PHP_LSP_SOURCE: &str = "php-lsp";
8
9/// Parse `source` without converting parse errors into LSP `Diagnostic`s.
10///
11/// Hot-path callers (workspace scan, the salsa `parsed_doc` query) discard
12/// diagnostics — using this variant skips an O(errors) Vec allocation per
13/// file. Callers that actually publish diagnostics call [`parse_document`]
14/// instead.
15pub fn parse_document_no_diags(source: &str) -> ParsedDoc {
16    ParsedDoc::parse(Arc::from(source))
17}
18
19/// Build LSP diagnostics from an already-parsed document. Separated from
20/// [`parse_document_no_diags`] so the workspace-scan path can skip the
21/// allocation entirely.
22pub fn diagnostics_from_doc(doc: &ParsedDoc) -> Vec<Diagnostic> {
23    let sv = doc.view();
24    doc.errors
25        .iter()
26        .map(|e| {
27            let span = e.span();
28            let start = sv.position_of(span.start);
29            let end = if span.end > span.start {
30                sv.position_of(span.end)
31            } else {
32                // Zero-width span: advance by the UTF-16 width of the character
33                // at the error position so the range is never a mid-surrogate
34                // slice (characters outside the BMP take 2 UTF-16 code units).
35                let ch_width = sv.source()[span.start as usize..]
36                    .chars()
37                    .next()
38                    .map(|c| c.len_utf16() as u32)
39                    .unwrap_or(1);
40                Position {
41                    line: start.line,
42                    character: start.character + ch_width,
43                }
44            };
45            Diagnostic {
46                range: Range { start, end },
47                severity: Some(DiagnosticSeverity::ERROR),
48                source: Some(PHP_LSP_SOURCE.to_string()),
49                message: e.to_string(),
50                ..Default::default()
51            }
52        })
53        .collect()
54}
55
56/// Merge per-file diagnostic categories into one ordered Vec.
57///
58/// Consistent order: parse errors → semantic issues.
59/// All call sites that publish diagnostics for a single file use this function
60/// so the ordering is uniform across `did_open`, `did_change`, `document_diagnostic`,
61/// `workspace_diagnostic`, and the dependent-republish path.
62pub fn merge_file_diagnostics(
63    parse: Vec<Diagnostic>,
64    semantic: Vec<Diagnostic>,
65) -> Vec<Diagnostic> {
66    let mut all = parse;
67    all.extend(semantic);
68    all
69}
70
71/// Parse `source` and return the (owned) `ParsedDoc` plus any parse diagnostics.
72pub fn parse_document(source: &str) -> (ParsedDoc, Vec<Diagnostic>) {
73    let doc = parse_document_no_diags(source);
74    let diagnostics = diagnostics_from_doc(&doc);
75    (doc, diagnostics)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn valid_php_produces_no_diagnostics() {
84        let (doc, diags) = parse_document("<?php\nfunction greet() {}");
85        assert!(diags.is_empty());
86        assert!(!doc.program().stmts.is_empty());
87    }
88
89    #[test]
90    fn syntax_error_produces_diagnostic() {
91        let (_, diags) = parse_document("<?php\nclass {");
92        assert!(!diags.is_empty(), "expected at least one diagnostic");
93        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
94    }
95
96    /// Probe: print every (start, end, zero_width) tuple for a wider set of
97    /// error-inducing snippets to see if any zero-width span can be made to
98    /// land *on* a non-BMP (surrogate-pair) character rather than at EOF.
99    #[test]
100    fn probe_zero_width_spans() {
101        let cases: &[(&str, &str)] = &[
102            ("class_no_name", "<?php\nclass {"),
103            ("fn_no_name", "<?php\nfunction ("),
104            ("assign_no_rhs", "<?php\n$x ="),
105            ("bare_emoji", "<?php\n\u{1F600}"),
106            ("emoji_class", "<?php\nclass \u{1F600} {"),
107            // Try to force a zero-width span mid-file rather than at EOF.
108            ("emoji_then_valid", "<?php\n\u{1F600}\nfunction f() {}"),
109            ("emoji_in_string_ctx", "<?php\n$x = \u{1F600};"),
110        ];
111        for (label, src) in cases {
112            let doc = crate::ast::ParsedDoc::parse(src.to_string());
113            for e in &doc.errors {
114                let span = e.span();
115                let ch = src[span.start as usize..].chars().next();
116                println!(
117                    "{label}: span=({},{}) zero_width={} char={ch:?} src_len={}",
118                    span.start,
119                    span.end,
120                    span.end == span.start,
121                    src.len(),
122                );
123            }
124        }
125    }
126}