use lsp_types::{GotoDefinitionParams, GotoDefinitionResponse, Location, Position, Range, Uri};
use rustledger_core::Directive;
use rustledger_parser::ParseResult;
use crate::ledger_state::LedgerState;
use super::utils::{
LineIndex, PositionEncoding, get_word_at_source_position, is_account_type, is_currency_like,
};
pub fn handle_goto_definition(
params: &GotoDefinitionParams,
source: &str,
parse_result: &ParseResult,
ledger_state: Option<&LedgerState>,
uri: &Uri,
encoding: PositionEncoding,
) -> Option<GotoDefinitionResponse> {
let position = params.text_document_position_params.position;
let word = get_word_at_source_position(source, position, encoding)?;
tracing::debug!("Go-to-definition for word: {:?}", word);
let line_index = LineIndex::new(source, encoding);
let is_account = word.contains(':') || is_account_type(&word);
if is_account
&& let Some(location) = find_account_definition(&word, parse_result, &line_index, uri)
{
return Some(GotoDefinitionResponse::Scalar(location));
}
if is_account
&& let Some(state) = ledger_state
&& let Some((path, line)) = state.find_account_definition(&word)
&& let Some(location) = cross_file_location(&path, line)
{
return Some(GotoDefinitionResponse::Scalar(location));
}
if is_currency_like(&word, parse_result)
&& let Some(location) = find_currency_definition(&word, parse_result, &line_index, uri)
{
return Some(GotoDefinitionResponse::Scalar(location));
}
None
}
fn cross_file_location(path: &std::path::Path, line_1based: u32) -> Option<Location> {
let uri: Uri = format!("file://{}", path.display()).parse().ok()?;
let line = line_1based.saturating_sub(1);
Some(Location {
uri,
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 0),
},
})
}
fn find_account_definition(
account: &str,
parse_result: &ParseResult,
line_index: &LineIndex,
uri: &Uri,
) -> Option<Location> {
for spanned_directive in &parse_result.directives {
if let Directive::Open(open) = &spanned_directive.value {
let open_account = open.account.as_ref();
let prefix_match = account
.strip_prefix(open_account)
.is_some_and(|rest| rest.starts_with(':'));
if account == open_account || prefix_match {
let (start_line, start_col) =
line_index.offset_to_position(spanned_directive.span.start);
let (end_line, end_col) = line_index.offset_to_position(spanned_directive.span.end);
return Some(Location {
uri: uri.clone(),
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
});
}
}
}
None
}
fn find_currency_definition(
currency: &str,
parse_result: &ParseResult,
line_index: &LineIndex,
uri: &Uri,
) -> Option<Location> {
let commodity_directive = parse_result.directives.iter().find(|d| {
matches!(
&d.value,
Directive::Commodity(c) if c.currency.as_ref() == currency
)
})?;
let declaration_token = parse_result.currency_occurrences.iter().find(|o| {
o.span.start >= commodity_directive.span.start && o.span.end <= commodity_directive.span.end
})?;
let (start_line, start_col) = line_index.offset_to_position(declaration_token.span.start);
let (end_line, end_col) = line_index.offset_to_position(declaration_token.span.end);
Some(Location {
uri: uri.clone(),
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_goto_definition_currency_returns_token_span() {
let source = "\
2024-01-01 commodity USD
name: \"United States Dollar\"
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
";
let parse_result = parse(source);
assert!(
parse_result.errors.is_empty(),
"parse errors: {:?}",
parse_result.errors
);
let uri: Uri = "file:///test.beancount".parse().unwrap();
let params = GotoDefinitionParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
position: Position::new(3, 21),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let resp = handle_goto_definition(
¶ms,
source,
&parse_result,
None,
&uri,
PositionEncoding::Utf16,
)
.expect("definition returns Some");
let loc = match resp {
GotoDefinitionResponse::Scalar(l) => l,
other => panic!("expected Scalar location; got {other:?}"),
};
assert_eq!(loc.range.start, Position::new(0, 21));
assert_eq!(loc.range.end, Position::new(0, 24));
}
#[test]
fn test_goto_definition_currency_with_no_commodity_returns_none() {
let source = "2024-01-15 * \"Coffee\"\n Assets:Bank -5.00 USD\n";
let parse_result = parse(source);
let uri: Uri = "file:///test.beancount".parse().unwrap();
let params = GotoDefinitionParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
position: Position::new(1, 21),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
assert!(
handle_goto_definition(
¶ms,
source,
&parse_result,
None,
&uri,
PositionEncoding::Utf16
)
.is_none()
);
}
#[test]
fn cross_file_location_builds_uri_and_converts_line() {
let loc = cross_file_location(std::path::Path::new("/ledger/accounts.beancount"), 5)
.expect("location");
assert_eq!(loc.uri.as_str(), "file:///ledger/accounts.beancount");
assert_eq!(loc.range.start, Position::new(4, 0)); assert_eq!(loc.range.end, Position::new(4, 0));
let first = cross_file_location(std::path::Path::new("/a.beancount"), 1).unwrap();
assert_eq!(first.range.start, Position::new(0, 0));
}
}