Parse PHPStan's JSON output into LSP diagnostics. PHPStan JSON format (with `--error-format=json`): ```json { "totals": { "errors": 0, "file_errors": 2 }, "files": { "/path/to/file.php": { "errors": 2, "messages": [ { "message": "...", "line": 42, "ignorable": true, "identifier": "argument.type" } ] } }, "errors": [] } ``` We extract messages for the file being edited (matching by path) and als
(json_str: &str, file_path: &Path)
| 243 | /// We extract messages for the file being edited (matching by path) |
| 244 | /// and also include top-level `errors` (configuration/internal errors). |
| 245 | fn parse_phpstan_json(json_str: &str, file_path: &Path) -> Result<Vec<Diagnostic>, String> { |
| 246 | let output: serde_json::Value = serde_json::from_str(json_str) |
| 247 | .map_err(|e| format!("Failed to parse PHPStan JSON: {}", e))?; |
| 248 | |
| 249 | let mut diagnostics = Vec::new(); |
| 250 | |
| 251 | // Extract file-level errors. |
| 252 | if let Some(files) = output.get("files").and_then(|f| f.as_object()) { |
| 253 | // PHPStan keys files by their real path. The --tmp-file flag |
| 254 | // causes PHPStan to report errors under the *original* file |
| 255 | // path (the --instead-of path), not the temp file path. |
| 256 | // We need to match against the original file path. |
| 257 | let file_path_str = file_path.to_string_lossy(); |
| 258 | |
| 259 | for (path, file_data) in files { |
| 260 | // Match the file: PHPStan normalizes to absolute paths, |
| 261 | // so compare by checking if either path ends with the other |
| 262 | // or if they match exactly. |
| 263 | if !paths_match(path, &file_path_str) { |
| 264 | continue; |
| 265 | } |
| 266 | |
| 267 | if let Some(messages) = file_data.get("messages").and_then(|m| m.as_array()) { |
| 268 | for msg in messages { |
| 269 | if let Some(diag) = parse_phpstan_message(msg) { |
| 270 | diagnostics.push(diag); |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // Extract top-level errors (configuration issues, etc.). |
| 278 | if let Some(errors) = output.get("errors").and_then(|e| e.as_array()) { |
| 279 | for error in errors { |
| 280 | if let Some(error_str) = error.as_str() { |
| 281 | diagnostics.push(Diagnostic { |
| 282 | range: Range { |
| 283 | start: Position { |
| 284 | line: 0, |
| 285 | character: 0, |
| 286 | }, |
| 287 | end: Position { |
| 288 | line: 0, |
| 289 | character: 0, |
| 290 | }, |
| 291 | }, |
| 292 | severity: Some(DiagnosticSeverity::ERROR), |
| 293 | code: Some(NumberOrString::String("phpstan".to_string())), |
| 294 | code_description: None, |
| 295 | source: Some("phpstan".to_string()), |
| 296 | message: error_str.to_string(), |
| 297 | related_information: None, |
| 298 | tags: None, |
| 299 | data: None, |
| 300 | }); |
| 301 | } |
| 302 | } |