use std::sync::Arc;
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use rustledger_booking::BookingEngine;
use rustledger_core::{BookingMethod, Directive};
use rustledger_loader::{LoadOptions, Options as LoaderOptions, Plugin, SourceMap};
use rustledger_parser::{ParseError, ParseResult, Span, Spanned};
use rustledger_plugin::NativePluginRegistry;
use rustledger_validate::{Severity, ValidationError, ValidationOptions, ValidationSession};
use super::utils::{LineIndex, PositionEncoding};
use crate::ledger_state::LedgerState;
fn build_validation_options_from_loader(
loader_options: &LoaderOptions,
source_map: &SourceMap,
base_dir: &std::path::Path,
) -> ValidationOptions {
rustledger_loader::validation_options_from_options(loader_options)
.with_document_dirs(rustledger_loader::resolve_document_dirs(
&loader_options.documents,
Some(base_dir),
))
.with_document_source_dirs(rustledger_loader::document_source_dirs(source_map))
}
fn build_validation_options_from_file(
file_options: &[(String, String, Span)],
base_dir: Option<&std::path::Path>,
) -> ValidationOptions {
let mut options = LoaderOptions::new();
for (key, value, _span) in file_options {
options.set(key, value);
}
rustledger_loader::validation_options_from_options(&options).with_document_dirs(
rustledger_loader::resolve_document_dirs(&options.documents, base_dir),
)
}
pub fn parse_errors_to_diagnostics(
result: &ParseResult,
source: &str,
encoding: PositionEncoding,
) -> Vec<Diagnostic> {
let line_index = LineIndex::new(source, encoding);
result
.errors
.iter()
.map(|e| parse_error_to_diagnostic(e, &line_index))
.collect()
}
pub fn parse_error_to_diagnostic(error: &ParseError, line_index: &LineIndex) -> Diagnostic {
let (start_line, start_col) = line_index.offset_to_position(error.span.start);
let (end_line, end_col) = line_index.offset_to_position(error.span.end);
Diagnostic {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(lsp_types::NumberOrString::String(format!(
"P{:04}",
error.kind_code()
))),
source: Some("rustledger".to_string()),
message: error.message(),
related_information: None,
tags: None,
code_description: None,
data: None,
}
}
pub struct PluginContext<'a> {
pub plugins: &'a [Plugin],
pub file_options: &'a LoaderOptions,
pub source_map: &'a SourceMap,
}
pub fn validation_errors_to_diagnostics(
mut booked_directives: Vec<Spanned<Directive>>,
source: &str,
validation_options: ValidationOptions,
current_file_id: Option<u16>,
plugin_ctx: Option<&PluginContext<'_>>,
encoding: PositionEncoding,
) -> Vec<Diagnostic> {
let line_index = LineIndex::new(source, encoding);
let mut extra_diagnostics = Vec::new();
booked_directives.sort_by_cached_key(|d| rustledger_core::booking_sort_key(&d.value));
let mut booking_engine = BookingEngine::with_method(BookingMethod::Strict);
booking_engine.register_account_methods(booked_directives.iter().map(|s| &s.value));
for spanned in &mut booked_directives {
if let Directive::Transaction(txn) = &mut spanned.value
&& let Ok(result) = booking_engine.book_and_interpolate(txn)
{
booking_engine.apply(&result.transaction);
*txn = result.transaction;
}
}
if let Some(ctx) = plugin_ctx {
let registry = NativePluginRegistry::global();
for plugin in ctx.plugins {
if let Some(fid) = current_file_id
&& plugin.file_id != fid as usize
{
continue;
}
let is_native = registry.has(&plugin.name);
if !is_native {
let (start_line, start_col) = line_index.offset_to_position(plugin.span.start);
let (end_line, end_col) = line_index.offset_to_position(plugin.span.end);
let kind = if plugin.name.ends_with(".wasm") {
"WASM"
} else {
"Python"
};
extra_diagnostics.push(Diagnostic {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
severity: Some(DiagnosticSeverity::INFORMATION),
code: Some(lsp_types::NumberOrString::String("E8006".to_string())),
source: Some("rustledger".to_string()),
message: format!(
"Plugin \"{}\" is a {kind} plugin — skipped in LSP, validation may differ from `rledger check`",
plugin.name
),
related_information: None,
tags: None,
code_description: None,
data: None,
});
}
}
let load_options = LoadOptions::default();
let mut plugin_errors = Vec::new();
let synth_result = rustledger_loader::run_plugins(
&mut booked_directives,
ctx.plugins,
ctx.file_options,
&load_options,
ctx.source_map,
&mut plugin_errors,
rustledger_loader::PluginPass::PreBookingSynth,
);
match synth_result.and_then(|()| {
rustledger_loader::run_plugins(
&mut booked_directives,
ctx.plugins,
ctx.file_options,
&load_options,
ctx.source_map,
&mut plugin_errors,
rustledger_loader::PluginPass::PostBooking,
)
}) {
Ok(()) => {
let show_plugin_errors = current_file_id.is_none() || current_file_id == Some(0);
if show_plugin_errors {
for err in &plugin_errors {
let severity = match err.severity {
rustledger_loader::ErrorSeverity::Error => DiagnosticSeverity::ERROR,
rustledger_loader::ErrorSeverity::Warning => {
DiagnosticSeverity::WARNING
}
};
extra_diagnostics.push(Diagnostic {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
severity: Some(severity),
code: Some(lsp_types::NumberOrString::String(err.code.clone())),
source: Some("rustledger".to_string()),
message: err.message.clone(),
related_information: None,
tags: None,
code_description: None,
data: None,
});
}
}
}
Err(e) => {
tracing::warn!("Plugin execution failed in LSP: {e}");
}
}
}
let today = jiff::Zoned::now().date();
let session = ValidationSession::new(validation_options);
let (session, mut validation_errors) = session.run_early_spanned(&booked_directives, today);
let (session, late_errs) = session.run_late_spanned(&booked_directives, today);
validation_errors.extend(late_errs);
validation_errors.extend(session.finalize());
let filtered_errors: Vec<_> = if let Some(file_id) = current_file_id {
validation_errors
.into_iter()
.filter(|e| e.file_id == Some(file_id) || e.file_id.is_none())
.collect()
} else {
validation_errors
};
let mut result: Vec<Diagnostic> = extra_diagnostics;
result.extend(
filtered_errors
.iter()
.map(|e| validation_error_to_diagnostic(e, source, &line_index)),
);
result
}
pub(crate) fn validation_error_to_diagnostic(
error: &ValidationError,
source: &str,
line_index: &LineIndex,
) -> Diagnostic {
let (start_line, start_col, end_line, end_col, has_location) = if let Some(span) = &error.span {
let (sl, sc) = line_index.offset_to_position(span.start);
let clamped_end = span.end.min(source.len());
let end = source
.get(span.start..clamped_end)
.map_or(clamped_end, |s| span.start + s.trim_end().len());
let (el, ec) = line_index.offset_to_position(end);
(sl, sc, el, ec, true)
} else {
(0, 0, 0, 0, false)
};
let severity = match error.code.severity() {
Severity::Error => DiagnosticSeverity::ERROR,
Severity::Warning => DiagnosticSeverity::WARNING,
Severity::Info => DiagnosticSeverity::INFORMATION,
};
let mut message = if let Some(ctx) = &error.context {
format!("{} ({})\n context: {}", error.message, error.date, ctx)
} else {
format!("{} ({})", error.message, error.date)
};
if !has_location {
message.push_str("\n (source location unknown)");
}
Diagnostic {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(end_line, end_col),
},
severity: Some(severity),
code: Some(lsp_types::NumberOrString::String(
error.code.code().to_string(),
)),
source: Some("rustledger".to_string()),
message,
related_information: None,
tags: None,
code_description: None,
data: None,
}
}
const MAX_VALIDATION_FILE_SIZE: usize = 500 * 1024;
#[must_use]
pub(crate) fn validation_would_run(source: &str, parse_result: &ParseResult) -> bool {
parse_result.errors.is_empty() && source.len() <= MAX_VALIDATION_FILE_SIZE
}
fn build_live_directive_overlay(
fresh_overlays: &[(u16, &[Spanned<Directive>])],
full_directives: Option<&[Spanned<Directive>]>,
) -> Option<Vec<Spanned<Directive>>> {
let full = full_directives?;
if fresh_overlays.is_empty() {
return None;
}
let replaced: std::collections::HashSet<u16> =
fresh_overlays.iter().map(|(fid, _)| *fid).collect();
let mut merged: Vec<Spanned<Directive>> = full
.iter()
.filter(|d| !replaced.contains(&d.file_id))
.cloned()
.collect();
for (fid, fresh) in fresh_overlays {
for d in *fresh {
debug_assert!(
d.file_id == 0 || d.file_id == *fid,
"fresh directive for file_id={fid} was pre-tagged with \
unexpected file_id={} (caller bug?)",
d.file_id
);
let mut d = d.clone();
d.file_id = *fid;
merged.push(d);
}
}
Some(merged)
}
pub fn all_diagnostics(
result: &ParseResult,
source: &str,
ledger_state: Option<&LedgerState>,
current_file_id: Option<u16>,
current_file_path: Option<&std::path::Path>,
other_buffer_overlays: &[(u16, &[Spanned<Directive>])],
encoding: PositionEncoding,
) -> Vec<Diagnostic> {
let mut diagnostics = parse_errors_to_diagnostics(result, source, encoding);
if validation_would_run(source, result) {
let full_directives_raw = ledger_state.and_then(|ls| ls.directives());
let mut overlay_entries: Vec<(u16, &[Spanned<Directive>])> =
Vec::with_capacity(1 + other_buffer_overlays.len());
if let Some(fid) = current_file_id {
overlay_entries.push((fid, result.directives.as_slice()));
}
overlay_entries.extend_from_slice(other_buffer_overlays);
let overlay = build_live_directive_overlay(&overlay_entries, full_directives_raw);
let booked_directives: Vec<Spanned<Directive>> = if let Some(owned) = overlay {
owned
} else if let Some(full) = full_directives_raw
&& current_file_id.is_some()
{
full.to_vec()
} else {
result.directives.clone()
};
let validation_options = if let Some(ls) = ledger_state
&& let Some(ledger) = ls.ledger()
{
let base_dir = ledger
.source_map
.files()
.first()
.and_then(|f| f.path.parent())
.unwrap_or_else(|| std::path::Path::new("."));
build_validation_options_from_loader(&ledger.options, &ledger.source_map, base_dir)
} else {
let base_dir = current_file_path.and_then(|p| p.parent());
build_validation_options_from_file(&result.options, base_dir)
};
let parse_result_to_plugins =
|plugins: &[(String, Option<String>, Span)], file_id: usize| -> Vec<Plugin> {
plugins
.iter()
.map(|(name, config, span)| {
let (actual_name, force_python) =
if let Some(stripped) = name.strip_prefix("python:") {
(stripped.to_string(), true)
} else {
(name.clone(), false)
};
Plugin {
name: actual_name,
config: config.clone(),
span: *span,
file_id,
force_python,
}
})
.collect()
};
let merged_plugins: Vec<Plugin>;
let single_file_options: LoaderOptions;
let single_file_source_map: SourceMap;
let plugin_ctx = if let Some(ls) = ledger_state
&& let Some(ledger) = ls.ledger()
{
let current_fid = current_file_id.unwrap_or(0) as usize;
merged_plugins = ledger
.plugins
.iter()
.filter(|p| p.file_id != current_fid)
.cloned()
.chain(parse_result_to_plugins(&result.plugins, current_fid))
.collect();
if merged_plugins.is_empty() {
None
} else {
Some(PluginContext {
plugins: &merged_plugins,
file_options: &ledger.options,
source_map: &ledger.source_map,
})
}
} else if !result.plugins.is_empty() {
merged_plugins = parse_result_to_plugins(&result.plugins, 0);
single_file_options = {
let mut opts = LoaderOptions::new();
for (key, value, _span) in &result.options {
opts.set(key, value);
}
opts
};
single_file_source_map = {
let mut sm = SourceMap::new();
sm.add_file(
std::path::PathBuf::from("/tmp/rustledger-lsp-buffer.beancount"),
Arc::from(source),
);
sm
};
Some(PluginContext {
plugins: &merged_plugins,
file_options: &single_file_options,
source_map: &single_file_source_map,
})
} else {
None
};
let validation_diagnostics = validation_errors_to_diagnostics(
booked_directives,
source,
validation_options,
current_file_id,
plugin_ctx.as_ref(),
encoding,
);
diagnostics.extend(validation_diagnostics);
} else if result.errors.is_empty() && source.len() > MAX_VALIDATION_FILE_SIZE {
tracing::debug!(
"Skipping validation for large file ({} bytes > {} limit)",
source.len(),
MAX_VALIDATION_FILE_SIZE
);
}
let show_option_warnings = current_file_id.is_none() || current_file_id == Some(0);
if show_option_warnings {
let single_file_options;
let option_warnings = if let Some(ls) = ledger_state
&& let Some(ledger) = ls.ledger()
{
ledger.options.warnings.as_slice()
} else {
let mut opts = LoaderOptions::default();
for (key, value, _span) in &result.options {
opts.set(key, value);
}
single_file_options = opts;
single_file_options.warnings.as_slice()
};
for warning in option_warnings {
diagnostics.push(Diagnostic {
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(lsp_types::NumberOrString::String(warning.code.to_string())),
source: Some("rustledger".to_string()),
message: warning.message.clone(),
related_information: None,
tags: None,
code_description: None,
data: None,
});
}
}
diagnostics.extend(super::import::import_diagnostics(
&result.directives,
source,
encoding,
));
diagnostics
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn from_loader_carries_inferred_tolerance_default() {
let mut opts = LoaderOptions::new();
opts.set("inferred_tolerance_default", "CLP:0.5");
let vo = build_validation_options_from_loader(
&opts,
&SourceMap::new(),
std::path::Path::new("/tmp"),
);
assert_eq!(
vo.inferred_tolerance_default.get("CLP"),
Some(&rust_decimal::Decimal::new(5, 1))
);
}
#[test]
fn builders_carry_booking_method() {
use rustledger_core::BookingMethod;
let mut opts = LoaderOptions::new();
opts.set("booking_method", "FIFO");
let vo = build_validation_options_from_loader(
&opts,
&SourceMap::new(),
std::path::Path::new("/"),
);
assert_eq!(vo.default_booking_method, BookingMethod::Fifo);
let file_options = vec![("booking_method".to_string(), "FIFO".to_string(), Span::ZERO)];
let vo = build_validation_options_from_file(&file_options, None);
assert_eq!(vo.default_booking_method, BookingMethod::Fifo);
}
#[test]
fn from_file_carries_inferred_tolerance_default() {
let file_options = vec![(
"inferred_tolerance_default".to_string(),
"CLP:0.5".to_string(),
Span::ZERO,
)];
let vo = build_validation_options_from_file(&file_options, None);
assert_eq!(
vo.inferred_tolerance_default.get("CLP"),
Some(&rust_decimal::Decimal::new(5, 1))
);
}
fn get_code(d: &Diagnostic) -> String {
match d.code.as_ref().unwrap() {
lsp_types::NumberOrString::String(s) => s.clone(),
lsp_types::NumberOrString::Number(n) => panic!("Unexpected number code: {n}"),
}
}
#[test]
fn test_line_index_offset_to_position() {
let source = "line1\nline2\nline3";
let line_index = LineIndex::new(source, PositionEncoding::Utf8);
assert_eq!(line_index.offset_to_position(0), (0, 0));
assert_eq!(line_index.offset_to_position(5), (0, 5));
assert_eq!(line_index.offset_to_position(6), (1, 0));
assert_eq!(line_index.offset_to_position(12), (2, 0));
}
#[test]
fn test_validation_errors_shown_as_diagnostics() {
let source = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Typo
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary -3000 USD
2024-01-16 balance Assets:Bank:Checking 2000 USD
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "Should have no parse errors");
let diagnostics = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
assert!(
!diagnostics.is_empty(),
"Should have at least one validation error"
);
let codes: Vec<_> = diagnostics.iter().map(get_code).collect();
assert!(
codes.iter().any(|c| c == "E1001"),
"Should have E1001 (account not opened)"
);
assert!(
codes.iter().any(|c| c == "E3001"),
"Should have E3001 (unbalanced transaction)"
);
assert!(
codes.iter().any(|c| c == "E2001"),
"Should have E2001 (balance assertion failed)"
);
for diag in &diagnostics {
let code = get_code(diag);
let expected_severity = match code.as_str() {
"E1001" | "E2001" | "E3001" => Some(DiagnosticSeverity::ERROR),
_ => continue, };
assert_eq!(
diag.severity, expected_severity,
"Diagnostic {} should have correct severity",
code
);
}
}
#[test]
fn test_auto_filled_postings_do_not_trigger_false_positive() {
let source = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary
2024-01-16 balance Assets:Bank:Checking 5000 USD
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "Should have no parse errors");
let diagnostics = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
let error_diagnostics: Vec<&Diagnostic> = diagnostics
.iter()
.filter(|d| matches!(d.severity, Some(DiagnosticSeverity::ERROR)))
.collect();
let error_codes: Vec<_> = error_diagnostics.iter().map(|d| get_code(d)).collect();
assert!(
!error_codes.iter().any(|c| c == "E3001"),
"Should NOT have E3001 - the transaction is balanced after booking fills in the missing amount. Got codes: {:?}",
error_codes
);
assert!(
error_diagnostics.is_empty(),
"Valid file should have no ERROR diagnostics, but got: {:?}",
error_codes
);
}
#[test]
fn test_unbalanced_diagnostic_range_does_not_overshoot() {
let source = "\
2024-01-01 open Assets:Bank USD
2024-01-01 open Expenses:Food USD
2024-02-01 * \"x\"
Assets:Bank 10 USD
Expenses:Food -9 USD
2024-03-01 open Equity:X USD
";
let result = parse(source);
assert!(result.errors.is_empty(), "no parse errors");
let diagnostics = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
let bal = diagnostics
.iter()
.find(|d| get_code(d) == "E3001")
.expect("expected an E3001 unbalanced-transaction diagnostic");
assert_eq!(bal.range.start.line, 3, "starts at the transaction header");
assert!(
bal.range.end.line <= 5,
"range overshot past the last posting: end line {}",
bal.range.end.line
);
}
#[test]
fn test_multi_file_balance_assertion_issue_470() {
let bank_source = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary
2024-01-16 balance Assets:Bank:Checking 5000 USD
; After paying off credit card:
2024-01-21 balance Assets:Bank:Checking 4950 USD
"#;
let credit_card_source = r#"2024-01-01 open Liabilities:Credit-Card
2024-01-20 * "Pay off credit card"
Assets:Bank:Checking -50 USD
Liabilities:Credit-Card
"#;
let main_source = r#"2024-01-01 open Income:Salary USD
2024-01-01 open Expenses:Food USD
"#;
let bank_result = parse(bank_source);
let credit_card_result = parse(credit_card_source);
let main_result = parse(main_source);
assert!(bank_result.errors.is_empty(), "bank.bean should parse");
assert!(
credit_card_result.errors.is_empty(),
"credit_card.bean should parse"
);
assert!(main_result.errors.is_empty(), "main.bean should parse");
let mut all_directives: Vec<Spanned<Directive>> = Vec::new();
for mut d in main_result.directives {
d.file_id = 0;
all_directives.push(d);
}
for mut d in bank_result.directives.clone() {
d.file_id = 1;
all_directives.push(d);
}
for mut d in credit_card_result.directives {
d.file_id = 2;
all_directives.push(d);
}
let isolated_diagnostics = validation_errors_to_diagnostics(
bank_result.directives.clone(),
bank_source,
ValidationOptions::default(),
None,
None,
PositionEncoding::Utf16,
);
let isolated_codes: Vec<_> = isolated_diagnostics.iter().map(get_code).collect();
assert!(
isolated_codes.iter().any(|c| c == "E2001"),
"Isolated validation should show E2001 (balance assertion failed). Got: {:?}",
isolated_codes
);
let full_ledger_diagnostics = validation_errors_to_diagnostics(
all_directives.clone(),
bank_source,
ValidationOptions::default(),
Some(1), None,
PositionEncoding::Utf16,
);
let full_ledger_codes: Vec<_> = full_ledger_diagnostics.iter().map(get_code).collect();
assert!(
!full_ledger_codes.iter().any(|c| c == "E2001"),
"Full ledger validation should NOT show E2001 - balance is correct when all files are considered. Got: {:?}",
full_ledger_codes
);
let error_diagnostics: Vec<_> = full_ledger_diagnostics
.iter()
.filter(|d| matches!(d.severity, Some(DiagnosticSeverity::ERROR)))
.collect();
assert!(
error_diagnostics.is_empty(),
"bank.bean should have no errors when validated with full ledger. Got: {:?}",
full_ledger_codes
);
}
#[test]
fn test_unicode_account_names_issue_572() {
let source = r#"option "name_assets" "Активы"
option "name_liabilities" "Обязательства"
option "name_income" "Доходы"
option "name_expenses" "Расходы"
option "name_equity" "Капитал"
1900-01-01 open Капитал:Retained-Earnings
1900-01-01 open Капитал:Opening-Balances
2024-01-01 open Активы:Банк:Checking USD
2024-01-01 open Доходы:Зарплата
"#;
let result = parse(source);
assert!(
result.errors.is_empty(),
"Unicode account names should parse without errors: {:?}",
result
.errors
.iter()
.map(|e| e.message())
.collect::<Vec<_>>()
);
let diagnostics = parse_errors_to_diagnostics(&result, source, PositionEncoding::Utf16);
assert!(
diagnostics.is_empty(),
"Valid Unicode accounts should produce no diagnostics"
);
}
#[test]
fn test_live_overlay_reflects_buffer_edits_issue_685() {
let on_disk_source = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary -5000 USD
"#;
let buffer_unbalanced_source = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary -5001 USD
"#;
let buffer_fixed_source = on_disk_source;
let on_disk_stale_broken_source = buffer_unbalanced_source;
let fresh_unbalanced = parse(buffer_unbalanced_source);
assert!(
fresh_unbalanced.errors.is_empty(),
"buffer should parse cleanly"
);
let on_disk_clean = parse(on_disk_source);
let stale_full_directives: Vec<Spanned<Directive>> = on_disk_clean
.directives
.iter()
.map(|d| {
let mut d = d.clone();
d.file_id = 1;
d
})
.collect();
let no_overlay = validation_errors_to_diagnostics(
stale_full_directives.clone(),
buffer_unbalanced_source,
ValidationOptions::default(),
Some(1),
None,
PositionEncoding::Utf16,
);
let no_overlay_codes: Vec<_> = no_overlay.iter().map(get_code).collect();
assert!(
!no_overlay_codes.iter().any(|c| c == "E3001"),
"Bug reproduction: without overlay, stale ledger_state hides \
the buffer's new imbalance. Got: {no_overlay_codes:?}"
);
let overlay = build_live_directive_overlay(
&[(1, fresh_unbalanced.directives.as_slice())],
Some(&stale_full_directives),
)
.expect("overlay must be built when both full_directives and overlays are present");
let with_overlay = validation_errors_to_diagnostics(
overlay,
buffer_unbalanced_source,
ValidationOptions::default(),
Some(1),
None,
PositionEncoding::Utf16,
);
let with_overlay_codes: Vec<_> = with_overlay.iter().map(get_code).collect();
assert!(
with_overlay_codes.iter().any(|c| c == "E3001"),
"Fix verification: with overlay, buffer's imbalance should be \
reported as E3001. Got: {with_overlay_codes:?}"
);
let fresh_fixed = parse(buffer_fixed_source);
assert!(
fresh_fixed.errors.is_empty(),
"fixed buffer should parse cleanly"
);
let stale_broken = parse(on_disk_stale_broken_source);
let stale_broken_full: Vec<Spanned<Directive>> = stale_broken
.directives
.iter()
.map(|d| {
let mut d = d.clone();
d.file_id = 1;
d
})
.collect();
let no_overlay_persist = validation_errors_to_diagnostics(
stale_broken_full.clone(),
buffer_fixed_source,
ValidationOptions::default(),
Some(1),
None,
PositionEncoding::Utf16,
);
let no_overlay_persist_codes: Vec<_> = no_overlay_persist.iter().map(get_code).collect();
assert!(
no_overlay_persist_codes.iter().any(|c| c == "E3001"),
"Bug reproduction: without overlay, stale broken ledger_state \
makes a now-fixed buffer still appear broken. \
Got: {no_overlay_persist_codes:?}"
);
let overlay_fixed = build_live_directive_overlay(
&[(1, fresh_fixed.directives.as_slice())],
Some(&stale_broken_full),
)
.expect("overlay must be built when both full_directives and overlays are present");
let with_overlay_fixed = validation_errors_to_diagnostics(
overlay_fixed,
buffer_fixed_source,
ValidationOptions::default(),
Some(1),
None,
PositionEncoding::Utf16,
);
let with_overlay_fixed_codes: Vec<_> = with_overlay_fixed.iter().map(get_code).collect();
assert!(
!with_overlay_fixed_codes.iter().any(|c| c == "E3001"),
"Fix verification: with overlay, fixed buffer should clear the \
stale error. Got: {with_overlay_fixed_codes:?}"
);
}
#[test]
fn test_live_overlay_returns_none_when_nothing_to_overlay() {
let parsed = parse("2024-01-01 open Assets:Bank:Checking USD\n");
let result = build_live_directive_overlay(&[(1, parsed.directives.as_slice())], None);
assert!(
result.is_none(),
"no full_directives: overlay should be None (caller falls back \
to the single-file validation path)"
);
let other_parsed = parse("2024-01-01 open Income:Salary\n");
let other_dirs: Vec<Spanned<Directive>> = other_parsed
.directives
.iter()
.map(|d| {
let mut d = d.clone();
d.file_id = 2;
d
})
.collect();
let result = build_live_directive_overlay(&[], Some(&other_dirs));
assert!(
result.is_none(),
"full_directives present but no overlays: overlay should be None \
(caller falls back to full_directives as-is)"
);
}
#[test]
fn test_multi_buffer_overlay_replaces_multiple_files_issue_760() {
let bank_on_disk = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary
2024-01-21 balance Assets:Bank:Checking 4950 USD
"#;
let credit_card_on_disk = r#"2024-01-01 open Liabilities:Credit-Card
2024-01-20 * "Pay off credit card"
Assets:Bank:Checking -50 USD
Liabilities:Credit-Card
"#;
let credit_card_buffer = r#"2024-01-01 open Liabilities:Credit-Card
2024-01-20 * "Pay off credit card"
Assets:Bank:Checking -75 USD
Liabilities:Credit-Card
"#;
let main_on_disk = r#"2024-01-01 open Income:Salary USD
2024-01-01 open Expenses:Food USD
"#;
let main_parsed = parse(main_on_disk);
let bank_parsed = parse(bank_on_disk);
let credit_card_parsed_disk = parse(credit_card_on_disk);
let credit_card_parsed_buffer = parse(credit_card_buffer);
assert!(main_parsed.errors.is_empty());
assert!(bank_parsed.errors.is_empty());
assert!(credit_card_parsed_disk.errors.is_empty());
assert!(credit_card_parsed_buffer.errors.is_empty());
let mut stale_full: Vec<Spanned<Directive>> = Vec::new();
for mut d in main_parsed.directives.clone() {
d.file_id = 0;
stale_full.push(d);
}
for mut d in bank_parsed.directives.clone() {
d.file_id = 1;
stale_full.push(d);
}
for mut d in credit_card_parsed_disk.directives {
d.file_id = 2;
stale_full.push(d);
}
let baseline = validation_errors_to_diagnostics(
stale_full.clone(),
bank_on_disk,
ValidationOptions::default(),
Some(1),
None,
PositionEncoding::Utf16,
);
let baseline_codes: Vec<_> = baseline.iter().map(get_code).collect();
assert!(
!baseline_codes.iter().any(|c| c == "E2001"),
"baseline: bank balance should hold with disk state. Got: {baseline_codes:?}"
);
let single_buffer_overlay = build_live_directive_overlay(
&[(1, bank_parsed.directives.as_slice())],
Some(&stale_full),
)
.expect("overlay should be built");
let single_overlay_diagnostics = validation_errors_to_diagnostics(
single_buffer_overlay,
bank_on_disk,
ValidationOptions::default(),
Some(1),
None,
PositionEncoding::Utf16,
);
let single_codes: Vec<_> = single_overlay_diagnostics.iter().map(get_code).collect();
assert!(
!single_codes.iter().any(|c| c == "E2001"),
"Bug reproduction: with only the current file overlaid, the \
credit_card buffer edit is invisible and the bank balance \
appears to still hold. Got: {single_codes:?}"
);
let multi_buffer_overlay = build_live_directive_overlay(
&[
(1, bank_parsed.directives.as_slice()),
(2, credit_card_parsed_buffer.directives.as_slice()),
],
Some(&stale_full),
)
.expect("overlay should be built");
let multi_overlay_diagnostics = validation_errors_to_diagnostics(
multi_buffer_overlay,
bank_on_disk,
ValidationOptions::default(),
Some(1),
None,
PositionEncoding::Utf16,
);
let multi_codes: Vec<_> = multi_overlay_diagnostics.iter().map(get_code).collect();
assert!(
multi_codes.iter().any(|c| c == "E2001"),
"Fix verification: with both files overlaid, bank balance \
assertion (4950) should fail because credit_card was edited to \
-75 in the buffer, making actual 4925. Got: {multi_codes:?}"
);
}
#[test]
fn test_all_diagnostics_applies_live_overlay_issue_685() {
use std::fs;
let on_disk = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary -5000 USD
"#;
let buffer_unbalanced = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary -5001 USD
"#;
let tempdir = tempfile::tempdir().expect("tempdir");
let journal_path = tempdir.path().join("ledger.beancount");
fs::write(&journal_path, on_disk).expect("write journal");
let mut ledger_state = LedgerState::new();
ledger_state
.load(&journal_path)
.expect("LedgerState::load should succeed on well-formed journal");
let canonical = journal_path.canonicalize().expect("canonicalize");
let file_id = ledger_state
.ledger()
.expect("ledger loaded")
.source_map
.files()
.iter()
.find_map(|f| {
f.path
.canonicalize()
.ok()
.filter(|p| *p == canonical)
.map(|_| f.id as u16)
})
.expect("file_id for loaded file");
let result = parse(buffer_unbalanced);
assert!(
result.errors.is_empty(),
"buffer content should parse cleanly"
);
let diagnostics = all_diagnostics(
&result,
buffer_unbalanced,
Some(&ledger_state),
Some(file_id),
None,
&[],
PositionEncoding::Utf16,
);
let codes: Vec<_> = diagnostics.iter().map(get_code).collect();
assert!(
codes.iter().any(|c| c == "E3001"),
"all_diagnostics should report the buffer's new imbalance (E3001) \
even though LedgerState still holds the balanced on-disk \
version. Got: {codes:?}"
);
let result_clean = parse(on_disk);
assert!(result_clean.errors.is_empty());
let clean_diagnostics = all_diagnostics(
&result_clean,
on_disk,
Some(&ledger_state),
Some(file_id),
None,
&[],
PositionEncoding::Utf16,
);
let clean_error_count = clean_diagnostics
.iter()
.filter(|d| matches!(d.severity, Some(DiagnosticSeverity::ERROR)))
.count();
assert_eq!(
clean_error_count,
0,
"balanced buffer should produce no ERROR diagnostics. Got: {:?}",
clean_diagnostics.iter().map(get_code).collect::<Vec<_>>()
);
}
#[test]
fn test_all_diagnostics_multi_buffer_overlay_issue_760() {
use std::fs;
let main_content = r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary USD
2024-01-01 open Liabilities:Credit-Card USD
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary
2024-01-21 balance Assets:Bank:Checking 4950 USD
include "credit_card.beancount"
"#;
let credit_card_disk = r#"2024-01-20 * "Pay off credit card"
Assets:Bank:Checking -50 USD
Liabilities:Credit-Card
"#;
let credit_card_buffer = r#"2024-01-20 * "Pay off credit card"
Assets:Bank:Checking -75 USD
Liabilities:Credit-Card
"#;
let tempdir = tempfile::tempdir().expect("tempdir");
let main_path = tempdir.path().join("main.beancount");
let credit_card_path = tempdir.path().join("credit_card.beancount");
fs::write(&main_path, main_content).expect("write main");
fs::write(&credit_card_path, credit_card_disk).expect("write credit_card");
let mut ledger_state = LedgerState::new();
ledger_state
.load(&main_path)
.expect("LedgerState::load should succeed");
let ledger = ledger_state.ledger().expect("ledger loaded");
let main_canonical = main_path.canonicalize().expect("canonicalize main");
let credit_card_canonical = credit_card_path
.canonicalize()
.expect("canonicalize credit_card");
let main_file_id = ledger
.source_map
.files()
.iter()
.find_map(|f| {
f.path
.canonicalize()
.ok()
.filter(|p| *p == main_canonical)
.map(|_| f.id as u16)
})
.expect("main file_id");
let credit_card_file_id = ledger
.source_map
.files()
.iter()
.find_map(|f| {
f.path
.canonicalize()
.ok()
.filter(|p| *p == credit_card_canonical)
.map(|_| f.id as u16)
})
.expect("credit_card file_id");
let main_result = parse(main_content);
assert!(main_result.errors.is_empty(), "main should parse cleanly");
let baseline = all_diagnostics(
&main_result,
main_content,
Some(&ledger_state),
Some(main_file_id),
None,
&[],
PositionEncoding::Utf16,
);
let baseline_codes: Vec<_> = baseline.iter().map(get_code).collect();
assert!(
!baseline_codes.iter().any(|c| c == "E2001"),
"baseline: bank balance should hold with disk credit_card. Got: {baseline_codes:?}"
);
let credit_card_buffer_parse = parse(credit_card_buffer);
assert!(
credit_card_buffer_parse.errors.is_empty(),
"credit_card buffer should parse cleanly"
);
let with_overlay = all_diagnostics(
&main_result,
main_content,
Some(&ledger_state),
Some(main_file_id),
None,
&[(
credit_card_file_id,
credit_card_buffer_parse.directives.as_slice(),
)],
PositionEncoding::Utf16,
);
let with_overlay_codes: Vec<_> = with_overlay.iter().map(get_code).collect();
assert!(
with_overlay_codes.iter().any(|c| c == "E2001"),
"Fix verification: with credit_card buffer overlaid, main's \
balance assertion should fail (4950 expected, 4925 actual \
after the -75 edit). Got: {with_overlay_codes:?}"
);
}
#[test]
fn test_native_plugin_runs_in_lsp_diagnostics() {
let source = r#"plugin "auto_accounts"
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary -5000 USD
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "Should have no parse errors");
let diags = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
let codes: Vec<_> = diags.iter().map(get_code).collect();
assert!(
!codes.iter().any(|c| c == "E1001"),
"With auto_accounts plugin running, should NOT have E1001. Got: {codes:?}"
);
}
#[test]
fn test_plugin_context_transforms_directives() {
let source = r#"plugin "auto_accounts"
2024-01-15 * "Paycheck"
Assets:Bank:Checking 5000 USD
Income:Salary -5000 USD
"#;
let result = parse(source);
assert!(result.errors.is_empty());
let without_plugins = validation_errors_to_diagnostics(
result.directives.clone(),
source,
ValidationOptions::default(),
None,
None,
PositionEncoding::Utf16,
);
let without_codes: Vec<_> = without_plugins.iter().map(get_code).collect();
assert!(
without_codes.iter().any(|c| c == "E1001"),
"Without plugins, should have E1001 for unopened accounts. Got: {without_codes:?}"
);
let plugins = vec![Plugin {
name: "auto_accounts".to_string(),
config: None,
span: Span::ZERO,
file_id: 0,
force_python: false,
}];
let file_options = LoaderOptions::new();
let mut source_map = SourceMap::new();
source_map.add_file(std::path::PathBuf::from("<test>"), Arc::from(source));
let ctx = PluginContext {
plugins: &plugins,
file_options: &file_options,
source_map: &source_map,
};
let with_plugins = validation_errors_to_diagnostics(
result.directives.clone(),
source,
ValidationOptions::default(),
None,
Some(&ctx),
PositionEncoding::Utf16,
);
let with_codes: Vec<_> = with_plugins.iter().map(get_code).collect();
assert!(
!with_codes.iter().any(|c| c == "E1001"),
"With auto_accounts plugin, should NOT have E1001. Got: {with_codes:?}"
);
}
#[test]
fn test_effective_date_plugin_prevents_false_balance_error_issue_793() {
let source = concat!(
"option \"operating_currency\" \"USD\"\n",
"\n",
"plugin \"beancount_reds_plugins.effective_date.effective_date\" \"{\n",
" 'Assets': {'earlier': 'Equity:Transfer', 'later': 'Equity:Transfer'},\n",
" }\"\n",
"\n",
"2024-01-01 open Assets:Bank\n",
"2024-01-01 open Equity:Transfer\n",
"2024-01-01 open Expenses:Food\n",
"2024-01-01 open Income:Employment\n",
"\n",
"2024-02-01 * \"Salary\"\n",
" Assets:Bank 1000 USD\n",
" Income:Employment\n",
"\n",
"2024-02-02 balance Assets:Bank 1000 USD\n",
"\n",
"2024-02-03 * \"Delayed food purchase\"\n",
" Expenses:Food 100 USD\n",
" Assets:Bank -100 USD\n",
" effective_date: 2024-03-01\n",
"\n",
"2024-02-04 balance Assets:Bank 1000 USD\n",
"2024-03-02 balance Assets:Bank 900 USD\n",
);
let result = parse(source);
assert!(result.errors.is_empty(), "Should have no parse errors");
let diagnostics = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
let codes: Vec<_> = diagnostics.iter().map(get_code).collect();
let balance_errors: Vec<_> = diagnostics
.iter()
.filter(|d| get_code(d) == "E2001")
.collect();
assert!(
balance_errors.is_empty(),
"Issue #793 regression: effective_date plugin should prevent false \
balance errors. Got E2001 diagnostics: {balance_errors:?}\n\
All codes: {codes:?}"
);
}
#[test]
fn test_non_native_plugin_emits_info_diagnostic() {
let source = r#"plugin "some.python.plugin"
2024-01-01 open Assets:Cash USD
"#;
let result = parse(source);
assert!(result.errors.is_empty());
let diagnostics = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
let info_diags: Vec<_> = diagnostics
.iter()
.filter(|d| get_code(d) == "E8006")
.collect();
assert!(
!info_diags.is_empty(),
"Should emit E8006 info for non-native plugin. Got: {:?}",
diagnostics.iter().map(get_code).collect::<Vec<_>>()
);
assert_eq!(
info_diags[0].severity,
Some(DiagnosticSeverity::INFORMATION),
"E8006 should be INFORMATION severity"
);
assert!(
info_diags[0].message.contains("some.python.plugin"),
"E8006 message should name the plugin"
);
assert!(
info_diags[0].message.contains("skipped"),
"E8006 message should say the plugin is skipped"
);
}
#[test]
fn test_native_plugin_no_info_diagnostic() {
let source = r#"plugin "auto_accounts"
2024-01-15 * "Test"
Assets:Cash 100 USD
Income:Salary
"#;
let result = parse(source);
assert!(result.errors.is_empty());
let diagnostics = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
let info_diags: Vec<_> = diagnostics
.iter()
.filter(|d| get_code(d) == "E8006")
.collect();
assert!(
info_diags.is_empty(),
"Native plugins should NOT emit E8006 info diagnostic. Got: {info_diags:?}"
);
}
#[test]
fn test_plugin_errors_become_diagnostics() {
let source = r#"option "documents" "/nonexistent/path/to/docs"
plugin "auto_accounts"
2024-01-15 * "Test"
Assets:Cash 100 USD
Income:Salary
"#;
let result = parse(source);
assert!(result.errors.is_empty());
let diagnostics = all_diagnostics(
&result,
source,
None,
None,
None,
&[],
PositionEncoding::Utf16,
);
let codes: Vec<_> = diagnostics.iter().map(get_code).collect();
assert!(
!codes.iter().any(|c| c == "E1001"),
"auto_accounts should still auto-generate opens. Got: {codes:?}"
);
}
}