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