Skip to main content

php_lsp/analysis/
diagnostics.rs

1use std::sync::Arc;
2
3use php_rs_parser::diagnostics::ParseError;
4use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range};
5
6use crate::document::ast::{ParsedDoc, SourceView};
7
8pub const PHP_LSP_SOURCE: &str = "php-lsp";
9
10/// Code for parse-error diagnostics; not "ParseError" since `mir_issues::IssueKind::ParseError` already claims that string.
11const PARSE_ERROR_CODE: &str = "SyntaxError";
12
13/// Renders embedded `Span`s as `line:col` instead of raw `Debug` byte offsets.
14fn parse_error_message(e: &ParseError, sv: SourceView<'_>) -> String {
15    match e {
16        ParseError::UnclosedDelimiter {
17            delimiter,
18            opened_at,
19            ..
20        } => {
21            let pos = sv.position_of(opened_at.start);
22            format!(
23                "unclosed {delimiter} opened at {}:{}",
24                pos.line, pos.character
25            )
26        }
27        _ => e.to_string(),
28    }
29}
30
31/// Parse `source` without converting parse errors into LSP `Diagnostic`s.
32///
33/// Hot-path callers (workspace scan, the salsa `parsed_doc` query) discard
34/// diagnostics — using this variant skips an O(errors) Vec allocation per
35/// file. Callers that actually publish diagnostics call [`parse_document`]
36/// instead.
37pub fn parse_document_no_diags(source: &str) -> ParsedDoc {
38    ParsedDoc::parse(Arc::from(source))
39}
40
41/// Build LSP diagnostics from an already-parsed document. Separated from
42/// [`parse_document_no_diags`] so the workspace-scan path can skip the
43/// allocation entirely.
44pub fn diagnostics_from_doc(doc: &ParsedDoc) -> Vec<Diagnostic> {
45    let sv = doc.view();
46    doc.errors
47        .iter()
48        .map(|e| {
49            let span = e.span();
50            let start = sv.position_of(span.start);
51            let end = if span.end > span.start {
52                sv.position_of(span.end)
53            } else {
54                // Zero-width span: advance by the UTF-16 width of the character
55                // at the error position so the range is never a mid-surrogate
56                // slice (characters outside the BMP take 2 UTF-16 code units).
57                let ch_width = sv.source()[span.start as usize..]
58                    .chars()
59                    .next()
60                    .map(|c| c.len_utf16() as u32)
61                    .unwrap_or(1);
62                Position {
63                    line: start.line,
64                    character: start.character + ch_width,
65                }
66            };
67            Diagnostic {
68                range: Range { start, end },
69                severity: Some(DiagnosticSeverity::ERROR),
70                source: Some(PHP_LSP_SOURCE.to_string()),
71                code: Some(NumberOrString::String(PARSE_ERROR_CODE.to_string())),
72                message: parse_error_message(e, sv),
73                ..Default::default()
74            }
75        })
76        .collect()
77}
78
79/// Merge per-file diagnostic categories into one ordered Vec.
80///
81/// Consistent order: parse errors → semantic issues. Callers extend the
82/// result with Laravel and external diagnostics afterward, in that order, so
83/// the full per-file order (parse → semantic → laravel → external) is
84/// uniform across `did_open`, `did_change`, `document_diagnostic`,
85/// `workspace_diagnostic`, and the dependent-republish path.
86pub fn merge_file_diagnostics(
87    parse: Vec<Diagnostic>,
88    semantic: Vec<Diagnostic>,
89) -> Vec<Diagnostic> {
90    let mut all = parse;
91    all.extend(semantic);
92    all
93}
94
95/// Parse `source` and return the (owned) `ParsedDoc` plus any parse diagnostics.
96pub fn parse_document(source: &str) -> (ParsedDoc, Vec<Diagnostic>) {
97    let doc = parse_document_no_diags(source);
98    let diagnostics = diagnostics_from_doc(&doc);
99    (doc, diagnostics)
100}