use lsp_types::{
CodeLens, CodeLensParams, Command, Diagnostic, DiagnosticSeverity, NumberOrString, Position,
Range,
};
use rustledger_core::Directive;
use rustledger_parser::ParseResult;
use rustledger_validate::ErrorCode;
use std::collections::HashMap;
use super::diagnostics::validation_would_run;
use super::utils::{LineIndex, PositionEncoding, count_noun};
pub fn handle_code_lens(
params: &CodeLensParams,
source: &str,
parse_result: &ParseResult,
cached_diagnostics: Option<&[Diagnostic]>,
encoding: PositionEncoding,
) -> Option<Vec<CodeLens>> {
let line_index = LineIndex::new(source, encoding);
let mut lenses = Vec::new();
let uri = params.text_document.uri.as_str();
let verdict_diagnostics = if validation_would_run(source, parse_result) {
cached_diagnostics
} else {
None
};
let account_stats = collect_account_stats(parse_result);
for spanned in &parse_result.directives {
let (line, _) = line_index.offset_to_position(spanned.span.start);
match &spanned.value {
Directive::Open(open) => {
let account = open.account.to_string();
let stats = account_stats.get(&account);
let txn_count = stats.map(|s| s.transaction_count).unwrap_or(0);
let currencies: Vec<String> =
open.currencies.iter().map(|c| c.to_string()).collect();
let title = if txn_count > 0 {
let counted = count_noun(txn_count, "transaction");
if currencies.is_empty() {
counted
} else {
format!("{} | {}", counted, currencies.join(", "))
}
} else if !currencies.is_empty() {
currencies.join(", ")
} else {
"No transactions".to_string()
};
lenses.push(CodeLens {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 0),
},
command: Some(Command {
title,
command: "rledger.showAccountDetails".to_string(),
arguments: Some(vec![serde_json::json!(account)]),
}),
data: Some(serde_json::json!({ "uri": uri })),
});
}
Directive::Transaction(txn) => {
let posting_count = txn.postings.len();
let currencies: Vec<String> = txn
.postings
.iter()
.filter_map(|p| {
p.units
.as_ref()
.and_then(|u| u.currency().map(String::from))
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let counted = count_noun(posting_count, "posting");
let title = if currencies.is_empty() {
counted
} else {
format!("{} | {}", counted, currencies.join(", "))
};
lenses.push(CodeLens {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 0),
},
command: Some(Command {
title,
command: "rledger.showTransactionDetails".to_string(),
arguments: None,
}),
data: Some(serde_json::json!({ "uri": uri })),
});
}
Directive::Balance(bal) => {
let title = balance_lens_title(
bal.amount.number,
bal.amount.currency.as_ref(),
line,
verdict_diagnostics,
);
lenses.push(CodeLens {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 0),
},
command: Some(Command {
title,
command: "rledger.noop".to_string(),
arguments: None,
}),
data: None,
});
}
_ => {}
}
}
lenses.extend(super::import::import_code_lens(
&parse_result.directives,
source,
encoding,
));
if lenses.is_empty() {
None
} else {
Some(lenses)
}
}
const BALANCE_ERROR_CODES: &[&str] = &[
ErrorCode::BalanceAssertionFailed.code(),
ErrorCode::BalanceToleranceExceeded.code(),
ErrorCode::MultiplePadForBalance.code(),
];
fn balance_lens_title(
amount: rustledger_core::Decimal,
currency: &str,
line: u32,
cached_diagnostics: Option<&[Diagnostic]>,
) -> String {
let amount_str = format!("Balance: {amount} {currency}");
let Some(diags) = cached_diagnostics else {
return amount_str;
};
if has_balance_error_at_line(diags, line) {
format!("⚠ {amount_str} (see diagnostic)")
} else if has_non_balance_error_at_line(diags, line) {
amount_str
} else {
format!("✓ {amount_str}")
}
}
fn has_balance_error_at_line(diagnostics: &[Diagnostic], line: u32) -> bool {
diagnostics.iter().any(|d| {
d.range.start.line == line
&& d.severity == Some(DiagnosticSeverity::ERROR)
&& is_balance_error_code(d.code.as_ref())
})
}
fn has_non_balance_error_at_line(diagnostics: &[Diagnostic], line: u32) -> bool {
diagnostics.iter().any(|d| {
d.range.start.line == line
&& !is_global_sentinel_range(&d.range)
&& matches!(
d.severity,
Some(DiagnosticSeverity::ERROR)
| Some(DiagnosticSeverity::WARNING)
| Some(DiagnosticSeverity::INFORMATION)
)
&& !is_balance_error_code(d.code.as_ref())
})
}
fn is_global_sentinel_range(range: &Range) -> bool {
range.start.line == 0
&& range.start.character == 0
&& range.end.line == 0
&& range.end.character == 0
}
fn is_balance_error_code(code: Option<&NumberOrString>) -> bool {
match code {
Some(NumberOrString::String(s)) => BALANCE_ERROR_CODES.contains(&s.as_str()),
Some(NumberOrString::Number(n)) => {
debug_assert!(
false,
"lens received an unexpected numeric diagnostic code: {n}; \
update `is_balance_error_code` or normalize at the emitter",
);
false
}
None => false,
}
}
pub fn handle_code_lens_resolve(lens: CodeLens) -> CodeLens {
let mut resolved = lens;
if resolved.command.is_none() {
resolved.command = Some(Command {
title: "rledger lens".to_string(),
command: "rledger.noop".to_string(),
arguments: None,
});
}
resolved
}
#[derive(Default)]
struct AccountStats {
transaction_count: usize,
}
fn collect_account_stats(parse_result: &ParseResult) -> HashMap<String, AccountStats> {
let mut stats: HashMap<String, AccountStats> = HashMap::new();
for spanned in &parse_result.directives {
if let Directive::Transaction(txn) = &spanned.value {
for posting in &txn.postings {
let account = posting.account.to_string();
stats.entry(account).or_default().transaction_count += 1;
}
}
}
stats
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{DiagnosticSeverity, NumberOrString};
use rustledger_parser::parse;
fn code_lens_params() -> CodeLensParams {
CodeLensParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: "file:///test.beancount".parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
}
}
fn find_balance_lens(lenses: Vec<CodeLens>) -> CodeLens {
lenses
.into_iter()
.find(|l| {
l.command
.as_ref()
.is_some_and(|c| c.title.contains("Balance:"))
})
.expect("balance lens emitted")
}
fn diagnostic_with_code_severity_at_line(
code: &str,
severity: DiagnosticSeverity,
line: u32,
) -> Diagnostic {
Diagnostic {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, 80),
},
severity: Some(severity),
code: Some(NumberOrString::String(code.into())),
code_description: None,
source: Some("rustledger".into()),
message: format!("{code} test diagnostic"),
related_information: None,
tags: None,
data: None,
}
}
fn error_at_line(line: u32) -> Diagnostic {
diagnostic_with_code_severity_at_line("E2001", DiagnosticSeverity::ERROR, line)
}
#[test]
fn test_code_lens_accounts() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food
2024-01-16 * "Lunch"
Assets:Bank -10.00 USD
Expenses:Food
"#;
let result = parse(source);
let params = code_lens_params();
let lenses = handle_code_lens(¶ms, source, &result, Some(&[]), PositionEncoding::Utf16);
let lenses = lenses.expect("lenses emitted");
assert_eq!(lenses.len(), 3);
assert!(
lenses[0]
.command
.as_ref()
.unwrap()
.title
.contains("2 transactions")
);
}
#[test]
fn balance_lens_neutral_when_diagnostics_not_yet_computed() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-01 open Income:Salary
2024-01-15 * "Deposit"
Assets:Bank 100.00 USD
Income:Salary
2024-01-31 balance Assets:Bank 100.00 USD
"#;
let result = parse(source);
let params = code_lens_params();
let balance_lens = find_balance_lens(
handle_code_lens(¶ms, source, &result, None, PositionEncoding::Utf16)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
!cmd.title.contains('✓') && !cmd.title.contains('⚠'),
"cold start: lens must not claim a verdict before the \
validator has run. got {:?}",
cmd.title
);
assert!(cmd.title.starts_with("Balance:"));
assert!(cmd.title.contains("100"));
}
#[test]
fn balance_lens_shows_check_when_validator_passes() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-31 balance Assets:Bank 100.00 USD
"#;
let result = parse(source);
let params = code_lens_params();
let balance_lens = find_balance_lens(
handle_code_lens(¶ms, source, &result, Some(&[]), PositionEncoding::Utf16)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(cmd.title.contains('✓'), "got {:?}", cmd.title);
assert!(cmd.title.contains("100"));
assert!(
balance_lens.data.is_none(),
"eager-resolved balance lens carries no resolve-data payload; \
pre-#1253 the data payload triggered a codeLens/resolve \
round-trip that nvim's client could race against \
cancellation. got data = {:?}",
balance_lens.data
);
}
#[test]
fn balance_lens_shows_warning_when_validator_fails() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let params = code_lens_params();
let diags = vec![error_at_line(1)];
let balance_lens = find_balance_lens(
handle_code_lens(
¶ms,
source,
&result,
Some(&diags),
PositionEncoding::Utf16,
)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
cmd.title.contains('⚠') && cmd.title.contains("see diagnostic"),
"got {:?}",
cmd.title
);
assert_eq!(cmd.command, "rledger.noop");
}
#[test]
fn balance_lens_follows_diagnostic_cache_not_local_eval() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-01 open Income:Salary
2024-01-15 * "Deposit"
Assets:Bank 100.00 USD
Income:Salary
2024-01-31 balance Assets:Bank 100.00 USD
"#;
let result = parse(source);
let params = code_lens_params();
let diags = vec![error_at_line(5)];
let balance_lens = find_balance_lens(
handle_code_lens(
¶ms,
source,
&result,
Some(&diags),
PositionEncoding::Utf16,
)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
cmd.title.contains('⚠'),
"lens must follow validator's verdict, not re-derive from \
parse_result. got {:?}",
cmd.title
);
}
#[test]
fn balance_lens_shows_check_when_parse_disagrees_but_validator_passes() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-01 open Income:Salary
2024-01-01 open Expenses:Food
2024-02-01 * "Salary"
Assets:Bank 1000 USD
Income:Salary
2024-02-03 * "Food"
Assets:Bank -100 USD
Expenses:Food
2024-02-04 balance Assets:Bank 1000 USD
"#;
let result = parse(source);
let params = code_lens_params();
let balance_lens = find_balance_lens(
handle_code_lens(¶ms, source, &result, Some(&[]), PositionEncoding::Utf16)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
cmd.title.contains('✓'),
"lens must trust the validator (empty diagnostics) even \
when a naive parse-only reading would disagree. This is \
the structural property that fixes #1264's effective_date \
false positive: the validator runs plugins, the lens \
trusts the validator. got {:?}",
cmd.title
);
}
#[test]
fn balance_lens_neutral_when_parse_errors_skip_validation() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-31 balance Assets:Bank 100.00 USD
!!! syntax garbage on a trailing line
"#;
let result = parse(source);
assert!(
!result.errors.is_empty(),
"test setup: source must produce a parse error to exercise \
the validation-skip branch. got errors = {:?}",
result.errors,
);
let params = code_lens_params();
let balance_lens = find_balance_lens(
handle_code_lens(¶ms, source, &result, Some(&[]), PositionEncoding::Utf16)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
!cmd.title.contains('✓') && !cmd.title.contains('⚠'),
"parse-error skip path: lens must not claim a verdict the \
validator never computed. got {:?}",
cmd.title
);
assert!(cmd.title.starts_with("Balance:"));
}
#[test]
fn balance_lens_neutral_on_non_balance_error_at_line() {
let source = r#"2024-01-31 balance Assets:NeverOpened 0 USD
"#;
let result = parse(source);
let params = code_lens_params();
let diags = vec![diagnostic_with_code_severity_at_line(
"E1001",
DiagnosticSeverity::ERROR,
0,
)];
let balance_lens = find_balance_lens(
handle_code_lens(
¶ms,
source,
&result,
Some(&diags),
PositionEncoding::Utf16,
)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
!cmd.title.contains('⚠') && !cmd.title.contains("see diagnostic"),
"non-balance error (E1001) at the balance line must not \
render as a balance arithmetic failure. got {:?}",
cmd.title
);
assert!(
!cmd.title.contains('✓'),
"lens must not claim ✓ when an unrelated error blankets \
the assertion's line — the assertion's status is uncertain. \
got {:?}",
cmd.title
);
}
#[test]
fn balance_lens_neutral_on_non_balance_warning_at_line() {
let source = r#"2099-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let params = code_lens_params();
let diags = vec![diagnostic_with_code_severity_at_line(
"E10002",
DiagnosticSeverity::WARNING,
0,
)];
let balance_lens = find_balance_lens(
handle_code_lens(
¶ms,
source,
&result,
Some(&diags),
PositionEncoding::Utf16,
)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
!cmd.title.contains('✓') && !cmd.title.contains('⚠'),
"warning at the balance line must disqualify ✓; lens must \
not claim a verdict while the validator is flagging the \
directive. got {:?}",
cmd.title
);
}
#[test]
fn balance_lens_neutral_on_information_severity_at_line() {
let source = r#"2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let params = code_lens_params();
let diags = vec![diagnostic_with_code_severity_at_line(
"E10001",
DiagnosticSeverity::INFORMATION,
0,
)];
let balance_lens = find_balance_lens(
handle_code_lens(
¶ms,
source,
&result,
Some(&diags),
PositionEncoding::Utf16,
)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
!cmd.title.contains('✓') && !cmd.title.contains('⚠'),
"information-severity diagnostic at the balance line must \
disqualify ✓. got {:?}",
cmd.title
);
}
#[test]
fn balance_lens_keeps_check_when_only_hint_at_line() {
let source = r#"2024-01-31 balance Assets:Bank 100 USD
"#;
let result = parse(source);
let params = code_lens_params();
let diags = vec![diagnostic_with_code_severity_at_line(
"H1001",
DiagnosticSeverity::HINT,
0,
)];
let balance_lens = find_balance_lens(
handle_code_lens(
¶ms,
source,
&result,
Some(&diags),
PositionEncoding::Utf16,
)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
cmd.title.contains('✓'),
"HINT-severity must not disqualify ✓ — code-action hints \
routinely anchor on directives. got {:?}",
cmd.title
);
}
#[test]
fn balance_lens_ignores_global_sentinel_range_diagnostic() {
let source = "2024-01-31 balance Assets:Bank 100 USD\n";
let result = parse(source);
let params = code_lens_params();
let plugin_error = Diagnostic {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("PluginLoadFailed".into())),
code_description: None,
source: Some("rustledger".into()),
message: "plugin failed to load".into(),
related_information: None,
tags: None,
data: None,
};
let balance_lens = find_balance_lens(
handle_code_lens(
¶ms,
source,
&result,
Some(&[plugin_error]),
PositionEncoding::Utf16,
)
.expect("lenses emitted"),
);
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
cmd.title.contains('✓'),
"global-sentinel-range diagnostic (plugin error with no \
source span) must not disqualify ✓ on a line-0 balance \
directive; the sentinel range means 'global', not \
'anchored on line 0'. got {:?}",
cmd.title
);
}
#[test]
fn test_code_lens_resolve_fallback_for_command_none_lens() {
let lens = CodeLens {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
command: None,
data: None,
};
let resolved = handle_code_lens_resolve(lens);
let cmd = resolved
.command
.as_ref()
.expect("fallback must populate command");
assert_eq!(cmd.command, "rledger.noop");
}
#[test]
fn issue_1253_balance_lens_ships_eagerly_resolved() {
let source = "\
2012-01-01 open Assets:Bank
2012-01-01 open Income:Employment
2012-02-01 * \"Salary\"
Assets:Bank 1000 USD
Income:Employment
2012-02-02 balance Assets:Bank 1000 USD
";
let result = parse(source);
let params = code_lens_params();
let balance_lens = find_balance_lens(
handle_code_lens(¶ms, source, &result, Some(&[]), PositionEncoding::Utf16)
.expect("lenses emitted"),
);
let cmd = balance_lens
.command
.as_ref()
.expect("balance lens ships fully-resolved");
assert!(
cmd.title.contains('✓'),
"issue #1253: passing assertion must ship with the real ✓ \
title on the initial response, not a `(checking…)` \
placeholder that nvim could leave stuck. got {:?}",
cmd.title
);
assert!(
!cmd.title.contains("checking"),
"issue #1253: title must not contain the `(checking…)` \
placeholder; that's the stuck-state symptom. got {:?}",
cmd.title
);
assert!(
balance_lens.data.is_none(),
"issue #1253: balance lens must not carry a resolve-data \
payload; the resolve round-trip is what nvim could race \
against cancellation. got data = {:?}",
balance_lens.data
);
}
}