Skip to main content

php_lsp/analysis/external/
phpcs.rs

1//! Runs PHPCS against a single file via `--report=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::PhpcsConfig;
11
12pub const SOURCE: &str = "phpcs";
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    source: Option<String>,
30    #[serde(rename = "type")]
31    kind: String,
32    line: Option<u32>,
33    column: Option<u32>,
34}
35
36/// Check `path` with PHPCS and return its findings as diagnostics.
37///
38/// Returns an empty `Vec` whenever the tool can't be run at all (binary
39/// missing, spawn failure, unparseable output) — same best-effort contract
40/// as [`super::phpstan::run`].
41pub async fn run(cfg: &PhpcsConfig, path: &Path, workspace_root: Option<&Path>) -> Vec<Diagnostic> {
42    let mut command = tokio::process::Command::new(&cfg.bin_path);
43    command.arg("-q").arg("--report=json");
44    if let Some(standard) = &cfg.standard {
45        command.arg(format!("--standard={standard}"));
46    }
47    command.arg(path);
48    if let Some(root) = workspace_root {
49        command.current_dir(root);
50    }
51    command.stdin(Stdio::null());
52    command.stdout(Stdio::piped());
53    command.stderr(Stdio::null());
54
55    let output = match command.output().await {
56        Ok(o) => o,
57        Err(e) => {
58            tracing::debug!("phpcs ({}) did not run: {e}", cfg.bin_path);
59            return Vec::new();
60        }
61    };
62
63    // PHPCS exits non-zero when it finds violations — expected, unrelated to
64    // whether stdout holds a valid report.
65    let Ok(report) = serde_json::from_slice::<Report>(&output.stdout) else {
66        return Vec::new();
67    };
68
69    // Only one file was ever passed on the command line; take the single
70    // entry regardless of its key rather than matching by path string (see
71    // the same note in `phpstan::run`).
72    let Some(file_report) = report.files.into_values().next() else {
73        return Vec::new();
74    };
75
76    file_report.messages.iter().map(to_diagnostic).collect()
77}
78
79fn to_diagnostic(message: &Message) -> Diagnostic {
80    let line = message.line.unwrap_or(1).saturating_sub(1);
81    let character = message.column.unwrap_or(1).saturating_sub(1);
82    Diagnostic {
83        range: Range {
84            start: Position { line, character },
85            end: Position {
86                line,
87                character: character + 1,
88            },
89        },
90        severity: Some(if message.kind == "WARNING" {
91            DiagnosticSeverity::WARNING
92        } else {
93            DiagnosticSeverity::ERROR
94        }),
95        code: message.source.clone().map(NumberOrString::String),
96        source: Some(SOURCE.to_string()),
97        message: message.message.clone(),
98        ..Default::default()
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn parses_a_real_shaped_report_and_maps_position_to_zero_based() {
108        let json = r#"{
109            "totals": {"errors": 1, "warnings": 1, "fixable": 1},
110            "files": {
111                "/abs/path/Foo.php": {
112                    "errors": 1,
113                    "warnings": 1,
114                    "messages": [
115                        {
116                            "message": "Line indented incorrectly.",
117                            "source": "Generic.WhiteSpace.ScopeIndent.Incorrect",
118                            "severity": 5,
119                            "fixable": true,
120                            "type": "ERROR",
121                            "line": 12,
122                            "column": 5
123                        },
124                        {
125                            "message": "Line exceeds 120 characters.",
126                            "source": "Generic.Files.LineLength.TooLong",
127                            "severity": 5,
128                            "fixable": false,
129                            "type": "WARNING",
130                            "line": 20,
131                            "column": 1
132                        }
133                    ]
134                }
135            }
136        }"#;
137        let report: Report = serde_json::from_str(json).unwrap();
138        let file_report = report.files.into_values().next().unwrap();
139        let diagnostics: Vec<Diagnostic> = file_report.messages.iter().map(to_diagnostic).collect();
140
141        assert_eq!(diagnostics.len(), 2);
142
143        let error = &diagnostics[0];
144        assert_eq!(
145            error.range.start,
146            Position {
147                line: 11,
148                character: 4
149            }
150        );
151        assert_eq!(error.severity, Some(DiagnosticSeverity::ERROR));
152        assert_eq!(
153            error.code,
154            Some(NumberOrString::String(
155                "Generic.WhiteSpace.ScopeIndent.Incorrect".to_string()
156            ))
157        );
158
159        let warning = &diagnostics[1];
160        assert_eq!(
161            warning.range.start,
162            Position {
163                line: 19,
164                character: 0
165            }
166        );
167        assert_eq!(warning.severity, Some(DiagnosticSeverity::WARNING));
168        assert_eq!(
169            warning.code,
170            Some(NumberOrString::String(
171                "Generic.Files.LineLength.TooLong".to_string()
172            ))
173        );
174        assert_eq!(warning.message, "Line exceeds 120 characters.");
175    }
176
177    #[test]
178    fn missing_position_falls_back_to_the_first_character() {
179        let message = Message {
180            message: "Some violation".to_string(),
181            source: None,
182            kind: "ERROR".to_string(),
183            line: None,
184            column: None,
185        };
186        let d = to_diagnostic(&message);
187        assert_eq!(
188            d.range,
189            Range {
190                start: Position {
191                    line: 0,
192                    character: 0
193                },
194                end: Position {
195                    line: 0,
196                    character: 1
197                },
198            }
199        );
200    }
201}