php_lsp/analysis/
diagnostics.rs1use 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
10const PARSE_ERROR_CODE: &str = "SyntaxError";
12
13fn 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
31pub fn parse_document_no_diags(source: &str) -> ParsedDoc {
38 ParsedDoc::parse(Arc::from(source))
39}
40
41pub 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 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
79pub 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
95fn 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
106pub 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}