php_lsp/editing/
formatting.rs1use std::process::{Command, Stdio};
15use tower_lsp_server::ls_types::{Position, Range, TextEdit};
16
17pub fn format_document(source: &str) -> Option<Vec<TextEdit>> {
20 let formatted = run_formatter(source)?;
21 if formatted == source {
22 return None; }
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
45pub 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 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 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 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 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 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 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}