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, target: mir_analyzer::PhpVersion) -> Vec<Diagnostic> {
45    let sv = doc.view();
46    doc.errors
47        .iter()
48        .filter(|e| !supported_by_target(e, target))
49        .map(|e| {
50            let span = e.span();
51            let start = sv.position_of(span.start);
52            let end = if span.end > span.start {
53                sv.position_of(span.end)
54            } else {
55                // Zero-width span: advance by the UTF-16 width of the character
56                // at the error position so the range is never a mid-surrogate
57                // slice (characters outside the BMP take 2 UTF-16 code units).
58                let ch_width = sv.source()[span.start as usize..]
59                    .chars()
60                    .next()
61                    .map(|c| c.len_utf16() as u32)
62                    .unwrap_or(1);
63                Position {
64                    line: start.line,
65                    character: start.character + ch_width,
66                }
67            };
68            Diagnostic {
69                range: Range { start, end },
70                severity: Some(DiagnosticSeverity::ERROR),
71                source: Some(PHP_LSP_SOURCE.to_string()),
72                code: Some(NumberOrString::String(PARSE_ERROR_CODE.to_string())),
73                message: parse_error_message(e, sv),
74                ..Default::default()
75            }
76        })
77        .collect()
78}
79
80/// Merge per-file diagnostic categories into one ordered Vec.
81///
82/// Consistent order: parse errors → semantic issues. Callers extend the
83/// result with Laravel and external diagnostics afterward, in that order, so
84/// the full per-file order (parse → semantic → laravel → external) is
85/// uniform across `did_open`, `did_change`, `document_diagnostic`,
86/// `workspace_diagnostic`, and the dependent-republish path.
87pub fn merge_file_diagnostics(
88    parse: Vec<Diagnostic>,
89    semantic: Vec<Diagnostic>,
90) -> Vec<Diagnostic> {
91    let mut all = parse;
92    all.extend(semantic);
93    all
94}
95
96/// The document is parsed at the parser's default version, so a version gate
97/// above it (PHP 8.6 syntax) is lifted when the workspace targets that version.
98fn supported_by_target(e: &ParseError, target: mir_analyzer::PhpVersion) -> bool {
99    match e {
100        ParseError::VersionTooLow { required, .. } => required
101            .parse::<mir_analyzer::PhpVersion>()
102            .is_ok_and(|required| required <= target),
103        _ => false,
104    }
105}
106
107/// Parse `source` and return the (owned) `ParsedDoc` plus any parse diagnostics.
108pub fn parse_document(
109    source: &str,
110    target: mir_analyzer::PhpVersion,
111) -> (ParsedDoc, Vec<Diagnostic>) {
112    let doc = parse_document_no_diags(source);
113    let diagnostics = diagnostics_from_doc(&doc, target);
114    (doc, diagnostics)
115}