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
17/// Format `source` with the best available PHP formatter.
18/// Returns `None` if no formatter is installed or if the source was unchanged.
19pub fn format_document(source: &str) -> Option<Vec<TextEdit>> {
20    let formatted = run_formatter(source)?;
21    if formatted == source {
22        return None; // already clean — no edits needed
23    }
24    let line_count = source.lines().count() as u32;
25    let last_line_len = source
26        .lines()
27        .last()
28        .map(|l| l.chars().map(|c| c.len_utf16() as u32).sum())
29        .unwrap_or(0);
30    Some(vec![TextEdit {
31        range: Range {
32            start: Position {
33                line: 0,
34                character: 0,
35            },
36            end: Position {
37                line: line_count.saturating_sub(1),
38                character: last_line_len,
39            },
40        },
41        new_text: formatted,
42    }])
43}
44
45/// Format only the lines covered by `range`.  Extracts those lines, formats
46/// the snippet, then returns an edit targeting just that range.
47pub fn format_range(source: &str, range: Range) -> Option<Vec<TextEdit>> {
48    let lines: Vec<&str> = source.lines().collect();
49    let end = (range.end.line as usize + 1).min(lines.len());
50    let start = (range.start.line as usize).min(end);
51    if start == end {
52        return None;
53    }
54    let snippet = lines[start..end].join("\n") + "\n";
55
56    // Wrap in `<?php` if the snippet doesn't have an opener (needed for
57    // php-cs-fixer to recognise it as PHP).
58    let needs_wrapper = !snippet.trim_start().starts_with("<?php");
59    let to_format = if needs_wrapper {
60        format!("<?php\n{snippet}")
61    } else {
62        snippet.clone()
63    };
64
65    let mut formatted = run_formatter(&to_format)?;
66    if needs_wrapper {
67        // Strip the injected <?php header back out
68        formatted = formatted
69            .strip_prefix("<?php\n")
70            .unwrap_or(&formatted)
71            .to_string();
72    }
73
74    if formatted == snippet {
75        return None;
76    }
77
78    let end_char = lines
79        .get(end - 1)
80        .map(|l| l.chars().map(|c| c.len_utf16() as u32).sum())
81        .unwrap_or(0);
82    Some(vec![TextEdit {
83        range: Range {
84            start: Position {
85                line: range.start.line,
86                character: 0,
87            },
88            end: Position {
89                line: range.end.line,
90                character: end_char,
91            },
92        },
93        new_text: formatted,
94    }])
95}
96
97fn run_formatter(source: &str) -> Option<String> {
98    try_php_cs_fixer(source).or_else(|| try_phpcbf(source))
99}
100
101fn try_php_cs_fixer(source: &str) -> Option<String> {
102    // php-cs-fixer reads from stdin when passed `-` as the path.
103    // `--dry-run` is NOT used so the formatter actually rewrites the content,
104    // but since we pipe through stdin/stdout nothing on disk is touched.
105    //
106    // We use `fix --quiet --rules=@PSR12 -` which outputs the fixed source on
107    // stdout when stdin mode is supported (php-cs-fixer ≥ 3.x).
108    let output = Command::new("php-cs-fixer")
109        .args(["fix", "--quiet", "--no-interaction", "--rules=@PSR12", "-"])
110        .stdin(Stdio::piped())
111        .stdout(Stdio::piped())
112        .stderr(Stdio::null())
113        .spawn()
114        .ok()
115        .and_then(|mut child| {
116            use std::io::Write;
117            child.stdin.take()?.write_all(source.as_bytes()).ok()?;
118            child.wait_with_output().ok()
119        })?;
120
121    if output.status.success() || output.status.code() == Some(1) {
122        // exit 0 = already formatted, exit 1 = fixed
123        let text = String::from_utf8(output.stdout).ok()?;
124        if !text.is_empty() {
125            return Some(text);
126        }
127    }
128    None
129}
130
131fn try_phpcbf(source: &str) -> Option<String> {
132    // phpcbf writes to stdout when passed `--stdin-path` and reads from stdin.
133    let output = Command::new("phpcbf")
134        .args(["--standard=PSR12", "--stdin-path=file.php", "-"])
135        .stdin(Stdio::piped())
136        .stdout(Stdio::piped())
137        .stderr(Stdio::null())
138        .spawn()
139        .ok()
140        .and_then(|mut child| {
141            use std::io::Write;
142            child.stdin.take()?.write_all(source.as_bytes()).ok()?;
143            child.wait_with_output().ok()
144        })?;
145
146    // phpcbf exits 1 on success (fixable issues found and fixed), 0 = nothing to fix
147    if output.status.code() == Some(1) || output.status.success() {
148        let text = String::from_utf8(output.stdout).ok()?;
149        if !text.is_empty() {
150            return Some(text);
151        }
152    }
153    None
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn format_range_past_end_of_document_returns_none() {
162        let range = Range {
163            start: Position {
164                line: 10,
165                character: 0,
166            },
167            end: Position {
168                line: 12,
169                character: 0,
170            },
171        };
172        assert_eq!(format_range("<?php\n$a = 1;\n", range), None);
173        assert_eq!(format_range("", range), None);
174    }
175}