php_lsp/analysis/diagnostics.rs
1use std::sync::Arc;
2
3use php_rs_parser::diagnostics::ParseError;
4use tower_lsp::lsp_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.
82/// All call sites that publish diagnostics for a single file use this function
83/// so the ordering is uniform across `did_open`, `did_change`, `document_diagnostic`,
84/// `workspace_diagnostic`, and the dependent-republish path.
85pub fn merge_file_diagnostics(
86 parse: Vec<Diagnostic>,
87 semantic: Vec<Diagnostic>,
88) -> Vec<Diagnostic> {
89 let mut all = parse;
90 all.extend(semantic);
91 all
92}
93
94/// Parse `source` and return the (owned) `ParsedDoc` plus any parse diagnostics.
95pub fn parse_document(source: &str) -> (ParsedDoc, Vec<Diagnostic>) {
96 let doc = parse_document_no_diags(source);
97 let diagnostics = diagnostics_from_doc(&doc);
98 (doc, diagnostics)
99}