use lsp_types::{Position, Range, SelectionRange, SelectionRangeParams};
use rustledger_parser::{
ParseResult, SyntaxKind, SyntaxNode, SyntaxToken, TextRange, TextSize, TokenAtOffset,
};
use super::utils::{LineIndex, PositionEncoding, is_word_char};
pub fn handle_selection_range(
params: &SelectionRangeParams,
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<Vec<SelectionRange>> {
let cst = parse_result.syntax_node();
let line_index = LineIndex::new(source, encoding);
let bom_offset: usize = if parse_result.has_leading_bom { 3 } else { 0 };
let mut results = Vec::with_capacity(params.positions.len());
for position in ¶ms.positions {
results.push(
compute_selection_range(&cst, &line_index, *position, bom_offset).unwrap_or(
SelectionRange {
range: Range {
start: *position,
end: *position,
},
parent: None,
},
),
);
}
Some(results)
}
fn compute_selection_range(
cst: &SyntaxNode,
line_index: &LineIndex<'_>,
position: Position,
bom_offset: usize,
) -> Option<SelectionRange> {
let orig_offset = line_index.position_to_offset(position.line, position.character)?;
let cst_offset = orig_offset.checked_sub(bom_offset)?;
let offset_ts = TextSize::try_from(cst_offset).ok()?;
let token = match cst.token_at_offset(offset_ts) {
TokenAtOffset::Single(t) => t,
TokenAtOffset::Between(left, right) => prefer_word_token(left, right),
TokenAtOffset::None => return None,
};
let mut ranges: Vec<Range> = Vec::new();
let token_text = token.text();
let token_start_byte: usize = u32::from(token.text_range().start()) as usize;
let offset_in_token = cst_offset
.saturating_sub(token_start_byte)
.min(token_text.len());
if let Some(word) =
word_range_in_token(&token, token_text, offset_in_token, line_index, bom_offset)
{
ranges.push(word);
}
match token.kind() {
SyntaxKind::ACCOUNT => {
if let Some(seg) = account_segment_range_in_token(
&token,
token_text,
offset_in_token,
line_index,
bom_offset,
) && Some(seg) != ranges.last().copied()
{
ranges.push(seg);
}
}
SyntaxKind::STRING => {
if let Some(interior) =
string_interior_range_in_token(&token, token_text, line_index, bom_offset)
&& Some(interior) != ranges.last().copied()
{
ranges.push(interior);
}
}
_ => {}
}
let token_range = node_or_token_range(token.text_range(), line_index, bom_offset);
if Some(token_range) != ranges.last().copied() {
ranges.push(token_range);
}
let mut node = token.parent();
while let Some(n) = node {
if n.kind() != SyntaxKind::ERROR_NODE {
let r = node_or_token_range(n.text_range(), line_index, bom_offset);
if Some(r) != ranges.last().copied() {
ranges.push(r);
}
}
node = n.parent();
}
Some(build_hierarchy(ranges))
}
fn prefer_word_token(left: SyntaxToken, right: SyntaxToken) -> SyntaxToken {
if token_priority(right.kind()) > token_priority(left.kind()) {
right
} else {
left
}
}
fn token_priority(kind: SyntaxKind) -> u8 {
if kind.is_trivia() || kind == SyntaxKind::ERROR_TOKEN {
return 0;
}
match kind {
SyntaxKind::COLON
| SyntaxKind::COMMA
| SyntaxKind::AT
| SyntaxKind::AT_AT
| SyntaxKind::PLUS
| SyntaxKind::MINUS
| SyntaxKind::STAR
| SyntaxKind::SLASH
| SyntaxKind::L_PAREN
| SyntaxKind::R_PAREN
| SyntaxKind::L_BRACE
| SyntaxKind::R_BRACE
| SyntaxKind::L_DOUBLE_BRACE
| SyntaxKind::R_DOUBLE_BRACE
| SyntaxKind::L_BRACE_HASH
| SyntaxKind::TILDE
| SyntaxKind::META_KEY => 1,
_ => 2,
}
}
fn build_hierarchy(ranges: Vec<Range>) -> SelectionRange {
assert!(
!ranges.is_empty(),
"compute_selection_range must always emit at least the cursor's token range"
);
let mut parent: Option<Box<SelectionRange>> = None;
for range in ranges.into_iter().rev() {
parent = Some(Box::new(SelectionRange { range, parent }));
}
*parent.expect("non-empty ranges (asserted above)")
}
fn node_or_token_range(range: TextRange, line_index: &LineIndex<'_>, bom_offset: usize) -> Range {
let start_byte: usize = u32::from(range.start()) as usize + bom_offset;
let end_byte: usize = u32::from(range.end()) as usize + bom_offset;
let (start_line, start_col) = line_index.offset_to_position(start_byte);
let (end_line, end_col) = line_index.offset_to_position(end_byte);
Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
}
}
fn word_range_in_token(
token: &SyntaxToken,
token_text: &str,
offset_in_token: usize,
line_index: &LineIndex<'_>,
bom_offset: usize,
) -> Option<Range> {
let token_start: usize = u32::from(token.text_range().start()) as usize;
let here = token_text[offset_in_token..].chars().next();
let on_word = here.is_some_and(is_word_char);
if !on_word {
let prev = token_text[..offset_in_token].chars().next_back();
if !prev.is_some_and(is_word_char) {
return None;
}
}
let mut start_byte = offset_in_token;
while let Some((b, c)) = token_text[..start_byte].char_indices().next_back() {
if !is_word_char(c) {
break;
}
start_byte = b;
}
let mut end_byte = offset_in_token;
for (b, c) in token_text[offset_in_token..].char_indices() {
if !is_word_char(c) {
break;
}
end_byte = offset_in_token + b + c.len_utf8();
}
if start_byte == end_byte {
return None;
}
if start_byte == 0 && end_byte == token_text.len() {
return None;
}
let abs_start = token_start + start_byte + bom_offset;
let abs_end = token_start + end_byte + bom_offset;
let (sl, sc) = line_index.offset_to_position(abs_start);
let (el, ec) = line_index.offset_to_position(abs_end);
Some(Range {
start: Position::new(sl, sc),
end: Position::new(el, ec),
})
}
fn account_segment_range_in_token(
token: &SyntaxToken,
token_text: &str,
offset_in_token: usize,
line_index: &LineIndex<'_>,
bom_offset: usize,
) -> Option<Range> {
let token_start: usize = u32::from(token.text_range().start()) as usize;
let clamped = offset_in_token.min(token_text.len().saturating_sub(1));
let mut start_byte = clamped;
while let Some((b, c)) = token_text[..start_byte].char_indices().next_back() {
if c == ':' {
break;
}
start_byte = b;
}
let mut end_byte = clamped;
for (b, c) in token_text[clamped..].char_indices() {
if c == ':' {
break;
}
end_byte = clamped + b + c.len_utf8();
}
if start_byte == end_byte {
return None;
}
if start_byte == 0 && end_byte == token_text.len() {
return None;
}
let abs_start = token_start + start_byte + bom_offset;
let abs_end = token_start + end_byte + bom_offset;
let (sl, sc) = line_index.offset_to_position(abs_start);
let (el, ec) = line_index.offset_to_position(abs_end);
Some(Range {
start: Position::new(sl, sc),
end: Position::new(el, ec),
})
}
fn string_interior_range_in_token(
token: &SyntaxToken,
token_text: &str,
line_index: &LineIndex<'_>,
bom_offset: usize,
) -> Option<Range> {
if token_text.len() < 2 {
return None;
}
if !token_text.starts_with('"') || !token_text.ends_with('"') {
return None;
}
let token_start: usize = u32::from(token.text_range().start()) as usize;
let abs_start = token_start + 1 + bom_offset;
let abs_end = token_start + token_text.len() - 1 + bom_offset;
let (sl, sc) = line_index.offset_to_position(abs_start);
let (el, ec) = line_index.offset_to_position(abs_end);
Some(Range {
start: Position::new(sl, sc),
end: Position::new(el, ec),
})
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{Position, TextDocumentIdentifier};
fn run(source: &str, position: Position) -> Vec<Range> {
let params = SelectionRangeParams {
text_document: TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
positions: vec![position],
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let parse_result = rustledger_parser::parse(source);
let result =
handle_selection_range(¶ms, source, &parse_result, PositionEncoding::Utf16)
.unwrap();
assert_eq!(result.len(), 1);
let mut out = Vec::new();
let mut cur: Option<&SelectionRange> = Some(&result[0]);
while let Some(r) = cur {
out.push(r.range);
cur = r.parent.as_deref();
}
out
}
#[test]
fn account_segment_then_account_then_posting_then_transaction() {
let source = "2024-01-15 * \"Coffee\"\n Assets:Bank:Checking -5.00 USD\n Expenses:Food\n";
let ranges = run(source, Position::new(1, 11)); assert!(ranges.len() >= 4, "got {} ranges: {ranges:?}", ranges.len());
assert_eq!(
ranges[0],
Range {
start: Position::new(1, 9),
end: Position::new(1, 13)
},
"deepest range should be the 'Bank' word/segment",
);
for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
}
#[test]
fn number_token_inside_amount() {
let source = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n Expenses:Misc 5.00 USD\n";
let ranges = run(source, Position::new(1, 17)); assert!(ranges.len() >= 4, "got {} ranges: {ranges:?}", ranges.len());
for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
}
#[test]
fn string_interior_then_string_then_transaction_header() {
let source = "2024-01-15 * \"Coffee Shop\"\n Assets:Cash -1.00 USD\n Expenses:Food\n";
let ranges = run(source, Position::new(0, 17)); assert!(ranges.len() >= 4, "got {} ranges: {ranges:?}", ranges.len());
for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
}
#[test]
fn cursor_in_whitespace_at_line_start_picks_account() {
let source = "2024-01-15 * \"x\"\n Assets:Cash -1.00 USD\n Expenses:Misc 1.00 USD\n";
let ranges = run(source, Position::new(1, 2)); assert!(ranges.len() >= 3, "got {} ranges: {ranges:?}", ranges.len());
}
#[test]
fn posting_with_interleaved_metadata_is_not_corrupted() {
let source = "2024-01-15 * \"FX\"\n Assets:USD -100.00 USD\n effective_date: 2024-01-16\n Assets:EUR 92.00 EUR\n effective_date: 2024-01-17\n";
let ranges = run(source, Position::new(3, 5)); assert!(ranges.len() >= 4, "got {} ranges: {ranges:?}", ranges.len());
for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
}
#[test]
fn out_of_bounds_position_returns_collapsed_range() {
let source = "2024-01-15 open Assets:A\n";
let ranges = run(source, Position::new(99, 99));
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, ranges[0].end);
}
#[test]
fn number_inside_cost_spec() {
let source = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL {500.00 USD}\n Assets:Cash -5000.00 USD\n";
let ranges = run(source, Position::new(1, 28)); assert!(ranges.len() >= 4, "got {} ranges: {ranges:?}", ranges.len());
for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
assert!(
ranges
.iter()
.any(|r| r.start.character == 27 && r.end.character == 39),
"COST_SPEC range {{500.00 USD}} (cols 27..39) not in chain: {ranges:?}",
);
}
#[test]
fn number_inside_price_annotation() {
let source =
"2024-01-15 * \"fx\"\n Assets:USD -100 USD @ 1.00 EUR\n Assets:EUR 100 EUR\n";
let ranges = run(source, Position::new(1, 24)); assert!(ranges.len() >= 4, "got {} ranges: {ranges:?}", ranges.len());
for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
}
#[test]
fn string_inside_option_directive() {
let source = "option \"title\" \"My Book\"\n";
let ranges = run(source, Position::new(0, 19)); assert!(ranges.len() >= 4, "got {} ranges: {ranges:?}", ranges.len());
for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
}
#[test]
fn error_node_ancestors_are_skipped_in_broken_syntax() {
let source = "2024-01-15 * \"unterminated\n Assets:Cash -1.00 USD\n";
let ranges = run(source, Position::new(0, 18)); for win in ranges.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
let last = ranges.last().copied().expect("non-empty chain");
for r in ranges.iter().take(ranges.len() - 1) {
assert!(
!range_contains(*r, last) || *r == last,
"non-root range {r:?} swallowed the whole source - ERROR_NODE not skipped",
);
}
}
#[test]
fn utf8_encoding_emits_correct_positions_on_non_ascii_content() {
let source = "2024-01-15 * \"x\"\n Активы:Банк -5.00 USD\n Expenses:Misc 5.00 USD\n";
let params = SelectionRangeParams {
text_document: TextDocumentIdentifier {
uri: "file:///utf8.beancount".parse().unwrap(),
},
positions: vec![Position::new(1, 17)],
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let parse_result = rustledger_parser::parse(source);
let result = handle_selection_range(¶ms, source, &parse_result, PositionEncoding::Utf8)
.expect("Some");
assert_eq!(result.len(), 1);
let mut chain = Vec::new();
let mut cur: Option<&SelectionRange> = Some(&result[0]);
while let Some(r) = cur {
chain.push(r.range);
cur = r.parent.as_deref();
}
assert!(chain.len() >= 3, "got {} ranges: {chain:?}", chain.len());
for win in chain.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}"
);
}
}
fn range_contains(outer: Range, inner: Range) -> bool {
pos_le(outer.start, inner.start) && pos_le(inner.end, outer.end)
}
fn pos_le(a: Position, b: Position) -> bool {
(a.line, a.character) <= (b.line, b.character)
}
#[test]
fn bom_prefixed_source_does_not_shift_ranges() {
let source = "\u{FEFF}2024-01-15 * \"Coffee\"\n Assets:Bank -5.00 USD\n";
let parse_result = rustledger_parser::parse(source);
assert!(
parse_result.has_leading_bom,
"parser must have detected the BOM for the fix to take effect",
);
assert!(
parse_result.errors.is_empty(),
"parse errors: {:?}",
parse_result.errors,
);
let params = SelectionRangeParams {
text_document: TextDocumentIdentifier {
uri: "file:///bom.beancount".parse().unwrap(),
},
positions: vec![Position::new(1, 2)],
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result =
handle_selection_range(¶ms, source, &parse_result, PositionEncoding::Utf16)
.expect("selection range returns Some");
assert_eq!(result.len(), 1);
let mut chain = Vec::new();
let mut cur: Option<&SelectionRange> = Some(&result[0]);
while let Some(r) = cur {
chain.push(r.range);
cur = r.parent.as_deref();
}
assert_eq!(
chain[0],
Range {
start: Position::new(1, 2),
end: Position::new(1, 8),
},
"deepest range should be the 'Assets' segment at line 1 cols 2..8; got {chain:?}",
);
for win in chain.windows(2) {
let (inner, outer) = (win[0], win[1]);
assert!(
range_contains(outer, inner),
"outer={outer:?} does not contain inner={inner:?}; \
a BOM-frame bug typically breaks containment when one of \
the helpers forgets the bom_offset shift",
);
}
let outer = *chain.last().unwrap();
assert_eq!(
outer.start,
Position::new(0, 1),
"SOURCE_FILE range start drifted; expected (0, 1) — the post-BOM byte 0 \
shifted +BOM_LEN into the original-source frame and then mapped to \
UTF-16 column 1 — got {outer:?}. A bug in node_or_token_range that \
forgets the bom_offset shift would land at (0, 0); double-shifting \
would land somewhere wrong.",
);
}
}