use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use serde::Deserialize;
use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range};
use crate::lang::config::PhpstanConfig;
pub const SOURCE: &str = "phpstan";
#[derive(Debug, Deserialize)]
struct Report {
#[serde(default)]
files: HashMap<String, FileReport>,
}
#[derive(Debug, Deserialize)]
struct FileReport {
#[serde(default)]
messages: Vec<Message>,
}
#[derive(Debug, Deserialize)]
struct Message {
message: String,
line: Option<u32>,
identifier: Option<String>,
}
pub async fn run(
cfg: &PhpstanConfig,
path: &Path,
workspace_root: Option<&Path>,
) -> Vec<Diagnostic> {
let mut command = tokio::process::Command::new(&cfg.bin_path);
command
.arg("analyse")
.arg("--error-format=json")
.arg("--no-progress")
.arg("--no-interaction")
.arg("--memory-limit=1G");
if let Some(config_path) = &cfg.config_path {
command.arg("-c").arg(config_path);
}
command.arg(path);
if let Some(root) = workspace_root {
command.current_dir(root);
}
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
let output = match command.output().await {
Ok(o) => o,
Err(e) => {
tracing::debug!("phpstan ({}) did not run: {e}", cfg.bin_path);
return Vec::new();
}
};
let Ok(report) = serde_json::from_slice::<Report>(&output.stdout) else {
return Vec::new();
};
let Some(file_report) = report.files.into_values().next() else {
return Vec::new();
};
file_report.messages.iter().map(to_diagnostic).collect()
}
fn to_diagnostic(message: &Message) -> Diagnostic {
let line = message.line.unwrap_or(1).saturating_sub(1);
Diagnostic {
range: Range {
start: Position { line, character: 0 },
end: Position {
line,
character: u32::MAX,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: message.identifier.clone().map(NumberOrString::String),
source: Some(SOURCE.to_string()),
message: message.message.clone(),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_real_shaped_report_and_maps_line_to_zero_based() {
let json = r#"{
"totals": {"errors": 1, "file_errors": 1},
"files": {
"/abs/path/Foo.php": {
"errors": 1,
"messages": [
{
"message": "Parameter #1 $x of method Foo::bar() expects int, string given.",
"line": 10,
"ignorable": true,
"identifier": "argument.type"
}
]
}
},
"errors": []
}"#;
let report: Report = serde_json::from_str(json).unwrap();
let file_report = report.files.into_values().next().unwrap();
let diagnostics: Vec<Diagnostic> = file_report.messages.iter().map(to_diagnostic).collect();
assert_eq!(diagnostics.len(), 1);
let d = &diagnostics[0];
assert_eq!(
d.range.start,
Position {
line: 9,
character: 0
}
);
assert_eq!(d.severity, Some(DiagnosticSeverity::ERROR));
assert_eq!(d.source.as_deref(), Some("phpstan"));
assert_eq!(
d.code,
Some(NumberOrString::String("argument.type".to_string()))
);
assert!(d.message.contains("expects int, string given"));
}
#[test]
fn missing_line_falls_back_to_the_first_line() {
let message = Message {
message: "Internal error".to_string(),
line: None,
identifier: None,
};
let d = to_diagnostic(&message);
assert_eq!(
d.range.start,
Position {
line: 0,
character: 0
}
);
assert_eq!(d.code, None);
}
#[test]
fn empty_files_map_yields_no_diagnostics() {
let json = r#"{"files": {}, "errors": []}"#;
let report: Report = serde_json::from_str(json).unwrap();
assert!(report.files.into_values().next().is_none());
}
}