1use std::process::{Command, Stdio};
15use tower_lsp_server::ls_types::{Position, Range, TextEdit};
16
17use crate::text::end_position;
18
19pub 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
41pub 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 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
103fn 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 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
132fn 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 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 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 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 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 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}