Skip to main content

php_lsp/editing/
formatting.rs

1/// `textDocument/formatting` and `textDocument/rangeFormatting`.
2///
3/// Delegates to the first available PHP formatter found on `$PATH`:
4///   1. `php-cs-fixer` (preferred — PSR-12 rules)
5///   2. `phpcbf` (PHP_CodeSniffer)
6///
7/// If neither tool is found the handler returns `Ok(None)` so the editor
8/// shows a gentle "formatter not available" message rather than an error.
9///
10/// Both handlers write the source to a temporary file, run the formatter
11/// in-place, read the result, then return a single `TextEdit` that replaces
12/// the entire document (simplest correct approach for whole-file formatting).
13/// Range formatting narrows the edit to the requested line span.
14use std::process::{Command, Stdio};
15use tower_lsp_server::ls_types::{Position, Range, TextEdit};
16
17use crate::text::end_position;
18
19/// Format `source` with the best available PHP formatter.
20/// Returns `None` if no formatter is installed or if the source was unchanged.
21pub fn format_document(source: &str) -> Option<Vec<TextEdit>> {
22    whole_document_edit(source, run_formatter(source)?)
23}
24
25fn whole_document_edit(source: &str, formatted: String) -> Option<Vec<TextEdit>> {
26    if formatted == source {
27        return None;
28    }
29    Some(vec![TextEdit {
30        range: Range {
31            start: Position {
32                line: 0,
33                character: 0,
34            },
35            end: end_position(source),
36        },
37        new_text: formatted,
38    }])
39}
40
41/// Format only the lines covered by `range`.  Extracts those lines, formats
42/// the snippet, then returns an edit targeting just that range.
43pub fn format_range(source: &str, range: Range) -> Option<Vec<TextEdit>> {
44    range_edit(source, range, run_formatter)
45}
46
47fn range_edit(
48    source: &str,
49    range: Range,
50    format: impl Fn(&str) -> Option<String>,
51) -> Option<Vec<TextEdit>> {
52    // A selection ending at column 0 of a later line doesn't cover that line.
53    let last_line = if range.end.character == 0 && range.end.line > range.start.line {
54        range.end.line - 1
55    } else {
56        range.end.line
57    };
58    let start = line_start_byte(source, range.start.line)?;
59    let end = line_start_byte(source, last_line + 1).unwrap_or(source.len());
60    let snippet = &source[start..end];
61    if snippet.trim().is_empty() {
62        return None;
63    }
64
65    let crlf = snippet.contains("\r\n");
66    let lf_snippet = snippet.replace("\r\n", "\n");
67    let formatted = if lf_snippet.trim_start().starts_with("<?php") {
68        format(&lf_snippet)?
69    } else {
70        format_fragment(&lf_snippet, &format)?
71    };
72    let mut formatted = formatted.trim_end_matches('\n').to_string();
73    if lf_snippet.ends_with('\n') {
74        formatted.push('\n');
75    }
76    if crlf {
77        formatted = formatted.replace('\n', "\r\n");
78    }
79    if formatted == snippet {
80        return None;
81    }
82
83    let end_pos = if end == source.len() {
84        end_position(source)
85    } else {
86        Position {
87            line: last_line + 1,
88            character: 0,
89        }
90    };
91    Some(vec![TextEdit {
92        range: Range {
93            start: Position {
94                line: range.start.line,
95                character: 0,
96            },
97            end: end_pos,
98        },
99        new_text: formatted,
100    }])
101}
102
103/// Format a snippet without an opening tag: dedent it, format it behind an
104/// injected `<?php`, then restore the original indentation.
105fn format_fragment(snippet: &str, format: &impl Fn(&str) -> Option<String>) -> Option<String> {
106    let indent = snippet
107        .lines()
108        .filter(|l| !l.trim().is_empty())
109        .map(|l| &l[..l.len() - l.trim_start().len()])
110        .min_by_key(|ws| ws.len())
111        .unwrap_or("");
112    let dedented: String = snippet
113        .lines()
114        .map(|l| l.strip_prefix(indent).unwrap_or(l.trim_start()))
115        .collect::<Vec<_>>()
116        .join("\n");
117    let formatted = format(&format!("<?php\n{dedented}\n"))?;
118    // PSR-12 puts a blank line after `<?php`; it belongs to the wrapper, not the snippet.
119    let body = formatted.strip_prefix("<?php")?.trim_start_matches('\n');
120    let leading_blank_lines = snippet.len() - snippet.trim_start_matches('\n').len();
121    let mut out = "\n".repeat(leading_blank_lines);
122    for line in body.lines() {
123        if !line.is_empty() {
124            out.push_str(indent);
125        }
126        out.push_str(line);
127        out.push('\n');
128    }
129    Some(out)
130}
131
132/// Byte offset where line `line` starts, or `None` past the last line.
133fn line_start_byte(source: &str, line: u32) -> Option<usize> {
134    if line == 0 {
135        return Some(0);
136    }
137    source
138        .match_indices('\n')
139        .nth(line as usize - 1)
140        .map(|(i, _)| i + 1)
141        .filter(|&i| i < source.len())
142}
143
144fn run_formatter(source: &str) -> Option<String> {
145    try_php_cs_fixer(source).or_else(|| try_phpcbf(source))
146}
147
148fn try_php_cs_fixer(source: &str) -> Option<String> {
149    // php-cs-fixer reads from stdin when passed `-` as the path.
150    // `--dry-run` is NOT used so the formatter actually rewrites the content,
151    // but since we pipe through stdin/stdout nothing on disk is touched.
152    //
153    // We use `fix --quiet --rules=@PSR12 -` which outputs the fixed source on
154    // stdout when stdin mode is supported (php-cs-fixer ≥ 3.x).
155    let output = Command::new("php-cs-fixer")
156        .args(["fix", "--quiet", "--no-interaction", "--rules=@PSR12", "-"])
157        .stdin(Stdio::piped())
158        .stdout(Stdio::piped())
159        .stderr(Stdio::null())
160        .spawn()
161        .ok()
162        .and_then(|mut child| {
163            use std::io::Write;
164            child.stdin.take()?.write_all(source.as_bytes()).ok()?;
165            child.wait_with_output().ok()
166        })?;
167
168    if output.status.success() || output.status.code() == Some(1) {
169        // exit 0 = already formatted, exit 1 = fixed
170        let text = String::from_utf8(output.stdout).ok()?;
171        if !text.is_empty() {
172            return Some(text);
173        }
174    }
175    None
176}
177
178fn try_phpcbf(source: &str) -> Option<String> {
179    // phpcbf writes to stdout when passed `--stdin-path` and reads from stdin.
180    let output = Command::new("phpcbf")
181        .args(["--standard=PSR12", "--stdin-path=file.php", "-"])
182        .stdin(Stdio::piped())
183        .stdout(Stdio::piped())
184        .stderr(Stdio::null())
185        .spawn()
186        .ok()
187        .and_then(|mut child| {
188            use std::io::Write;
189            child.stdin.take()?.write_all(source.as_bytes()).ok()?;
190            child.wait_with_output().ok()
191        })?;
192
193    // phpcbf exits 1 on success (fixable issues found and fixed), 0 = nothing to fix
194    if output.status.code() == Some(1) || output.status.success() {
195        let text = String::from_utf8(output.stdout).ok()?;
196        if !text.is_empty() {
197            return Some(text);
198        }
199    }
200    None
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn format_range_past_end_of_document_returns_none() {
209        let range = Range {
210            start: Position {
211                line: 10,
212                character: 0,
213            },
214            end: Position {
215                line: 12,
216                character: 0,
217            },
218        };
219        assert_eq!(format_range("<?php\n$a = 1;\n", range), None);
220        assert_eq!(format_range("", range), None);
221    }
222
223    fn apply(source: &str, edits: &[TextEdit]) -> String {
224        let mut text = source.to_string();
225        for edit in edits.iter().rev() {
226            crate::text::apply_content_change(&mut text, edit.range, &edit.new_text);
227        }
228        text
229    }
230
231    #[test]
232    fn whole_document_edit_replaces_trailing_newline() {
233        let source = "<?php\nfunction f( $x ){}\n";
234        let formatted = "<?php\nfunction f($x) {}\n";
235        let edits = whole_document_edit(source, formatted.to_string()).unwrap();
236        assert_eq!(apply(source, &edits), formatted);
237        assert_eq!(whole_document_edit(formatted, formatted.to_string()), None);
238    }
239
240    #[test]
241    fn whole_document_edit_covers_crlf_source() {
242        let source = "<?php\r\nfunction f( $x ){}\r\n";
243        let formatted = "<?php\r\nfunction f($x) {}\r\n";
244        let edits = whole_document_edit(source, formatted.to_string()).unwrap();
245        assert_eq!(apply(source, &edits), formatted);
246    }
247
248    fn range(start_line: u32, end_line: u32, end_char: u32) -> Range {
249        Range {
250            start: Position {
251                line: start_line,
252                character: 0,
253            },
254            end: Position {
255                line: end_line,
256                character: end_char,
257            },
258        }
259    }
260
261    /// Stands in for php-cs-fixer: fixes `( $x )` spacing and, like PSR-12, puts a
262    /// blank line after `<?php` and ends the file with one newline.
263    fn fake_formatter(src: &str) -> Option<String> {
264        let body = src.strip_prefix("<?php\n")?.trim_start_matches('\n');
265        let body = body.replace("( $x )", "($x)");
266        Some(format!("<?php\n\n{}\n", body.trim_end()))
267    }
268
269    #[test]
270    fn range_edit_keeps_single_newline_and_indent() {
271        let source = "<?php\nclass A {\n    function f( $x ) {}\n    function g() {}\n}\n";
272        let edits = range_edit(source, range(2, 2, 10), fake_formatter).unwrap();
273        assert_eq!(
274            apply(source, &edits),
275            "<?php\nclass A {\n    function f($x) {}\n    function g() {}\n}\n"
276        );
277    }
278
279    #[test]
280    fn range_edit_preserves_crlf() {
281        let source = "<?php\r\nf( $x );\r\ng( $x );\r\n";
282        let edits = range_edit(source, range(1, 3, 0), fake_formatter).unwrap();
283        assert_eq!(apply(source, &edits), "<?php\r\nf($x);\r\ng($x);\r\n");
284    }
285
286    #[test]
287    fn range_edit_last_line_without_newline() {
288        let source = "<?php\nf( $x );";
289        let edits = range_edit(source, range(1, 1, 3), fake_formatter).unwrap();
290        assert_eq!(apply(source, &edits), "<?php\nf($x);");
291    }
292
293    #[test]
294    fn range_edit_unchanged_returns_none() {
295        let source = "<?php\n    f($x);\n";
296        assert_eq!(range_edit(source, range(1, 1, 5), fake_formatter), None);
297    }
298}