1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
/// `textDocument/formatting` and `textDocument/rangeFormatting`.
///
/// Delegates to the first available PHP formatter found on `$PATH`:
/// 1. `php-cs-fixer` (preferred — PSR-12 rules)
/// 2. `phpcbf` (PHP_CodeSniffer)
///
/// If neither tool is found the handler returns `Ok(None)` so the editor
/// shows a gentle "formatter not available" message rather than an error.
///
/// Both handlers write the source to a temporary file, run the formatter
/// in-place, read the result, then return a single `TextEdit` that replaces
/// the entire document (simplest correct approach for whole-file formatting).
/// Range formatting narrows the edit to the requested line span.
use std::process::{Command, Stdio};
use tower_lsp::lsp_types::{Position, Range, TextEdit};
/// Format `source` with the best available PHP formatter.
/// Returns `None` if no formatter is installed or if the source was unchanged.
pub fn format_document(source: &str) -> Option<Vec<TextEdit>> {
let formatted = run_formatter(source)?;
if formatted == source {
return None; // already clean — no edits needed
}
let line_count = source.lines().count() as u32;
let last_line_len = source
.lines()
.last()
.map(|l| l.chars().map(|c| c.len_utf16() as u32).sum())
.unwrap_or(0);
Some(vec![TextEdit {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: line_count.saturating_sub(1),
character: last_line_len,
},
},
new_text: formatted,
}])
}
/// Format only the lines covered by `range`. Extracts those lines, formats
/// the snippet, then returns an edit targeting just that range.
pub fn format_range(source: &str, range: Range) -> Option<Vec<TextEdit>> {
let lines: Vec<&str> = source.lines().collect();
let start = range.start.line as usize;
let end = (range.end.line as usize + 1).min(lines.len());
let snippet = lines[start..end].join("\n") + "\n";
// Wrap in `<?php` if the snippet doesn't have an opener (needed for
// php-cs-fixer to recognise it as PHP).
let needs_wrapper = !snippet.trim_start().starts_with("<?php");
let to_format = if needs_wrapper {
format!("<?php\n{snippet}")
} else {
snippet.clone()
};
let mut formatted = run_formatter(&to_format)?;
if needs_wrapper {
// Strip the injected <?php header back out
formatted = formatted
.strip_prefix("<?php\n")
.unwrap_or(&formatted)
.to_string();
}
if formatted == snippet {
return None;
}
let end_char = lines
.get(end - 1)
.map(|l| l.chars().map(|c| c.len_utf16() as u32).sum())
.unwrap_or(0);
Some(vec![TextEdit {
range: Range {
start: Position {
line: range.start.line,
character: 0,
},
end: Position {
line: range.end.line,
character: end_char,
},
},
new_text: formatted,
}])
}
// ── Formatter invocation ──────────────────────────────────────────────────────
fn run_formatter(source: &str) -> Option<String> {
try_php_cs_fixer(source).or_else(|| try_phpcbf(source))
}
fn try_php_cs_fixer(source: &str) -> Option<String> {
// php-cs-fixer reads from stdin when passed `-` as the path.
// `--dry-run` is NOT used so the formatter actually rewrites the content,
// but since we pipe through stdin/stdout nothing on disk is touched.
//
// We use `fix --quiet --rules=@PSR12 -` which outputs the fixed source on
// stdout when stdin mode is supported (php-cs-fixer ≥ 3.x).
let output = Command::new("php-cs-fixer")
.args(["fix", "--quiet", "--no-interaction", "--rules=@PSR12", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take()?.write_all(source.as_bytes()).ok()?;
child.wait_with_output().ok()
})?;
if output.status.success() || output.status.code() == Some(1) {
// exit 0 = already formatted, exit 1 = fixed
let text = String::from_utf8(output.stdout).ok()?;
if !text.is_empty() {
return Some(text);
}
}
None
}
fn try_phpcbf(source: &str) -> Option<String> {
// phpcbf writes to stdout when passed `--stdin-path` and reads from stdin.
let output = Command::new("phpcbf")
.args(["--standard=PSR12", "--stdin-path=file.php", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take()?.write_all(source.as_bytes()).ok()?;
child.wait_with_output().ok()
})?;
// phpcbf exits 1 on success (fixable issues found and fixed), 0 = nothing to fix
if output.status.code() == Some(1) || output.status.success() {
let text = String::from_utf8(output.stdout).ok()?;
if !text.is_empty() {
return Some(text);
}
}
None
}