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