use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use rustledger_booking::BookingEngine;
use rustledger_core::{BookingMethod, Directive};
use rustledger_loader::Options as LoaderOptions;
use rustledger_parser::{ParseError, ParseResult, Span, Spanned};
use rustledger_validate::{
Severity, ValidationError, ValidationOptions, validate_spanned_with_options,
};
use super::utils::LineIndex;
use crate::ledger_state::LedgerState;
fn build_validation_options_from_loader(loader_options: &LoaderOptions) -> ValidationOptions {
ValidationOptions {
account_types: loader_options
.account_types()
.iter()
.map(|s| (*s).to_string())
.collect(),
..Default::default()
}
}
fn build_validation_options_from_file(
file_options: &[(String, String, Span)],
) -> ValidationOptions {
let mut opts = ValidationOptions::default();
let mut account_types = opts.account_types.clone();
for (key, value, _span) in file_options {
match key.as_str() {
"name_assets" => {
if !account_types.is_empty() {
account_types[0] = value.clone();
}
}
"name_liabilities" => {
if account_types.len() > 1 {
account_types[1] = value.clone();
}
}
"name_equity" => {
if account_types.len() > 2 {
account_types[2] = value.clone();
}
}
"name_income" => {
if account_types.len() > 3 {
account_types[3] = value.clone();
}
}
"name_expenses" => {
if account_types.len() > 4 {
account_types[4] = value.clone();
}
}
_ => {}
}
}
opts.account_types = account_types;
opts
}
pub fn parse_errors_to_diagnostics(result: &ParseResult, source: &str) -> Vec<Diagnostic> {
let line_index = LineIndex::new(source);
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 fn validation_errors_to_diagnostics(
directives: &[Spanned<Directive>],
source: &str,
validation_options: ValidationOptions,
full_directives: Option<&[Spanned<Directive>]>,
current_file_id: Option<u16>,
) -> Vec<Diagnostic> {
let line_index = LineIndex::new(source);
let directives_to_validate = if current_file_id.is_some() {
full_directives.unwrap_or(directives)
} else {
directives
};
let mut booked_directives: Vec<Spanned<Directive>> = directives_to_validate.to_vec();
booked_directives.sort_by(|a, b| {
a.value
.date()
.cmp(&b.value.date())
.then_with(|| a.value.priority().cmp(&b.value.priority()))
});
let mut booking_engine = BookingEngine::with_method(BookingMethod::Strict);
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;
}
}
let validation_errors = validate_spanned_with_options(&booked_directives, validation_options);
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
};
filtered_errors
.iter()
.map(|e| validation_error_to_diagnostic(e, &line_index))
.collect()
}
pub fn validation_error_to_diagnostic(
error: &ValidationError,
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 (el, ec) = line_index.offset_to_position(span.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;
pub fn all_diagnostics(
result: &ParseResult,
source: &str,
ledger_state: Option<&LedgerState>,
current_file_id: Option<u16>,
) -> Vec<Diagnostic> {
let mut diagnostics = parse_errors_to_diagnostics(result, source);
if result.errors.is_empty() {
if source.len() <= MAX_VALIDATION_FILE_SIZE {
let full_directives = ledger_state.and_then(|ls| ls.directives());
let validation_options = if let Some(ls) = ledger_state
&& let Some(ledger) = ls.ledger()
{
build_validation_options_from_loader(&ledger.options)
} else {
build_validation_options_from_file(&result.options)
};
let validation_diagnostics = validation_errors_to_diagnostics(
&result.directives,
source,
validation_options,
full_directives,
current_file_id,
);
diagnostics.extend(validation_diagnostics);
} else {
tracing::debug!(
"Skipping validation for large file ({} bytes > {} limit)",
source.len(),
MAX_VALIDATION_FILE_SIZE
);
}
}
diagnostics
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_parser::parse;
#[test]
fn test_line_index_offset_to_position() {
let source = "line1\nline2\nline3";
let line_index = LineIndex::new(source);
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);
assert!(
!diagnostics.is_empty(),
"Should have at least one validation error"
);
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),
}
}
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);
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),
}
}
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_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);
}
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),
}
}
let isolated_diagnostics = validation_errors_to_diagnostics(
&bank_result.directives,
bank_source,
ValidationOptions::default(),
None,
None,
);
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(
&bank_result.directives,
bank_source,
ValidationOptions::default(),
Some(&all_directives),
Some(1), );
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(), "Should have no parse errors");
let diagnostics = all_diagnostics(&result, source, None, None);
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),
}
}
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 == "E0001"),
"Should NOT have E0001 (invalid account name) - custom name_* options should be respected. Got: {:?}",
error_codes
);
assert!(
error_diagnostics.is_empty(),
"Valid file with custom account names should have no errors, but got: {:?}",
error_codes
);
}
}