use lsp_types::{LinkedEditingRangeParams, LinkedEditingRanges, Position, Range};
use rustledger_parser::ParseResult;
use super::utils::{
LineIndex, PositionEncoding, get_word_at_position, is_account_like, is_currency_like,
};
pub fn handle_linked_editing_range(
params: &LinkedEditingRangeParams,
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<LinkedEditingRanges> {
let position = params.text_document_position_params.position;
let line_idx = position.line as usize;
let lines: Vec<&str> = source.lines().collect();
let line = lines.get(line_idx)?;
let (word, _, _) = get_word_at_position(line, position.character as usize, encoding)?;
let mut ranges = Vec::new();
let line_index = LineIndex::new(source, encoding);
if is_account_like(&word) {
collect_account_ranges(parse_result, &line_index, &word, &mut ranges);
}
else if is_currency_like(&word, parse_result) {
collect_currency_ranges(parse_result, &line_index, &word, &mut ranges);
}
if ranges.is_empty() {
None
} else {
let word_pattern = if is_account_like(&word) {
Some(r"[A-Z][A-Za-z0-9:-]*".to_string())
} else {
Some(r"[A-Z][A-Z0-9]*".to_string())
};
Some(LinkedEditingRanges {
ranges,
word_pattern,
})
}
}
fn collect_account_ranges(
parse_result: &ParseResult,
line_index: &LineIndex,
account: &str,
ranges: &mut Vec<Range>,
) {
for occurrence in &parse_result.account_occurrences {
if occurrence.value != account {
continue;
}
let (start_line, start_col) = line_index.offset_to_position(occurrence.span.start);
let (end_line, end_col) = line_index.offset_to_position(occurrence.span.end);
ranges.push(Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
});
}
ranges.sort_by(|a, b| {
a.start
.line
.cmp(&b.start.line)
.then(a.start.character.cmp(&b.start.character))
});
ranges.dedup_by(|a, b| a == b);
}
fn collect_currency_ranges(
parse_result: &ParseResult,
line_index: &LineIndex,
currency: &str,
ranges: &mut Vec<Range>,
) {
for occurrence in &parse_result.currency_occurrences {
if occurrence.value != currency {
continue;
}
let (start_line, start_col) = line_index.offset_to_position(occurrence.span.start);
let (end_line, end_col) = line_index.offset_to_position(occurrence.span.end);
ranges.push(Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
});
}
ranges.sort_by(|a, b| {
a.start
.line
.cmp(&b.start.line)
.then(a.start.character.cmp(&b.start.character))
});
ranges.dedup();
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_linked_editing_account() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food
2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let uri: lsp_types::Uri = "file:///test.beancount".parse().unwrap();
let params = LinkedEditingRangeParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
work_done_progress_params: Default::default(),
};
let result = handle_linked_editing_range(¶ms, source, &result, PositionEncoding::Utf16);
assert!(result.is_some());
let ranges = result.unwrap();
assert_eq!(ranges.ranges.len(), 3);
assert!(ranges.word_pattern.is_some());
}
#[test]
fn test_linked_editing_currency() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food 5.00 USD
"#;
let result = parse(source);
let uri: lsp_types::Uri = "file:///test.beancount".parse().unwrap();
let params = LinkedEditingRangeParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 28), },
work_done_progress_params: Default::default(),
};
let result = handle_linked_editing_range(¶ms, source, &result, PositionEncoding::Utf16);
assert!(result.is_some());
let ranges = result.unwrap();
assert_eq!(ranges.ranges.len(), 3);
}
#[test]
fn test_linked_editing_currency_no_false_positives() {
let source = r#"2024-01-01 open Assets:USD-Reserve
2024-01-01 commodity USD
2024-01-15 * "USD-to-EUR transfer"
Assets:USD-Reserve -100 USD
Assets:Bank 100 USD
"#;
let result = parse(source);
assert!(
result.errors.is_empty(),
"parse errors: {:?}",
result.errors
);
let uri: lsp_types::Uri = "file:///test.beancount".parse().unwrap();
let params = LinkedEditingRangeParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(1, 21), },
work_done_progress_params: Default::default(),
};
let ranges = handle_linked_editing_range(¶ms, source, &result, PositionEncoding::Utf16)
.expect("linked editing returns Some");
assert_eq!(
ranges.ranges.len(),
3,
"expected 3 linked-edit ranges, got {}: {:#?}",
ranges.ranges.len(),
ranges.ranges
);
}
#[test]
fn test_linked_editing_account_no_false_positives() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Assets:Bank transfer note"
Assets:Bank -5.00 USD
memo: "moved Assets:Bank balance"
Expenses:Food
; rebalanced Assets:Bank yesterday
"#;
let result = parse(source);
assert!(
result.errors.is_empty(),
"parse errors: {:?}",
result.errors
);
let uri: lsp_types::Uri = "file:///test.beancount".parse().unwrap();
let params = LinkedEditingRangeParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
work_done_progress_params: Default::default(),
};
let ranges = handle_linked_editing_range(¶ms, source, &result, PositionEncoding::Utf16)
.expect("linked editing returns Some");
assert_eq!(
ranges.ranges.len(),
2,
"expected 2 linked-edit ranges, got {}: {:#?}",
ranges.ranges.len(),
ranges.ranges
);
let summary: Vec<(u32, u32, u32)> = ranges
.ranges
.iter()
.map(|r| {
(
r.start.line,
r.start.character,
r.end.character - r.start.character,
)
})
.collect();
assert_eq!(
summary,
vec![(0, 16, 11), (2, 2, 11)],
"expected (line 0, col 16, width 11) + (line 2, col 2, width 11); got {summary:?}"
);
}
}