php_lsp/analysis/external/
phpcs.rs1use std::collections::HashMap;
4use std::path::Path;
5use std::process::Stdio;
6
7use serde::Deserialize;
8use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range};
9
10use crate::lang::config::PhpcsConfig;
11
12pub const SOURCE: &str = "phpcs";
13
14#[derive(Debug, Deserialize)]
15struct Report {
16 #[serde(default)]
17 files: HashMap<String, FileReport>,
18}
19
20#[derive(Debug, Deserialize)]
21struct FileReport {
22 #[serde(default)]
23 messages: Vec<Message>,
24}
25
26#[derive(Debug, Deserialize)]
27struct Message {
28 message: String,
29 source: Option<String>,
30 #[serde(rename = "type")]
31 kind: String,
32 line: Option<u32>,
33 column: Option<u32>,
34}
35
36pub async fn run(
42 cfg: &PhpcsConfig,
43 path: &Path,
44 workspace_root: Option<&Path>,
45 timeout: std::time::Duration,
46) -> Vec<Diagnostic> {
47 let mut command = tokio::process::Command::new(&cfg.bin_path);
48 command.arg("-q").arg("--report=json");
49 if let Some(standard) = &cfg.standard {
50 command.arg(format!("--standard={standard}"));
51 }
52 command.arg(path);
53 if let Some(root) = workspace_root {
54 command.current_dir(root);
55 }
56 command.stdin(Stdio::null());
57 command.stdout(Stdio::piped());
58 command.stderr(Stdio::null());
59
60 let Some(output) = super::output_with_timeout(command, timeout, &cfg.bin_path).await else {
61 return Vec::new();
62 };
63
64 let Ok(report) = serde_json::from_slice::<Report>(&output.stdout) else {
67 return Vec::new();
68 };
69
70 let Some(file_report) = report.files.into_values().next() else {
74 return Vec::new();
75 };
76
77 file_report.messages.iter().map(to_diagnostic).collect()
78}
79
80fn to_diagnostic(message: &Message) -> Diagnostic {
81 let line = message.line.unwrap_or(1).saturating_sub(1);
82 let character = message.column.unwrap_or(1).saturating_sub(1);
83 Diagnostic {
84 range: Range {
85 start: Position { line, character },
86 end: Position {
87 line,
88 character: character + 1,
89 },
90 },
91 severity: Some(if message.kind == "WARNING" {
92 DiagnosticSeverity::WARNING
93 } else {
94 DiagnosticSeverity::ERROR
95 }),
96 code: message.source.clone().map(NumberOrString::String),
97 source: Some(SOURCE.to_string()),
98 message: message.message.clone(),
99 ..Default::default()
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn parses_a_real_shaped_report_and_maps_position_to_zero_based() {
109 let json = r#"{
110 "totals": {"errors": 1, "warnings": 1, "fixable": 1},
111 "files": {
112 "/abs/path/Foo.php": {
113 "errors": 1,
114 "warnings": 1,
115 "messages": [
116 {
117 "message": "Line indented incorrectly.",
118 "source": "Generic.WhiteSpace.ScopeIndent.Incorrect",
119 "severity": 5,
120 "fixable": true,
121 "type": "ERROR",
122 "line": 12,
123 "column": 5
124 },
125 {
126 "message": "Line exceeds 120 characters.",
127 "source": "Generic.Files.LineLength.TooLong",
128 "severity": 5,
129 "fixable": false,
130 "type": "WARNING",
131 "line": 20,
132 "column": 1
133 }
134 ]
135 }
136 }
137 }"#;
138 let report: Report = serde_json::from_str(json).unwrap();
139 let file_report = report.files.into_values().next().unwrap();
140 let diagnostics: Vec<Diagnostic> = file_report.messages.iter().map(to_diagnostic).collect();
141
142 assert_eq!(diagnostics.len(), 2);
143
144 let error = &diagnostics[0];
145 assert_eq!(
146 error.range.start,
147 Position {
148 line: 11,
149 character: 4
150 }
151 );
152 assert_eq!(error.severity, Some(DiagnosticSeverity::ERROR));
153 assert_eq!(
154 error.code,
155 Some(NumberOrString::String(
156 "Generic.WhiteSpace.ScopeIndent.Incorrect".to_string()
157 ))
158 );
159
160 let warning = &diagnostics[1];
161 assert_eq!(
162 warning.range.start,
163 Position {
164 line: 19,
165 character: 0
166 }
167 );
168 assert_eq!(warning.severity, Some(DiagnosticSeverity::WARNING));
169 assert_eq!(
170 warning.code,
171 Some(NumberOrString::String(
172 "Generic.Files.LineLength.TooLong".to_string()
173 ))
174 );
175 assert_eq!(warning.message, "Line exceeds 120 characters.");
176 }
177
178 #[test]
179 fn missing_position_falls_back_to_the_first_character() {
180 let message = Message {
181 message: "Some violation".to_string(),
182 source: None,
183 kind: "ERROR".to_string(),
184 line: None,
185 column: None,
186 };
187 let d = to_diagnostic(&message);
188 assert_eq!(
189 d.range,
190 Range {
191 start: Position {
192 line: 0,
193 character: 0
194 },
195 end: Position {
196 line: 0,
197 character: 1
198 },
199 }
200 );
201 }
202}