Skip to main content

php_lsp/analysis/external/
phpstan.rs

1//! Runs PHPStan against a single file via `--error-format=json` and maps its
2//! findings onto LSP diagnostics.
3use 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    /// Absent for a handful of file-level errors (e.g. a parse failure
30    /// PHPStan itself can't attribute to a line); those fall back to line 1.
31    line: Option<u32>,
32    /// Rule identifier (PHPStan >= 1.11, e.g. `"argument.type"`). Older
33    /// PHPStan versions omit it.
34    identifier: Option<String>,
35}
36
37/// Analyse `path` with PHPStan and return its findings as diagnostics.
38///
39/// PHPStan reports only a line number, never a column, so each diagnostic
40/// spans the full line (`character: u32::MAX` on the end position — editors
41/// clamp this to the actual line length, the same convention other
42/// line-only-precision tools use rather than re-reading the source here).
43///
44/// Returns an empty `Vec` whenever the tool can't be run at all (binary
45/// missing, spawn failure, unparseable output) — this is an opt-in, best-
46/// effort overlay on top of the built-in diagnostics, not a required source
47/// of truth, so a broken PHPStan setup degrades silently rather than
48/// erroring the whole diagnostics publish.
49pub 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    // PHPStan exits non-zero when it finds errors — that's expected and
81    // unrelated to whether stdout holds a valid report, so the exit code is
82    // deliberately not checked here.
83    let Ok(report) = serde_json::from_slice::<Report>(&output.stdout) else {
84        return Vec::new();
85    };
86
87    // Only one file was ever passed on the command line, so take whatever
88    // single entry came back rather than matching by path string — PHPStan
89    // may report a canonicalized (symlink-resolved) path that differs
90    // byte-for-byte from the one we passed.
91    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}