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