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::lsp_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 start = range.start.line as usize;
50    let end = (range.end.line as usize + 1).min(lines.len());
51    let snippet = lines[start..end].join("\n") + "\n";
52
53    // Wrap in `<?php` if the snippet doesn't have an opener (needed for
54    // php-cs-fixer to recognise it as PHP).
55    let needs_wrapper = !snippet.trim_start().starts_with("<?php");
56    let to_format = if needs_wrapper {
57        format!("<?php\n{snippet}")
58    } else {
59        snippet.clone()
60    };
61
62    let mut formatted = run_formatter(&to_format)?;
63    if needs_wrapper {
64        // Strip the injected <?php header back out
65        formatted = formatted
66            .strip_prefix("<?php\n")
67            .unwrap_or(&formatted)
68            .to_string();
69    }
70
71    if formatted == snippet {
72        return None;
73    }
74
75    let end_char = lines
76        .get(end - 1)
77        .map(|l| l.chars().map(|c| c.len_utf16() as u32).sum())
78        .unwrap_or(0);
79    Some(vec![TextEdit {
80        range: Range {
81            start: Position {
82                line: range.start.line,
83                character: 0,
84            },
85            end: Position {
86                line: range.end.line,
87                character: end_char,
88            },
89        },
90        new_text: formatted,
91    }])
92}
93
94fn run_formatter(source: &str) -> Option<String> {
95    try_php_cs_fixer(source).or_else(|| try_phpcbf(source))
96}
97
98fn try_php_cs_fixer(source: &str) -> Option<String> {
99    // php-cs-fixer reads from stdin when passed `-` as the path.
100    // `--dry-run` is NOT used so the formatter actually rewrites the content,
101    // but since we pipe through stdin/stdout nothing on disk is touched.
102    //
103    // We use `fix --quiet --rules=@PSR12 -` which outputs the fixed source on
104    // stdout when stdin mode is supported (php-cs-fixer ≥ 3.x).
105    let output = Command::new("php-cs-fixer")
106        .args(["fix", "--quiet", "--no-interaction", "--rules=@PSR12", "-"])
107        .stdin(Stdio::piped())
108        .stdout(Stdio::piped())
109        .stderr(Stdio::null())
110        .spawn()
111        .ok()
112        .and_then(|mut child| {
113            use std::io::Write;
114            child.stdin.take()?.write_all(source.as_bytes()).ok()?;
115            child.wait_with_output().ok()
116        })?;
117
118    if output.status.success() || output.status.code() == Some(1) {
119        // exit 0 = already formatted, exit 1 = fixed
120        let text = String::from_utf8(output.stdout).ok()?;
121        if !text.is_empty() {
122            return Some(text);
123        }
124    }
125    None
126}
127
128fn try_phpcbf(source: &str) -> Option<String> {
129    // phpcbf writes to stdout when passed `--stdin-path` and reads from stdin.
130    let output = Command::new("phpcbf")
131        .args(["--standard=PSR12", "--stdin-path=file.php", "-"])
132        .stdin(Stdio::piped())
133        .stdout(Stdio::piped())
134        .stderr(Stdio::null())
135        .spawn()
136        .ok()
137        .and_then(|mut child| {
138            use std::io::Write;
139            child.stdin.take()?.write_all(source.as_bytes()).ok()?;
140            child.wait_with_output().ok()
141        })?;
142
143    // phpcbf exits 1 on success (fixable issues found and fixed), 0 = nothing to fix
144    if output.status.code() == Some(1) || output.status.success() {
145        let text = String::from_utf8(output.stdout).ok()?;
146        if !text.is_empty() {
147            return Some(text);
148        }
149    }
150    None
151}