use lsp_types::{DocumentRangeFormattingParams, Position, Range, TextEdit};
use rustledger_parser::ParseResult;
use rustledger_parser::format::{format_node_range_with_alignment, lf_to_crlf_outside_strings};
use super::formatting::format_document;
use super::utils::{LineIndex, PositionEncoding};
pub fn handle_range_formatting(
params: &DocumentRangeFormattingParams,
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<Vec<TextEdit>> {
if let Some(all_edits) = format_document(source, parse_result, encoding) {
return clip_edits_to_range(params, source, encoding, all_edits);
}
if !parse_result.errors.is_empty() {
return fallback_cst_snap_edit(params, source, parse_result, encoding);
}
None
}
fn clip_edits_to_range(
params: &DocumentRangeFormattingParams,
source: &str,
encoding: PositionEncoding,
all_edits: Vec<TextEdit>,
) -> Option<Vec<TextEdit>> {
let line_index = LineIndex::new(source, encoding);
let range_start_byte =
line_index.position_to_offset(params.range.start.line, params.range.start.character)?;
let mut range_end_byte =
line_index.position_to_offset(params.range.end.line, params.range.end.character)?;
if range_end_byte <= range_start_byte {
return None;
}
let bytes = source.as_bytes();
if range_end_byte < bytes.len() {
match bytes[range_end_byte] {
b'\n' => range_end_byte += 1,
b'\r' if range_end_byte + 1 < bytes.len() && bytes[range_end_byte + 1] == b'\n' => {
range_end_byte += 2;
}
_ => {}
}
}
let kept: Vec<TextEdit> = all_edits
.into_iter()
.filter(|edit| edit_inside_range(&line_index, edit, range_start_byte, range_end_byte))
.collect();
if kept.is_empty() { None } else { Some(kept) }
}
fn edit_inside_range(
line_index: &LineIndex,
edit: &TextEdit,
range_start: usize,
range_end: usize,
) -> bool {
let Some(edit_start) =
line_index.position_to_offset(edit.range.start.line, edit.range.start.character)
else {
return false;
};
let Some(edit_end) =
line_index.position_to_offset(edit.range.end.line, edit.range.end.character)
else {
return false;
};
edit_start >= range_start && edit_end <= range_end
}
fn fallback_cst_snap_edit(
params: &DocumentRangeFormattingParams,
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<Vec<TextEdit>> {
let line_index = LineIndex::new(source, encoding);
let orig_start =
line_index.position_to_offset(params.range.start.line, params.range.start.character)?;
let orig_end =
line_index.position_to_offset(params.range.end.line, params.range.end.character)?;
if orig_end <= orig_start {
return None;
}
let bom_offset: usize = if parse_result.has_leading_bom { 3 } else { 0 };
let cst_start = orig_start.saturating_sub(bom_offset);
let cst_end = orig_end.saturating_sub(bom_offset);
let cst_start_ts = rustledger_parser::TextSize::try_from(cst_start).ok()?;
let cst_end_ts = rustledger_parser::TextSize::try_from(cst_end).ok()?;
let cst_range = rustledger_parser::TextRange::new(cst_start_ts, cst_end_ts);
let node = parse_result.syntax_node();
let (snap_cst, mut new_text) =
format_node_range_with_alignment(&node, cst_range, parse_result.alignment)?;
if source.contains("\r\n") {
new_text = lf_to_crlf_outside_strings(&new_text);
}
let snap_start_byte = u32::from(snap_cst.start()) as usize + bom_offset;
let snap_end_byte = u32::from(snap_cst.end()) as usize + bom_offset;
let (sl, sc) = line_index.offset_to_position(snap_start_byte);
let (el, ec) = line_index.offset_to_position(snap_end_byte);
Some(vec![TextEdit {
range: Range {
start: Position::new(sl, sc),
end: Position::new(el, ec),
},
new_text,
}])
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{DocumentRangeFormattingParams, Position, Range};
use rustledger_parser::parse;
fn params(range: Range) -> DocumentRangeFormattingParams {
DocumentRangeFormattingParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
range,
options: Default::default(),
work_done_progress_params: Default::default(),
}
}
#[test]
fn already_canonical_returns_none() {
let source = "2024-01-01 open Assets:Cash\n";
let result = parse(source);
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(0, 27),
});
assert!(handle_range_formatting(&p, source, &result, PositionEncoding::Utf16).is_none());
}
#[test]
fn fixes_misindentation_in_range() {
let source = "2024-01-15 * \"Coffee\"\n Assets:Bank -5.00 USD\n Expenses:Food\n";
let result = parse(source);
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(3, 0),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert!(!edits.is_empty());
}
#[test]
fn half_open_range_excludes_end_line() {
let source = "2024-01-15 * \"A\"\n Assets:Bank -5.00 USD\n Expenses:Food\n\n2024-02-15 * \"B\"\n Assets:Bank -7.00 USD\n Expenses:Food\n";
let result = parse(source);
let p = params(Range {
start: Position::new(4, 0),
end: Position::new(7, 0),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("expected edits");
for edit in &edits {
assert!(
edit.range.start.line >= 4 && edit.range.end.line <= 7,
"edit {edit:?} escapes the half-open range [4, 7)"
);
}
}
#[test]
fn edits_lie_entirely_inside_range() {
let source = "2024-01-15 * \"A\"\n Assets:Bank -5.00 USD\n Expenses:Food\n\n2024-02-15 * \"B\"\n Assets:Bank -7.00 USD\n Expenses:Food\n";
let result = parse(source);
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(3, 0),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.unwrap_or_default();
let line_index = LineIndex::new(source, PositionEncoding::Utf16);
let range_start = line_index
.position_to_offset(p.range.start.line, p.range.start.character)
.expect("range start in bounds");
let range_end = line_index
.position_to_offset(p.range.end.line, p.range.end.character)
.expect("range end in bounds");
for edit in &edits {
let s = line_index
.position_to_offset(edit.range.start.line, edit.range.start.character)
.expect("edit start in bounds");
let e = line_index
.position_to_offset(edit.range.end.line, edit.range.end.character)
.expect("edit end in bounds");
assert!(
s >= range_start && e <= range_end,
"edit {edit:?} (bytes {s}..{e}) escapes byte range {range_start}..{range_end}"
);
}
}
#[test]
fn zero_width_edit_at_range_start_is_kept() {
let source = "2024-01-01 open Assets:Bank";
let result = parse(source);
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(0, source.encode_utf16().count() as u32),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("expected edits");
assert!(
!edits.is_empty(),
"the trailing-newline insertion must be kept"
);
}
#[test]
fn end_of_line_selection_keeps_line_replace_edit() {
let source = "2024-01-15 * \"Coffee\"\n Assets:Bank -5.00 USD\n";
let result = parse(source);
let line1 = " Assets:Bank -5.00 USD";
let p = params(Range {
start: Position::new(1, 0),
end: Position::new(1, line1.encode_utf16().count() as u32),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("EOL selection should preserve the line-replace edit");
assert!(!edits.is_empty(), "got {edits:?}");
}
#[test]
fn empty_range_is_a_noop() {
let source = "2024-01-01 open Assets:Bank\n";
let result = parse(source);
let p = params(Range {
start: Position::new(0, 5),
end: Position::new(0, 5),
});
assert!(handle_range_formatting(&p, source, &result, PositionEncoding::Utf16).is_none());
}
#[test]
fn cursor_on_empty_line_does_not_get_widened() {
let source = "2024-01-01 open Assets:Bank\n\n2024-01-02 open Assets:Cash\n";
let result = parse(source);
let p = params(Range {
start: Position::new(1, 0),
end: Position::new(1, 0),
});
assert!(
handle_range_formatting(&p, source, &result, PositionEncoding::Utf16).is_none(),
"empty range on '\\n' byte must NOT be widened by the snap"
);
}
#[test]
fn crlf_end_of_line_selection_keeps_line_replace_edit() {
let source = "2024-01-15 * \"Coffee\"\r\n Assets:Bank -5.00 USD\r\n";
let result = parse(source);
let line1 = " Assets:Bank -5.00 USD";
let p = params(Range {
start: Position::new(1, 0),
end: Position::new(1, line1.encode_utf16().count() as u32),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("CRLF EOL selection should preserve the line-replace edit");
assert!(!edits.is_empty(), "got {edits:?}");
}
#[test]
fn crlf_whole_document_selection_produces_edits() {
let source = "2024-01-15 * \"Coffee\"\r\n Assets:Bank -5.00 USD\r\n";
let result = parse(source);
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(2, 0),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("whole-document CRLF selection should produce edits");
assert!(!edits.is_empty(), "got {edits:?}");
}
#[test]
fn parse_errors_with_only_broken_content_returns_none() {
let source = "}}}garbage{{{\n";
let result = parse(source);
assert!(!result.errors.is_empty(), "expected parse error");
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(1, 0),
});
assert!(
handle_range_formatting(&p, source, &result, PositionEncoding::Utf16).is_none(),
"selection covers only ERROR_NODE; fallback must surface None",
);
}
#[test]
fn parse_errors_with_valid_directive_in_selection_returns_fallback_edit() {
let source = "2024-01-01 open Assets:Bank \n}}}not_a_directive\n";
let result = parse(source);
assert!(!result.errors.is_empty(), "expected parse error on line 1");
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(1, 0),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("CST-snap fallback must fire");
assert_eq!(edits.len(), 1, "expected one fallback edit, got {edits:?}");
let edit = &edits[0];
assert_eq!(edit.new_text, "2024-01-01 open Assets:Bank\n");
assert_eq!(edit.range.start, Position::new(0, 0));
let line0_end = "2024-01-01 open Assets:Bank \n".encode_utf16().count() as u32;
assert_eq!(
edit.range.end,
Position::new(1, 0),
"replaced range must cover the ENTIRE line 0 (through its newline), \
ending at the start of line 1; otherwise the original line 0 \
would persist alongside the formatted text",
);
let _ = line0_end; }
#[test]
fn parse_errors_with_error_node_in_snap_returns_none() {
let source = "\
2024-01-01 open Assets:Bank USD
}}}garbage{{{
2024-01-31 close Assets:Bank
";
let result = parse(source);
assert!(!result.errors.is_empty(), "expected parse error");
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(3, 0),
});
assert!(
handle_range_formatting(&p, source, &result, PositionEncoding::Utf16).is_none(),
"selection covering valid + ERROR_NODE + valid must bail; \
deleting the ERROR_NODE is content loss the user did not opt into",
);
}
#[test]
fn parse_errors_with_crlf_source_emits_crlf_replacement() {
let source = "2024-01-01 open Assets:Bank \r\n}}}garbage\r\n";
let result = parse(source);
assert!(!result.errors.is_empty(), "expected parse error");
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(1, 0),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("CRLF source still gets a fallback edit");
assert_eq!(edits.len(), 1);
let edit = &edits[0];
assert!(
edit.new_text.contains("\r\n"),
"CRLF source must produce CRLF replacement text; got {:?}",
edit.new_text,
);
assert!(
!edit.new_text.contains("\n") || edit.new_text.contains("\r\n"),
"no bare LF allowed in replacement text on CRLF source; got {:?}",
edit.new_text,
);
}
#[test]
fn parse_errors_with_cursor_only_request_returns_none() {
let source = "2024-01-01 open Assets:Bank\n}}}garbage\n";
let result = parse(source);
assert!(!result.errors.is_empty(), "expected parse error");
let p = params(Range {
start: Position::new(0, 5),
end: Position::new(0, 5),
});
assert!(
handle_range_formatting(&p, source, &result, PositionEncoding::Utf16).is_none(),
"cursor-only request on a parse-error file must return None, \
matching empty_range_is_a_noop on the happy path",
);
}
#[test]
fn parse_errors_with_bom_fallback_emits_original_frame_range() {
let source = "\u{FEFF}2024-01-01 open Assets:Bank\n}}}garbage\n";
let result = parse(source);
assert!(result.has_leading_bom, "fixture must have a BOM");
assert!(!result.errors.is_empty(), "expected parse error");
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(1, 0),
});
let edits = handle_range_formatting(&p, source, &result, PositionEncoding::Utf16)
.expect("CST-snap fallback must fire on BOM-prefixed broken file");
assert_eq!(edits.len(), 1);
let edit = &edits[0];
assert_eq!(
edit.range.start,
Position::new(0, 1),
"fallback emit must add `bom_offset` back into the original-source frame; got {:?}",
edit.range.start,
);
}
#[test]
fn clean_file_already_canonical_returns_none_not_fallback() {
let source = "2024-01-01 open Assets:Cash\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"fixture must be clean for this regression check",
);
let p = params(Range {
start: Position::new(0, 0),
end: Position::new(1, 0),
});
assert!(
handle_range_formatting(&p, source, &result, PositionEncoding::Utf16).is_none(),
"clean + canonical file must return None; fallback must not fire",
);
}
}