php_lsp/analysis/external/
phpstan.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::PhpstanConfig;
11
12pub const SOURCE: &str = "phpstan";
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 line: Option<u32>,
32 identifier: Option<String>,
35}
36
37pub async fn run(
50 cfg: &PhpstanConfig,
51 path: &Path,
52 workspace_root: Option<&Path>,
53) -> Vec<Diagnostic> {
54 let mut command = tokio::process::Command::new(&cfg.bin_path);
55 command
56 .arg("analyse")
57 .arg("--error-format=json")
58 .arg("--no-progress")
59 .arg("--no-interaction")
60 .arg("--memory-limit=1G");
61 if let Some(config_path) = &cfg.config_path {
62 command.arg("-c").arg(config_path);
63 }
64 command.arg(path);
65 if let Some(root) = workspace_root {
66 command.current_dir(root);
67 }
68 command.stdin(Stdio::null());
69 command.stdout(Stdio::piped());
70 command.stderr(Stdio::null());
71
72 let output = match command.output().await {
73 Ok(o) => o,
74 Err(e) => {
75 tracing::debug!("phpstan ({}) did not run: {e}", cfg.bin_path);
76 return Vec::new();
77 }
78 };
79
80 let Ok(report) = serde_json::from_slice::<Report>(&output.stdout) else {
84 return Vec::new();
85 };
86
87 let Some(file_report) = report.files.into_values().next() else {
92 return Vec::new();
93 };
94
95 file_report.messages.iter().map(to_diagnostic).collect()
96}
97
98fn to_diagnostic(message: &Message) -> Diagnostic {
99 let line = message.line.unwrap_or(1).saturating_sub(1);
100 Diagnostic {
101 range: Range {
102 start: Position { line, character: 0 },
103 end: Position {
104 line,
105 character: u32::MAX,
106 },
107 },
108 severity: Some(DiagnosticSeverity::ERROR),
109 code: message.identifier.clone().map(NumberOrString::String),
110 source: Some(SOURCE.to_string()),
111 message: message.message.clone(),
112 ..Default::default()
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn parses_a_real_shaped_report_and_maps_line_to_zero_based() {
122 let json = r#"{
123 "totals": {"errors": 1, "file_errors": 1},
124 "files": {
125 "/abs/path/Foo.php": {
126 "errors": 1,
127 "messages": [
128 {
129 "message": "Parameter #1 $x of method Foo::bar() expects int, string given.",
130 "line": 10,
131 "ignorable": true,
132 "identifier": "argument.type"
133 }
134 ]
135 }
136 },
137 "errors": []
138 }"#;
139 let report: Report = serde_json::from_str(json).unwrap();
140 let file_report = report.files.into_values().next().unwrap();
141 let diagnostics: Vec<Diagnostic> = file_report.messages.iter().map(to_diagnostic).collect();
142
143 assert_eq!(diagnostics.len(), 1);
144 let d = &diagnostics[0];
145 assert_eq!(
146 d.range.start,
147 Position {
148 line: 9,
149 character: 0
150 }
151 );
152 assert_eq!(d.severity, Some(DiagnosticSeverity::ERROR));
153 assert_eq!(d.source.as_deref(), Some("phpstan"));
154 assert_eq!(
155 d.code,
156 Some(NumberOrString::String("argument.type".to_string()))
157 );
158 assert!(d.message.contains("expects int, string given"));
159 }
160
161 #[test]
162 fn missing_line_falls_back_to_the_first_line() {
163 let message = Message {
164 message: "Internal error".to_string(),
165 line: None,
166 identifier: None,
167 };
168 let d = to_diagnostic(&message);
169 assert_eq!(
170 d.range.start,
171 Position {
172 line: 0,
173 character: 0
174 }
175 );
176 assert_eq!(d.code, None);
177 }
178
179 #[test]
180 fn empty_files_map_yields_no_diagnostics() {
181 let json = r#"{"files": {}, "errors": []}"#;
182 let report: Report = serde_json::from_str(json).unwrap();
183 assert!(report.files.into_values().next().is_none());
184 }
185}