use lsp_types::{DocumentFormattingParams, Position, Range, TextEdit};
use rustledger_parser::ParseResult;
#[cfg(test)]
use rustledger_parser::format::format_source;
use rustledger_parser::format::{format_source_with_parsed, lf_to_crlf_outside_strings};
use super::utils::{LineIndex, PositionEncoding};
pub fn handle_formatting(
_params: &DocumentFormattingParams,
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<Vec<TextEdit>> {
if let Some(edits) = format_document(source, parse_result, encoding) {
return Some(edits);
}
if !parse_result.errors.is_empty() {
return surface_cleanup_edits(source, encoding);
}
None
}
pub fn format_document(
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<Vec<TextEdit>> {
if !parse_result.errors.is_empty() {
return None;
}
let mut formatted = format_source_with_parsed(parse_result, source);
if source.contains("\r\n") {
formatted = lf_to_crlf_outside_strings(&formatted);
}
if formatted == source {
return None;
}
Some(minimal_diff_edits(source, &formatted, encoding))
}
#[must_use]
pub fn surface_cleanup_edits(source: &str, encoding: PositionEncoding) -> Option<Vec<TextEdit>> {
let mut edits = Vec::new();
for (line_num, line) in source.split('\n').enumerate() {
let line_num = line_num as u32;
let cleaned = clean_line(line);
if cleaned == line {
continue;
}
let line_end_col: u32 = match encoding {
PositionEncoding::Utf8 => line.len() as u32,
PositionEncoding::Utf16 => line.encode_utf16().count() as u32,
};
edits.push(TextEdit {
range: Range {
start: Position::new(line_num, 0),
end: Position::new(line_num, line_end_col),
},
new_text: cleaned,
});
}
if edits.is_empty() { None } else { Some(edits) }
}
fn clean_line(line: &str) -> String {
let (body, cr) = match line.strip_suffix('\r') {
Some(b) => (b, true),
None => (line, false),
};
let mut out = String::with_capacity(body.len());
let mut leading = true;
for c in body.chars() {
if leading {
if c == '\t' {
out.push_str(" ");
continue;
}
if c == ' ' {
out.push(' ');
continue;
}
leading = false;
}
out.push(c);
}
let trimmed_len = out.trim_end_matches([' ', '\t']).len();
out.truncate(trimmed_len);
if cr {
out.push('\r');
}
out
}
fn minimal_diff_edits(source: &str, formatted: &str, encoding: PositionEncoding) -> Vec<TextEdit> {
use similar::{DiffTag, TextDiff};
let src_rope = ropey::Rope::from_str(source);
let fmt_rope = ropey::Rope::from_str(formatted);
let diff = TextDiff::from_lines(source, formatted);
let mut edits: Vec<TextEdit> = Vec::new();
let src_index = LineIndex::new(source, encoding);
for op in diff.ops() {
match op.tag() {
DiffTag::Equal => {}
DiffTag::Delete | DiffTag::Insert | DiffTag::Replace => {
let old = op.old_range();
let new = op.new_range();
let src_start = line_idx_to_byte(&src_rope, old.start);
let src_end = line_idx_to_byte(&src_rope, old.end);
let fmt_start = line_idx_to_byte(&fmt_rope, new.start);
let fmt_end = line_idx_to_byte(&fmt_rope, new.end);
let src_slice = &source[src_start..src_end];
let fmt_slice = &formatted[fmt_start..fmt_end];
let (sub_start, sub_end, sub_new) =
narrow_single_line_replace(src_slice, fmt_slice);
if sub_start == sub_end && sub_new.is_empty() {
continue;
}
let edit_start = src_start + sub_start;
let edit_end = src_start + sub_end;
let (sl, sc) = src_index.offset_to_position(edit_start);
let (el, ec) = src_index.offset_to_position(edit_end);
edits.push(TextEdit {
range: Range {
start: Position::new(sl, sc),
end: Position::new(el, ec),
},
new_text: sub_new.to_string(),
});
}
}
}
edits
}
fn narrow_single_line_replace<'a>(src_slice: &str, fmt_slice: &'a str) -> (usize, usize, &'a str) {
let src_terminated = src_slice.ends_with('\n');
let fmt_terminated = fmt_slice.ends_with('\n');
if src_slice.is_empty() || fmt_slice.is_empty() || src_terminated != fmt_terminated {
return (0, src_slice.len(), fmt_slice);
}
let src_body = src_slice.strip_suffix('\n').unwrap_or(src_slice);
let fmt_body = fmt_slice.strip_suffix('\n').unwrap_or(fmt_slice);
if src_body.contains('\n') || fmt_body.contains('\n') {
return (0, src_slice.len(), fmt_slice);
}
let s = src_body.as_bytes();
let f = fmt_body.as_bytes();
let mut prefix = 0;
let max_prefix = s.len().min(f.len());
while prefix < max_prefix && s[prefix] == f[prefix] {
prefix += 1;
}
while prefix > 0 && (!src_body.is_char_boundary(prefix) || !fmt_body.is_char_boundary(prefix)) {
prefix -= 1;
}
let mut suffix = 0;
let max_suffix = (s.len() - prefix).min(f.len() - prefix);
while suffix < max_suffix && s[s.len() - 1 - suffix] == f[f.len() - 1 - suffix] {
suffix += 1;
}
while suffix > 0
&& (!src_body.is_char_boundary(src_body.len() - suffix)
|| !fmt_body.is_char_boundary(fmt_body.len() - suffix))
{
suffix -= 1;
}
(
prefix,
s.len() - suffix,
&fmt_body[prefix..f.len() - suffix],
)
}
fn line_idx_to_byte(rope: &ropey::Rope, line: usize) -> usize {
if line >= rope.len_lines() {
rope.len_bytes()
} else {
rope.line_to_byte(line)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handlers::utils::LineIndex;
use rustledger_parser::parse;
fn apply(source: &str, edits: &[TextEdit]) -> String {
let mut sorted: Vec<&TextEdit> = edits.iter().collect();
sorted.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
let mut out = source.to_string();
for edit in sorted {
let idx = LineIndex::new(&out, PositionEncoding::Utf16);
let start = idx
.position_to_offset(edit.range.start.line, edit.range.start.character)
.expect("edit start in bounds");
let end = idx
.position_to_offset(edit.range.end.line, edit.range.end.character)
.expect("edit end in bounds");
out.replace_range(start..end, &edit.new_text);
}
out
}
fn params() -> DocumentFormattingParams {
DocumentFormattingParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
options: Default::default(),
work_done_progress_params: Default::default(),
}
}
fn assert_well_formed(edits: &[TextEdit]) {
for edit in edits {
let s = edit.range.start;
let e = edit.range.end;
assert!(
(e.line, e.character) >= (s.line, s.character),
"malformed range: end {e:?} < start {s:?} for edit {edit:?}"
);
}
}
#[test]
fn removes_trailing_whitespace() {
let source = "2024-01-01 open Assets:Bank USD \n";
let result = parse(source);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
assert_eq!(after, "2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn converts_tabs_to_spaces() {
let source = "2024-01-15 * \"Test\"\n\tAssets:Bank -5.00 USD\n\tExpenses:Food\n";
let result = parse(source);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
assert!(!after.contains('\t'), "got {after:?}");
}
#[test]
fn preserves_interleaved_metadata_1142() {
let source = "\
2024-01-15 * \"Test\"
Assets:Bank -50.00 USD
effective_date: 2024-01-20
Expenses:Food 50.00 USD
effective_date: 2024-01-21
";
let result = parse(source);
assert!(
result.errors.is_empty(),
"parse errors: {:?}",
result.errors
);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
let after_lines: Vec<&str> = after.lines().collect();
assert_eq!(
after_lines.get(2).copied(),
Some(" effective_date: 2024-01-20"),
);
assert_eq!(
after_lines.get(4).copied(),
Some(" effective_date: 2024-01-21"),
);
let bank_line = after.lines().find(|l| l.contains("Assets:Bank")).unwrap();
let food_line = after.lines().find(|l| l.contains("Expenses:Food")).unwrap();
assert_eq!(
bank_line.find("USD"),
food_line.find("USD"),
"amounts must align: {bank_line:?} / {food_line:?}"
);
}
#[test]
fn preserves_trailing_comment_on_posting() {
let source = "\
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD ; my comment
Expenses:Food
";
let result = parse(source);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
assert!(after.contains("; my comment"), "got {after:?}");
}
#[test]
fn lsp_matches_format_source() {
let source = "\
2024-01-01 open Assets:Bank
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
";
let result = parse(source);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
let cli = format_source(source);
assert_eq!(after, cli);
}
#[test]
fn source_without_trailing_newline_gets_one() {
let source = "; comment";
let result = parse(source);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
assert_eq!(after, "; comment\n");
}
#[test]
fn blank_only_file_is_canonical() {
let source = "\n\n\n\n";
let result = parse(source);
assert_eq!(format_source(source), "\n");
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("blanks-only file should reflow to a single newline");
assert_well_formed(&edits);
let after = apply(source, &edits);
assert_eq!(after, "\n");
}
#[test]
fn non_ascii_payee_roundtrips() {
let source = "2024-01-15 * \"Café\"\n Assets:Bank -1.00 USD\n Expenses:Food\n";
let result = parse(source);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
let cli = format_source(source);
assert_eq!(after, cli);
}
#[test]
fn emits_per_hunk_edits_for_far_apart_changes() {
let source = "\
2024-01-15 * \"A\"
Assets:Bank -5.00 USD
Expenses:Food
; unchanged separator block
; ----------------------------------
; (these lines must not appear in any edit's range)
2024-02-15 * \"B\"
Assets:Bank -7.00 USD
Expenses:Coffee
";
let result = parse(source);
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
let cli = format_source(source);
assert_eq!(after, cli);
assert!(edits.len() >= 2, "per-hunk failed, got {edits:#?}");
for edit in &edits {
assert!(edit.range.end.line - edit.range.start.line < 8);
}
}
#[test]
fn parse_errors_get_surface_cleanup_via_handle_formatting() {
let source = "2024-01-01 open Assets:Bank \n2024-01-02 not_a_directive\n\tAssets:Bank\n";
let result = parse(source);
assert!(!result.errors.is_empty());
let edits = handle_formatting(¶ms(), source, &result, PositionEncoding::Utf16)
.expect("expected cleanup edits");
assert_well_formed(&edits);
let after = apply(source, &edits);
assert!(!after.contains('\t'));
assert!(after.contains("not_a_directive"));
}
#[test]
fn format_document_returns_none_on_parse_errors() {
let source = "2024-01-01 not_a_directive\n";
let result = parse(source);
assert!(!result.errors.is_empty());
assert!(format_document(source, &result, PositionEncoding::Utf16).is_none());
}
#[test]
fn surface_cleanup_preserves_crlf() {
let source = "first\r\nsecond \r\nthird\t\r\n";
let edits = surface_cleanup_edits(source, PositionEncoding::Utf16)
.expect("trailing whitespace requires edits");
let after = apply(source, &edits);
assert!(after.contains("first\r\n"), "first CRLF gone: {after:?}");
assert!(after.contains("second\r\n"), "second CRLF gone: {after:?}");
assert!(after.contains("third\r\n"), "third CRLF gone: {after:?}");
assert!(!after.contains(" \r\n"));
assert!(!after.contains("\t\r\n"));
}
#[test]
fn surface_cleanup_only_replaces_leading_tabs() {
let source = "\t2024-01-01 open Assets:Bank \"col1\tcol2\"\n";
let edits = surface_cleanup_edits(source, PositionEncoding::Utf16)
.expect("leading tab requires an edit");
let after = apply(source, &edits);
assert!(!after.starts_with('\t'));
assert!(after.starts_with(" "));
assert!(
after.contains("col1\tcol2"),
"tab inside string literal was clobbered: {after:?}"
);
}
#[test]
fn surface_cleanup_noop_on_canonical_input() {
let source = "2024-01-01 open Assets:Bank USD\n";
assert!(surface_cleanup_edits(source, PositionEncoding::Utf16).is_none());
}
#[test]
fn pure_insert_between_unchanged_lines_lands_at_correct_byte() {
let source = "a\nb\n";
let formatted = "a\nX\nb\n";
let edits = minimal_diff_edits(source, formatted, PositionEncoding::Utf16);
let after = apply(source, &edits);
assert_eq!(
after, formatted,
"pure insert anchored at wrong byte: {edits:?}"
);
}
#[test]
fn pure_insert_at_eof_lands_at_correct_byte() {
let source = "a\nb\n";
let formatted = "a\nb\nc\n";
let edits = minimal_diff_edits(source, formatted, PositionEncoding::Utf16);
let after = apply(source, &edits);
assert_eq!(
after, formatted,
"EOF insert anchored at wrong byte: {edits:?}"
);
}
#[test]
fn two_separate_inserts_each_at_correct_byte() {
let source = "a\nb\nc\n";
let formatted = "a\nX\nb\nY\nc\n";
let edits = minimal_diff_edits(source, formatted, PositionEncoding::Utf16);
let after = apply(source, &edits);
assert_eq!(after, formatted, "multi-insert anchored wrong: {edits:?}");
}
#[test]
fn sub_line_precision_for_single_byte_change() {
let source = " Assets:Bank -5.00 USD\n";
let formatted = " Assets:Bank -6.00 USD\n";
let edits = minimal_diff_edits(source, formatted, PositionEncoding::Utf16);
assert_eq!(edits.len(), 1, "{edits:?}");
let edit = &edits[0];
assert_eq!(
edit.new_text, "6",
"should narrow to the changed digit, got new_text={:?}",
edit.new_text
);
assert_eq!(edit.range.start.line, 0);
assert_eq!(edit.range.end.line, 0);
assert_eq!(edit.range.end.character - edit.range.start.character, 1);
let after = apply(source, &edits);
assert_eq!(after, formatted);
}
#[test]
fn similar_compact_pass_keeps_replacements_atomic() {
use similar::{DiffTag, TextDiff};
let source = "alpha\nbeta\ngamma\n";
let formatted = "alpha\nBETA\ngamma\n";
let diff = TextDiff::from_lines(source, formatted);
let non_equal: Vec<_> = diff
.ops()
.iter()
.filter(|op| op.tag() != DiffTag::Equal)
.collect();
assert_eq!(
non_equal.len(),
1,
"expected exactly one non-Equal op; similar's compact pass changed semantics: {non_equal:?}"
);
assert_eq!(non_equal[0].tag(), DiffTag::Replace);
}
}