use lsp_types::{
DocumentHighlight, DocumentHighlightKind, DocumentHighlightParams, Position, Range,
};
use rustledger_core::Directive;
use rustledger_parser::ParseResult;
use super::utils::{
LineIndex, PositionEncoding, account_declaration_spans, commodity_declaration_spans,
get_word_at_position, is_account_like, is_currency_like,
};
pub fn handle_document_highlight(
params: &DocumentHighlightParams,
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<Vec<DocumentHighlight>> {
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 highlights = Vec::new();
let line_index = LineIndex::new(source, encoding);
if is_account_like(&word) {
collect_account_highlights(parse_result, &line_index, &word, &mut highlights);
}
else if is_currency_like(&word, parse_result) {
collect_currency_highlights(parse_result, &line_index, &word, &mut highlights);
}
else if is_in_quotes(line, position.character as usize) {
collect_payee_highlights(parse_result, &line_index, &word, &mut highlights);
}
if highlights.is_empty() {
None
} else {
Some(highlights)
}
}
fn collect_account_highlights(
parse_result: &ParseResult,
line_index: &LineIndex,
account: &str,
highlights: &mut Vec<DocumentHighlight>,
) {
let declaration_spans = account_declaration_spans(parse_result);
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);
let is_declaration = declaration_spans.contains(&occurrence.span);
highlights.push(DocumentHighlight {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
kind: Some(if is_declaration {
DocumentHighlightKind::WRITE
} else {
DocumentHighlightKind::READ
}),
});
}
highlights.sort_by(|a, b| {
a.range
.start
.line
.cmp(&b.range.start.line)
.then(a.range.start.character.cmp(&b.range.start.character))
});
highlights.dedup_by(|a, b| a.range == b.range);
}
fn collect_currency_highlights(
parse_result: &ParseResult,
line_index: &LineIndex,
currency: &str,
highlights: &mut Vec<DocumentHighlight>,
) {
let declaration_spans = commodity_declaration_spans(parse_result);
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);
let is_declaration = declaration_spans.contains(&occurrence.span);
highlights.push(DocumentHighlight {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
kind: Some(if is_declaration {
DocumentHighlightKind::WRITE
} else {
DocumentHighlightKind::READ
}),
});
}
highlights.sort_by(|a, b| {
a.range
.start
.line
.cmp(&b.range.start.line)
.then(a.range.start.character.cmp(&b.range.start.character))
});
highlights.dedup_by(|a, b| a.range == b.range);
}
fn collect_payee_highlights(
parse_result: &ParseResult,
line_index: &LineIndex,
payee: &str,
highlights: &mut Vec<DocumentHighlight>,
) {
for spanned in &parse_result.directives {
if let Directive::Transaction(txn) = &spanned.value
&& let Some(ref txn_payee) = txn.payee
&& txn_payee.as_ref() == payee
{
let (line, _) = line_index.offset_to_position(spanned.span.start);
let line_text = line_index.line_text(line).unwrap_or("");
if let Some(quote_byte) = line_text.find(&format!("\"{}\"", payee))
&& let Some(start) = line_index.byte_in_line_to_position(line, quote_byte + 1)
&& let Some(end) =
line_index.byte_in_line_to_position(line, quote_byte + 1 + payee.len())
{
highlights.push(DocumentHighlight {
range: Range { start, end },
kind: Some(DocumentHighlightKind::READ),
});
}
}
}
}
fn is_in_quotes(line: &str, col: usize) -> bool {
let chars: Vec<char> = line.chars().collect();
let mut in_quotes = false;
for (i, c) in chars.iter().enumerate() {
if i >= col {
break;
}
if *c == '"' {
in_quotes = !in_quotes;
}
}
in_quotes
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_highlight_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 = DocumentHighlightParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let highlights =
handle_document_highlight(¶ms, source, &result, PositionEncoding::Utf16);
assert!(highlights.is_some());
let highlights = highlights.unwrap();
assert_eq!(highlights.len(), 3);
assert_eq!(highlights[0].kind, Some(DocumentHighlightKind::WRITE));
}
#[test]
fn test_highlight_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 = DocumentHighlightParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 28), },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let highlights =
handle_document_highlight(¶ms, source, &result, PositionEncoding::Utf16);
assert!(highlights.is_some());
let highlights = highlights.unwrap();
assert_eq!(highlights.len(), 3);
}
#[test]
fn test_highlight_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 = DocumentHighlightParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(1, 21), },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let highlights =
handle_document_highlight(¶ms, source, &result, PositionEncoding::Utf16)
.expect("highlights returns Some");
assert_eq!(
highlights.len(),
3,
"expected 3 currency highlights, got {}: {highlights:#?}",
highlights.len()
);
let write_count = highlights
.iter()
.filter(|h| h.kind == Some(DocumentHighlightKind::WRITE))
.count();
assert_eq!(write_count, 1, "expected exactly one WRITE highlight");
}
#[test]
fn test_currency_in_commodity_metadata_is_read_not_write() {
let source = r#"2024-01-01 commodity USD
parent: USD
2024-01-15 * "Coffee"
Assets:Bank -5.00 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 = DocumentHighlightParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 21), },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let highlights =
handle_document_highlight(¶ms, source, &result, PositionEncoding::Utf16)
.expect("highlights returns Some");
assert_eq!(highlights.len(), 3, "{highlights:#?}");
let write_count = highlights
.iter()
.filter(|h| h.kind == Some(DocumentHighlightKind::WRITE))
.count();
assert_eq!(
write_count, 1,
"expected exactly one WRITE (the declaration); got {write_count} in {highlights:#?}"
);
}
#[test]
fn test_highlight_account_with_interleaved_metadata_1142() {
let source = "\
2024-01-01 open Assets:Bank USD
2024-01-15 * \"Test\"
Assets:Bank -5.00 USD
effective_date: 2024-01-20
Expenses:Food 5.00 USD
effective_date: 2024-01-21
";
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 = DocumentHighlightParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let highlights =
handle_document_highlight(¶ms, source, &result, PositionEncoding::Utf16)
.expect("Assets:Bank has at least the Open + 1 posting");
let metadata_lines = [3u32, 5u32];
for h in &highlights {
assert!(
!metadata_lines.contains(&h.range.start.line),
"highlight landed on metadata line: {h:?}"
);
}
assert!(
highlights.iter().any(|h| h.range.start.line == 2),
"Assets:Bank posting on line 2 should be highlighted; got {highlights:?}"
);
}
#[test]
fn test_highlight_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 = DocumentHighlightParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let highlights =
handle_document_highlight(¶ms, source, &result, PositionEncoding::Utf16)
.expect("highlights returns Some");
assert_eq!(
highlights.len(),
2,
"expected 2 account highlights, got {}: {highlights:#?}",
highlights.len()
);
let summary: Vec<(u32, Option<DocumentHighlightKind>, u32)> = highlights
.iter()
.map(|h| {
(
h.range.start.line,
h.kind,
h.range.end.character - h.range.start.character,
)
})
.collect();
assert_eq!(
summary,
vec![
(0, Some(DocumentHighlightKind::WRITE), 11),
(2, Some(DocumentHighlightKind::READ), 11),
],
"expected line 0 WRITE + line 2 READ, both 11 cols wide, got {summary:?}"
);
}
#[test]
fn test_highlight_account_close_is_write() {
let source = "\
2024-01-01 open Assets:Bank USD
2024-06-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
2024-12-31 close Assets:Bank
";
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 = DocumentHighlightParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let highlights =
handle_document_highlight(¶ms, source, &result, PositionEncoding::Utf16)
.expect("highlights returns Some");
let by_line: Vec<(u32, Option<DocumentHighlightKind>)> = highlights
.iter()
.map(|h| (h.range.start.line, h.kind))
.collect();
assert_eq!(
by_line,
vec![
(0, Some(DocumentHighlightKind::WRITE)), (2, Some(DocumentHighlightKind::READ)), (4, Some(DocumentHighlightKind::WRITE)), ],
"expected open=WRITE, posting=READ, close=WRITE; got {by_line:?}"
);
}
}