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: widen by the UTF-16 width of the character at
56                // the error (non-BMP chars take 2 units), but stay zero-width at
57                // a line break or EOF so the range never points past the line.
58                let ch_width = match sv.source()[span.start as usize..].chars().next() {
59                    None | Some('\n' | '\r') => 0,
60                    Some(c) => c.len_utf16() as u32,
61                };
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/// The document is parsed at the parser's default version, so a version gate
96/// above it (PHP 8.6 syntax) is lifted when the workspace targets that version.
97fn supported_by_target(e: &ParseError, target: mir_analyzer::PhpVersion) -> bool {
98    match e {
99        ParseError::VersionTooLow { required, .. } => required
100            .parse::<mir_analyzer::PhpVersion>()
101            .is_ok_and(|required| required <= target),
102        _ => false,
103    }
104}
105
106/// Parse `source` and return the (owned) `ParsedDoc` plus any parse diagnostics.
107pub fn parse_document(
108    source: &str,
109    target: mir_analyzer::PhpVersion,
110) -> (ParsedDoc, Vec<Diagnostic>) {
111    let doc = parse_document_no_diags(source);
112    let diagnostics = diagnostics_from_doc(&doc, target);
113    (doc, diagnostics)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn assert_ranges_within_lines(source: &str) {
121        let doc = parse_document_no_diags(source);
122        let diags = diagnostics_from_doc(&doc, mir_analyzer::PhpVersion::LATEST);
123        assert!(!diags.is_empty(), "expected a parse error for {source:?}");
124        let lines: Vec<&str> = source.split('\n').collect();
125        for d in diags {
126            let line = lines[d.range.end.line as usize].trim_end_matches('\r');
127            let len = line.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
128            assert!(
129                d.range.end.character <= len,
130                "range {:?} extends past line {:?}",
131                d.range,
132                line
133            );
134        }
135    }
136
137    #[test]
138    fn parse_error_at_eof_stays_within_file() {
139        assert_ranges_within_lines("<?php\nfoo(");
140    }
141
142    #[test]
143    fn parse_error_at_line_end_stays_within_line() {
144        assert_ranges_within_lines("<?php\n$a = \n");
145        assert_ranges_within_lines("<?php\r\n$a = \r\n");
146    }
147}