use lsp_types::{
Position, PrepareRenameResponse, Range, RenameParams, TextDocumentPositionParams, TextEdit,
WorkspaceEdit,
};
use rustledger_parser::{ParseResult, parse};
use std::collections::HashMap;
use super::utils::{
LineIndex, PositionEncoding, get_word_at_position, is_account_like, is_currency_like,
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum RenameKind {
Account,
Currency,
}
pub fn handle_prepare_rename(
params: &TextDocumentPositionParams,
source: &str,
parse_result: &ParseResult,
encoding: PositionEncoding,
) -> Option<PrepareRenameResponse> {
let 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, start_col, end_col) =
get_word_at_position(line, position.character as usize, encoding)?;
let is_known_account = parse_result
.account_occurrences
.iter()
.any(|o| o.value == word.as_str());
let is_known_currency = parse_result
.currency_occurrences
.iter()
.any(|o| o.value == word.as_str());
if is_known_account
|| is_known_currency
|| is_account_like(&word)
|| is_currency_like(&word, parse_result)
{
Some(PrepareRenameResponse::Range(Range {
start: Position::new(position.line, start_col as u32),
end: Position::new(position.line, end_col as u32),
}))
} else {
None
}
}
#[allow(clippy::mutable_key_type)] pub fn handle_rename(
params: &RenameParams,
source: &str,
parse_result: &ParseResult,
other_files: &[(lsp_types::Uri, String)],
encoding: PositionEncoding,
) -> Option<WorkspaceEdit> {
let position = params.text_document_position.position;
let new_name = ¶ms.new_name;
let uri = params.text_document_position.text_document.uri.clone();
let line_idx = position.line as usize;
let lines: Vec<&str> = source.lines().collect();
let line = lines.get(line_idx)?;
let (old_name, _, _) = get_word_at_position(line, position.character as usize, encoding)?;
let is_known_account = parse_result
.account_occurrences
.iter()
.any(|o| o.value == old_name);
let is_known_currency = parse_result
.currency_occurrences
.iter()
.any(|o| o.value == old_name);
let kind = if is_known_account {
RenameKind::Account
} else if is_known_currency {
RenameKind::Currency
} else if is_account_like(&old_name) {
RenameKind::Account
} else if is_currency_like(&old_name, parse_result) {
RenameKind::Currency
} else {
return None;
};
let collect = |pr: &ParseResult, src: &str| -> Vec<TextEdit> {
let idx = LineIndex::new(src, encoding);
let mut edits = Vec::new();
match kind {
RenameKind::Account => {
collect_account_rename_edits(pr, &idx, &old_name, new_name, &mut edits);
}
RenameKind::Currency => {
collect_currency_rename_edits(pr, &idx, &old_name, new_name, &mut edits);
}
}
edits
};
let mut changes: HashMap<lsp_types::Uri, Vec<TextEdit>> = HashMap::new();
let current_edits = collect(parse_result, source);
if current_edits.is_empty() {
return None;
}
changes.insert(uri.clone(), current_edits);
for (f_uri, f_source) in other_files {
if *f_uri == uri {
continue; }
let f_parse = parse(f_source);
let f_edits = collect(&f_parse, f_source);
if !f_edits.is_empty() {
changes.insert(f_uri.clone(), f_edits);
}
}
if changes.is_empty() {
return None;
}
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
fn collect_account_rename_edits(
parse_result: &ParseResult,
line_index: &LineIndex,
old_name: &str,
new_name: &str,
edits: &mut Vec<TextEdit>,
) {
for occurrence in &parse_result.account_occurrences {
if occurrence.value != old_name {
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);
edits.push(TextEdit {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
new_text: new_name.to_string(),
});
}
edits.sort_by(|a, b| {
a.range
.start
.line
.cmp(&b.range.start.line)
.then(a.range.start.character.cmp(&b.range.start.character))
});
edits.dedup_by(|a, b| a.range == b.range);
}
fn collect_currency_rename_edits(
parse_result: &ParseResult,
line_index: &LineIndex,
old_name: &str,
new_name: &str,
edits: &mut Vec<TextEdit>,
) {
for occurrence in &parse_result.currency_occurrences {
if occurrence.value != old_name {
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);
edits.push(TextEdit {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
new_text: new_name.to_string(),
});
}
edits.sort_by(|a, b| {
a.range
.start
.line
.cmp(&b.range.start.line)
.then(a.range.start.character.cmp(&b.range.start.character))
});
edits.dedup_by(|a, b| a.range == b.range);
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_get_word_at_position() {
let line = " Assets:Bank -5.00 USD";
let (word, start, end) = get_word_at_position(line, 5, PositionEncoding::Utf8).unwrap();
assert_eq!(word, "Assets:Bank");
assert_eq!(start, 2);
assert_eq!(end, 13);
}
#[test]
fn test_is_account_like() {
assert!(is_account_like("Assets:Bank"));
assert!(is_account_like("Expenses:Food:Coffee"));
assert!(!is_account_like("USD"));
assert!(!is_account_like("Bank"));
}
#[test]
#[allow(clippy::mutable_key_type)] fn test_rename_account() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food
"#;
let result = parse(source);
let uri: lsp_types::Uri = "file:///test.beancount".parse().unwrap();
let params = RenameParams {
text_document_position: TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
new_name: "Assets:Checking".to_string(),
work_done_progress_params: Default::default(),
};
let edit = handle_rename(¶ms, source, &result, &[], PositionEncoding::Utf16);
assert!(edit.is_some());
let edit = edit.unwrap();
let changes = edit.changes.unwrap();
let edits: Vec<_> = changes.values().next().unwrap().clone();
assert_eq!(edits.len(), 2);
}
#[test]
#[allow(clippy::mutable_key_type)]
fn test_rename_currency_no_false_positives() {
let source = r#"2024-01-01 open Assets:USD-Reserve
2024-01-01 commodity USD
name: "United States Dollar"
2024-01-15 * "USD-to-EUR transfer"
Assets:USD-Reserve -100 USD
Assets:Bank 100 USD
; switching USD to USDX later
"#;
let result = parse(source);
let uri: lsp_types::Uri = "file:///test.beancount".parse().unwrap();
let params = RenameParams {
text_document_position: TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(1, 21),
},
new_name: "USDX".to_string(),
work_done_progress_params: Default::default(),
};
let edit = handle_rename(¶ms, source, &result, &[], PositionEncoding::Utf16)
.expect("rename returns edit");
let changes = edit.changes.expect("edit has changes");
let edits = changes.values().next().expect("at least one file");
assert_eq!(
edits.len(),
3,
"expected 3 currency rename edits, got {}: {edits:#?}",
edits.len()
);
for e in edits {
assert_eq!(e.new_text, "USDX");
}
}
#[test]
#[allow(clippy::mutable_key_type)]
fn test_rename_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);
let uri: lsp_types::Uri = "file:///test.beancount".parse().unwrap();
let params = RenameParams {
text_document_position: TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: Position::new(0, 16), },
new_name: "Assets:Checking".to_string(),
work_done_progress_params: Default::default(),
};
let edit = handle_rename(¶ms, source, &result, &[], PositionEncoding::Utf16)
.expect("rename returns edit");
let changes = edit.changes.expect("edit has changes");
let edits = changes.values().next().expect("at least one file");
assert_eq!(
edits.len(),
2,
"expected exactly 2 account rename edits (one per ACCOUNT token), \
got {}: {edits:#?}. Any extra edit is a false positive in the \
payee/metadata/comment positions.",
edits.len()
);
for e in edits {
assert_eq!(e.new_text, "Assets:Checking");
}
}
}