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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
/// `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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unchanged_source_returns_none() {
// This test only runs if a formatter is available; otherwise it's a no-op.
let src = "<?php\n\nfunction greet(): void\n{\n}\n";
// We just check the function doesn't panic — result depends on installed tools.
let _ = format_document(src);
}
#[test]
fn format_range_end_char_is_utf16_not_bytes() {
// Last line contains "é" (2 bytes, 1 UTF-16 unit) so byte len != UTF-16 len.
// format_range returns None when no formatter is installed, but when it does
// return an edit the end character must be the UTF-16 length, not byte length.
let src = "<?php\n$x = 1;\n$y = \"café\";\n";
let range = Range {
start: Position {
line: 2,
character: 0,
},
end: Position {
line: 2,
character: 0,
},
};
// We can't force a formatter to be installed, so we verify the computation
// directly by checking that the UTF-16 length of the last line differs from
// its byte length (ensuring the test would have caught the bug).
let last_line = "$y = \"café\";";
let byte_len = last_line.len() as u32;
let utf16_len: u32 = last_line.chars().map(|c| c.len_utf16() as u32).sum();
assert_ne!(
byte_len, utf16_len,
"test requires a line where byte len != UTF-16 len"
);
// "café" has 12 chars, each a BMP code point (é = 1 UTF-16 unit), so 12 UTF-16 units.
// é is 2 bytes in UTF-8, so byte length is 13.
assert_eq!(utf16_len, 12);
assert_eq!(byte_len, 13);
// Smoke-check: function must not panic regardless of formatter availability.
let _ = format_range(src, range);
}
#[test]
fn format_range_does_not_panic_on_empty_source() {
let range = Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
};
let _ = format_range("<?php\n", range);
}
#[test]
fn format_document_returns_edit_or_none() {
let src = "<?php\nfunction foo() { }\n";
let result = format_document(src);
// Either None (no formatter installed) or Some with a single edit
if let Some(edits) = result {
assert_eq!(edits.len(), 1);
assert_eq!(
edits[0].range.start,
Position {
line: 0,
character: 0
}
);
}
}
}