use std::process::{Command, Stdio};
use tower_lsp_server::ls_types::{Position, Range, TextEdit};
use crate::text::end_position;
pub fn format_document(source: &str) -> Option<Vec<TextEdit>> {
whole_document_edit(source, run_formatter(source)?)
}
fn whole_document_edit(source: &str, formatted: String) -> Option<Vec<TextEdit>> {
if formatted == source {
return None;
}
Some(vec![TextEdit {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: end_position(source),
},
new_text: formatted,
}])
}
pub fn format_range(source: &str, range: Range) -> Option<Vec<TextEdit>> {
range_edit(source, range, run_formatter)
}
fn range_edit(
source: &str,
range: Range,
format: impl Fn(&str) -> Option<String>,
) -> Option<Vec<TextEdit>> {
let last_line = if range.end.character == 0 && range.end.line > range.start.line {
range.end.line - 1
} else {
range.end.line
};
let start = line_start_byte(source, range.start.line)?;
let end = line_start_byte(source, last_line + 1).unwrap_or(source.len());
let snippet = &source[start..end];
if snippet.trim().is_empty() {
return None;
}
let crlf = snippet.contains("\r\n");
let lf_snippet = snippet.replace("\r\n", "\n");
let formatted = if lf_snippet.trim_start().starts_with("<?php") {
format(&lf_snippet)?
} else {
format_fragment(&lf_snippet, &format)?
};
let mut formatted = formatted.trim_end_matches('\n').to_string();
if lf_snippet.ends_with('\n') {
formatted.push('\n');
}
if crlf {
formatted = formatted.replace('\n', "\r\n");
}
if formatted == snippet {
return None;
}
let end_pos = if end == source.len() {
end_position(source)
} else {
Position {
line: last_line + 1,
character: 0,
}
};
Some(vec![TextEdit {
range: Range {
start: Position {
line: range.start.line,
character: 0,
},
end: end_pos,
},
new_text: formatted,
}])
}
fn format_fragment(snippet: &str, format: &impl Fn(&str) -> Option<String>) -> Option<String> {
let indent = snippet
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| &l[..l.len() - l.trim_start().len()])
.min_by_key(|ws| ws.len())
.unwrap_or("");
let dedented: String = snippet
.lines()
.map(|l| l.strip_prefix(indent).unwrap_or(l.trim_start()))
.collect::<Vec<_>>()
.join("\n");
let formatted = format(&format!("<?php\n{dedented}\n"))?;
let body = formatted.strip_prefix("<?php")?.trim_start_matches('\n');
let leading_blank_lines = snippet.len() - snippet.trim_start_matches('\n').len();
let mut out = "\n".repeat(leading_blank_lines);
for line in body.lines() {
if !line.is_empty() {
out.push_str(indent);
}
out.push_str(line);
out.push('\n');
}
Some(out)
}
fn line_start_byte(source: &str, line: u32) -> Option<usize> {
if line == 0 {
return Some(0);
}
source
.match_indices('\n')
.nth(line as usize - 1)
.map(|(i, _)| i + 1)
.filter(|&i| i < source.len())
}
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> {
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) {
let text = String::from_utf8(output.stdout).ok()?;
if !text.is_empty() {
return Some(text);
}
}
None
}
fn try_phpcbf(source: &str) -> Option<String> {
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()
})?;
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 format_range_past_end_of_document_returns_none() {
let range = Range {
start: Position {
line: 10,
character: 0,
},
end: Position {
line: 12,
character: 0,
},
};
assert_eq!(format_range("<?php\n$a = 1;\n", range), None);
assert_eq!(format_range("", range), None);
}
fn apply(source: &str, edits: &[TextEdit]) -> String {
let mut text = source.to_string();
for edit in edits.iter().rev() {
crate::text::apply_content_change(&mut text, edit.range, &edit.new_text);
}
text
}
#[test]
fn whole_document_edit_replaces_trailing_newline() {
let source = "<?php\nfunction f( $x ){}\n";
let formatted = "<?php\nfunction f($x) {}\n";
let edits = whole_document_edit(source, formatted.to_string()).unwrap();
assert_eq!(apply(source, &edits), formatted);
assert_eq!(whole_document_edit(formatted, formatted.to_string()), None);
}
#[test]
fn whole_document_edit_covers_crlf_source() {
let source = "<?php\r\nfunction f( $x ){}\r\n";
let formatted = "<?php\r\nfunction f($x) {}\r\n";
let edits = whole_document_edit(source, formatted.to_string()).unwrap();
assert_eq!(apply(source, &edits), formatted);
}
fn range(start_line: u32, end_line: u32, end_char: u32) -> Range {
Range {
start: Position {
line: start_line,
character: 0,
},
end: Position {
line: end_line,
character: end_char,
},
}
}
fn fake_formatter(src: &str) -> Option<String> {
let body = src.strip_prefix("<?php\n")?.trim_start_matches('\n');
let body = body.replace("( $x )", "($x)");
Some(format!("<?php\n\n{}\n", body.trim_end()))
}
#[test]
fn range_edit_keeps_single_newline_and_indent() {
let source = "<?php\nclass A {\n function f( $x ) {}\n function g() {}\n}\n";
let edits = range_edit(source, range(2, 2, 10), fake_formatter).unwrap();
assert_eq!(
apply(source, &edits),
"<?php\nclass A {\n function f($x) {}\n function g() {}\n}\n"
);
}
#[test]
fn range_edit_preserves_crlf() {
let source = "<?php\r\nf( $x );\r\ng( $x );\r\n";
let edits = range_edit(source, range(1, 3, 0), fake_formatter).unwrap();
assert_eq!(apply(source, &edits), "<?php\r\nf($x);\r\ng($x);\r\n");
}
#[test]
fn range_edit_last_line_without_newline() {
let source = "<?php\nf( $x );";
let edits = range_edit(source, range(1, 1, 3), fake_formatter).unwrap();
assert_eq!(apply(source, &edits), "<?php\nf($x);");
}
#[test]
fn range_edit_unchanged_returns_none() {
let source = "<?php\n f($x);\n";
assert_eq!(range_edit(source, range(1, 1, 5), fake_formatter), None);
}
}