use lsp_types::{
Position, Range, SymbolKind, TypeHierarchyItem, TypeHierarchyPrepareParams,
TypeHierarchySubtypesParams, TypeHierarchySupertypesParams, Uri,
};
use rustledger_core::Directive;
use rustledger_parser::ParseResult;
use std::collections::HashSet;
use super::utils::{LineIndex, PositionEncoding, get_word_at_position, is_account_like};
pub fn handle_prepare_type_hierarchy(
params: &TypeHierarchyPrepareParams,
source: &str,
parse_result: &ParseResult,
uri: &Uri,
encoding: PositionEncoding,
) -> Option<Vec<TypeHierarchyItem>> {
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, start, end) = get_word_at_position(line, position.character as usize, encoding)?;
if !is_account_like(&word) {
return None;
}
if !account_exists(&word, parse_result) {
return None;
}
let item = TypeHierarchyItem {
name: word.clone(),
kind: SymbolKind::CLASS, tags: None,
detail: Some("Account".to_string()),
uri: uri.clone(),
range: Range {
start: Position::new(position.line, start as u32),
end: Position::new(position.line, end as u32),
},
selection_range: Range {
start: Position::new(position.line, start as u32),
end: Position::new(position.line, end as u32),
},
data: Some(serde_json::Value::String(word)),
};
Some(vec![item])
}
pub fn handle_supertypes(
params: &TypeHierarchySupertypesParams,
source: &str,
parse_result: &ParseResult,
uri: &Uri,
encoding: PositionEncoding,
) -> Option<Vec<TypeHierarchyItem>> {
let account = params
.item
.data
.as_ref()
.and_then(|v| v.as_str())
.unwrap_or(¶ms.item.name);
let parent = get_parent_account(account)?;
let line_index = LineIndex::new(source, encoding);
let location = find_account_location(source, &line_index, parse_result, &parent)?;
let item = TypeHierarchyItem {
name: parent.clone(),
kind: SymbolKind::CLASS,
tags: None,
detail: Some("Account".to_string()),
uri: uri.clone(),
range: location,
selection_range: location,
data: Some(serde_json::Value::String(parent)),
};
Some(vec![item])
}
pub fn handle_subtypes(
params: &TypeHierarchySubtypesParams,
source: &str,
parse_result: &ParseResult,
uri: &Uri,
encoding: PositionEncoding,
) -> Option<Vec<TypeHierarchyItem>> {
let account = params
.item
.data
.as_ref()
.and_then(|v| v.as_str())
.unwrap_or(¶ms.item.name);
let children = get_child_accounts(account, parse_result);
if children.is_empty() {
return None;
}
let line_index = LineIndex::new(source, encoding);
let items: Vec<TypeHierarchyItem> = children
.into_iter()
.filter_map(|child| {
let location = find_account_location(source, &line_index, parse_result, &child)?;
Some(TypeHierarchyItem {
name: child.clone(),
kind: SymbolKind::CLASS,
tags: None,
detail: Some("Account".to_string()),
uri: uri.clone(),
range: location,
selection_range: location,
data: Some(serde_json::Value::String(child)),
})
})
.collect();
if items.is_empty() { None } else { Some(items) }
}
fn get_parent_account(account: &str) -> Option<String> {
let parts: Vec<&str> = account.split(':').collect();
if parts.len() <= 1 {
return None;
}
Some(parts[..parts.len() - 1].join(":"))
}
fn get_child_accounts(parent: &str, parse_result: &ParseResult) -> Vec<String> {
let mut children = HashSet::new();
let prefix = format!("{}:", parent);
let parent_depth = parent.matches(':').count();
for spanned in &parse_result.directives {
let accounts = get_accounts_from_directive(&spanned.value);
for account in accounts {
if account.starts_with(&prefix) {
let child_depth = account.matches(':').count();
if child_depth == parent_depth + 1 {
children.insert(account);
} else if child_depth > parent_depth + 1 {
let parts: Vec<&str> = account.split(':').collect();
let direct_child = parts[..parent_depth + 2].join(":");
children.insert(direct_child);
}
}
}
}
let mut result: Vec<String> = children.into_iter().collect();
result.sort();
result
}
fn get_accounts_from_directive(directive: &Directive) -> Vec<String> {
match directive {
Directive::Open(open) => vec![open.account.to_string()],
Directive::Close(close) => vec![close.account.to_string()],
Directive::Balance(bal) => vec![bal.account.to_string()],
Directive::Pad(pad) => {
vec![pad.account.to_string(), pad.source_account.to_string()]
}
Directive::Note(note) => vec![note.account.to_string()],
Directive::Document(doc) => vec![doc.account.to_string()],
Directive::Transaction(txn) => txn.postings.iter().map(|p| p.account.to_string()).collect(),
_ => vec![],
}
}
fn account_exists(account: &str, parse_result: &ParseResult) -> bool {
for spanned in &parse_result.directives {
let accounts = get_accounts_from_directive(&spanned.value);
if accounts.iter().any(|a| a == account) {
return true;
}
}
false
}
fn find_account_location(
source: &str,
line_index: &LineIndex,
parse_result: &ParseResult,
account: &str,
) -> Option<Range> {
for spanned in &parse_result.directives {
if let Directive::Open(open) = &spanned.value
&& open.account.as_ref() == account
{
let (line, _) = line_index.offset_to_position(spanned.span.start);
let line_text = line_index.line_text(line)?;
if let Some(col) = line_text.find(account) {
let start = line_index.byte_in_line_to_position(line, col)?;
let end = line_index.byte_in_line_to_position(line, col + account.len())?;
return Some(Range { start, end });
}
}
}
for spanned in &parse_result.directives {
let accounts = get_accounts_from_directive(&spanned.value);
if accounts.iter().any(|a| a == account) {
let directive_text = &source[spanned.span.start..spanned.span.end];
let mut byte_cursor = spanned.span.start;
for line_content in directive_text.lines() {
if let Some(col) = line_content.find(account) {
let needle_start = byte_cursor + col;
let needle_end = needle_start + account.len();
let (sl, sc) = line_index.offset_to_position(needle_start);
let (el, ec) = line_index.offset_to_position(needle_end);
return Some(Range {
start: Position::new(sl, sc),
end: Position::new(el, ec),
});
}
byte_cursor += line_content.len();
let remaining = &source[byte_cursor.min(spanned.span.end)..spanned.span.end];
if remaining.starts_with("\r\n") {
byte_cursor += 2;
} else if remaining.starts_with('\n') {
byte_cursor += 1;
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_get_parent_account() {
assert_eq!(
get_parent_account("Assets:Bank:Checking"),
Some("Assets:Bank".to_string())
);
assert_eq!(
get_parent_account("Assets:Bank"),
Some("Assets".to_string())
);
assert_eq!(get_parent_account("Assets"), None);
}
#[test]
fn test_get_child_accounts() {
let source = r#"2024-01-01 open Assets:Bank:Checking
2024-01-01 open Assets:Bank:Savings
2024-01-01 open Assets:Cash
2024-01-01 open Assets:Investments:Stocks
"#;
let result = parse(source);
let children = get_child_accounts("Assets", &result);
assert!(children.contains(&"Assets:Bank".to_string()));
assert!(children.contains(&"Assets:Cash".to_string()));
assert!(children.contains(&"Assets:Investments".to_string()));
let bank_children = get_child_accounts("Assets:Bank", &result);
assert!(bank_children.contains(&"Assets:Bank:Checking".to_string()));
assert!(bank_children.contains(&"Assets:Bank:Savings".to_string()));
}
#[test]
fn test_prepare_type_hierarchy() {
let source = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-15 * "Coffee"
Assets:Bank:Checking -5.00 USD
Expenses:Food
"#;
let result = parse(source);
let uri: Uri = "file:///test.beancount".parse().unwrap();
let params = TypeHierarchyPrepareParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
position: Position::new(0, 20), },
work_done_progress_params: Default::default(),
};
let items =
handle_prepare_type_hierarchy(¶ms, source, &result, &uri, PositionEncoding::Utf16);
assert!(items.is_some());
let items = items.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "Assets:Bank:Checking");
}
}