#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod bom;
pub mod cst;
mod diagnostics;
mod error;
pub mod logos_lexer;
pub mod format {
pub use crate::cst::format::{
CanonicalizeError, PostingAlignment, canonicalize_directives, compute_alignment,
cr_outside_strings_present, crlf_to_lf_outside_strings, format_node, format_node_range,
format_node_range_with_alignment, format_node_with_alignment, format_source,
format_source_with_parsed, lf_to_crlf_outside_strings, try_format_source,
};
}
pub use cst::{
BeancountLanguage, SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken, lossless_kind_tokens,
parse_flat, parse_structured, parse_via_cst,
};
pub use error::{ParseError, ParseErrorKind};
pub use rowan::{Direction, NodeOrToken, TextRange, TextSize, TokenAtOffset, WalkEvent};
pub use rustledger_core::{InternedStr, SYNTHESIZED_FILE_ID, Span, Spanned};
use rustledger_core::Directive;
#[derive(Debug)]
#[non_exhaustive]
pub struct ParseResult {
pub directives: Vec<Spanned<Directive>>,
pub options: Vec<(String, String, Span)>,
pub includes: Vec<(String, Span)>,
pub plugins: Vec<(String, Option<String>, Span)>,
pub comments: Vec<Spanned<String>>,
pub errors: Vec<ParseError>,
pub warnings: Vec<ParseWarning>,
pub currency_occurrences: Vec<Spanned<rustledger_core::Currency>>,
pub account_occurrences: Vec<Spanned<rustledger_core::Account>>,
pub has_leading_bom: bool,
pub syntax_root: rowan::GreenNode,
pub alignment: crate::format::PostingAlignment,
}
impl ParseResult {
#[must_use]
pub fn syntax_node(&self) -> SyntaxNode {
SyntaxNode::new_root(self.syntax_root.clone())
}
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ParseResult>();
};
#[derive(Debug, Clone)]
pub struct ParseWarning {
pub message: String,
pub span: Span,
}
impl ParseWarning {
pub fn new(message: impl Into<String>, span: Span) -> Self {
Self {
message: message.into(),
span,
}
}
}
#[must_use]
pub fn parse(source: &str) -> ParseResult {
parse_via_cst(source)
}
#[must_use]
pub fn parse_directives(source: &str) -> (Vec<Spanned<Directive>>, Vec<ParseError>) {
let result = parse(source);
(result.directives, result.errors)
}
#[doc(hidden)]
#[must_use]
pub fn __baseline_canonical_payload(result: &ParseResult) -> Vec<u8> {
let ParseResult {
directives,
options,
includes,
plugins,
comments,
errors,
warnings,
currency_occurrences,
account_occurrences,
has_leading_bom,
syntax_root,
alignment,
} = result;
let _ = syntax_root;
let _ = alignment;
let mut out: Vec<u8> = Vec::new();
let directives_json = serde_json::to_value(directives)
.map_or_else(|e| format!("serialize-error:{e}"), |v| v.to_string());
out.extend_from_slice(b"directives:");
out.extend_from_slice(directives_json.as_bytes());
out.extend_from_slice(b"\noptions:");
out.extend_from_slice(format!("{options:?}").as_bytes());
out.extend_from_slice(b"\nincludes:");
out.extend_from_slice(format!("{includes:?}").as_bytes());
out.extend_from_slice(b"\nplugins:");
out.extend_from_slice(format!("{plugins:?}").as_bytes());
out.extend_from_slice(b"\ncomments:");
out.extend_from_slice(format!("{comments:?}").as_bytes());
out.extend_from_slice(b"\nerrors:");
out.extend_from_slice(format!("{errors:?}").as_bytes());
out.extend_from_slice(b"\nwarnings:");
out.extend_from_slice(format!("{warnings:?}").as_bytes());
out.extend_from_slice(b"\ncurrency_occurrences:");
out.extend_from_slice(format!("{currency_occurrences:?}").as_bytes());
out.extend_from_slice(b"\naccount_occurrences:");
out.extend_from_slice(format!("{account_occurrences:?}").as_bytes());
out.extend_from_slice(b"\nhas_leading_bom:");
out.extend_from_slice(format!("{has_leading_bom:?}").as_bytes());
out
}
#[cfg(test)]
mod canonical_payload_determinism {
use serde_json::json;
#[test]
fn serde_json_object_is_sorted() {
let v = json!({ "b": 1, "a": 2 });
let s = v.to_string();
assert!(
s.starts_with("{\"a\""),
"serde_json::Value::Object is not sorting keys (got {s}). \
This means cargo feature unification turned on \
serde_json/preserve_order somewhere in the workspace. \
The corpus baseline's canonical hash assumes sorted \
Object keys to neutralize FxHashMap iteration order in \
directive metadata. Find the crate that enabled \
`serde_json = {{ ..., features = [\"preserve_order\"] }}` \
and remove it, or thread an alternative canonicalization \
through __baseline_canonical_payload.",
);
}
}
#[cfg(test)]
mod cached_syntax_root_matches_fresh_parse {
use super::{cst::parse_structured, parse};
fn assert_round_trip(label: &str, source: &str) {
let parsed = parse(source);
let (stripped, _bom) = crate::bom::strip_leading(source);
let fresh = parse_structured(stripped).green().into_owned();
assert_eq!(
parsed.syntax_root, fresh,
"cached syntax_root diverged from fresh parse_structured for {label}: \n\
this means something is mutating the green tree between converter \
capture and consumer access. The two are supposed to be identical."
);
}
#[test]
fn empty_source() {
assert_round_trip("empty", "");
}
#[test]
fn simple_directive() {
assert_round_trip("open", "2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn every_directive_shape() {
assert_round_trip(
"directive zoo",
r#"option "title" "Test"
plugin "myplugin"
include "other.beancount"
2024-01-01 open Assets:Bank USD
2024-01-01 commodity USD
2024-06-15 * "Coffee"
Assets:Bank -5.00 USD
Expenses:Food
2024-12-31 close Assets:Bank
2024-01-31 balance Assets:Bank 100 USD
2024-01-15 pad Assets:Bank Equity:Opening
2024-01-15 note Assets:Bank "deposit pending"
2024-01-15 event "location" "SF"
2024-01-15 price USD 1.00 EUR
"#,
);
}
#[test]
fn with_parse_errors() {
assert_round_trip(
"broken",
"2024-01-01 open Assets:Bank \"unterminated\n2024-01-02 garbage line here\n",
);
}
#[test]
fn with_metadata_and_comments() {
assert_round_trip(
"metadata",
r#"; standalone comment
2024-01-01 open Assets:Bank USD
payee_account: Assets:Other
2024-06-15 * "Coffee" ; eol comment
memo: "morning"
Assets:Bank -5.00 USD
"#,
);
}
}
#[cfg(test)]
mod canonical_payload_excludes_syntax_root {
use super::{__baseline_canonical_payload, parse};
#[test]
fn mutating_syntax_root_does_not_change_canonical_payload() {
let src_a = "2024-01-01 open Assets:Bank USD\n";
let parsed_a = parse(src_a);
let mut mutated = parse(src_a);
mutated.syntax_root = parse("").syntax_root;
let payload_original = __baseline_canonical_payload(&parsed_a);
let payload_mutated = __baseline_canonical_payload(&mutated);
assert_eq!(
payload_original, payload_mutated,
"canonical payload changed after mutating only `syntax_root`. \
Either the destructure in `__baseline_canonical_payload` \
grew a `syntax_root` feed line (revert that — the field \
is deliberately excluded; see its rustdoc), or another \
field now reads from `syntax_root` indirectly. Either \
way the corpus manifest is about to drift."
);
}
}
#[cfg(test)]
mod canonical_payload_excludes_alignment {
use super::{__baseline_canonical_payload, parse};
use crate::cst::format::PostingAlignment;
#[test]
fn mutating_alignment_does_not_change_canonical_payload() {
let src = "\
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
";
let parsed = parse(src);
let mut mutated = parse(src);
mutated.alignment = PostingAlignment {
number_col: parsed.alignment.number_col + 100,
number_width: parsed.alignment.number_width + 7,
};
let payload_original = __baseline_canonical_payload(&parsed);
let payload_mutated = __baseline_canonical_payload(&mutated);
assert_eq!(
payload_original, payload_mutated,
"canonical payload changed after mutating only `alignment`. \
Either the destructure in `__baseline_canonical_payload` \
grew an `alignment` feed line (revert that — the field \
is deliberately excluded), or another field now reads \
from `alignment` indirectly. Either way the corpus \
manifest is about to drift across every source with \
postings.",
);
}
}
#[cfg(test)]
mod parse_result_alignment_cache {
use super::parse;
use crate::cst::ast::{AstNode, SourceFile};
use crate::cst::format::compute_alignment;
fn assert_equivalent(label: &str, source: &str) {
let result = parse(source);
let source_file = SourceFile::cast(result.syntax_node())
.expect("ParseResult::syntax_node() must be a SOURCE_FILE");
let fresh = compute_alignment(&source_file);
assert_eq!(
result.alignment, fresh,
"ParseResult::alignment cache diverged from a fresh \
compute_alignment call for {label}: cache = {:?}, fresh = {:?}. \
Either parse_via_cst forgot to call compute_alignment, or \
compute_alignment's semantics changed without refreshing \
the cache in the converter.",
result.alignment, fresh,
);
}
#[test]
fn empty_source() {
assert_equivalent("empty", "");
}
#[test]
fn open_only_no_postings() {
assert_equivalent("open only", "2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn single_transaction() {
assert_equivalent(
"single txn",
"\
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
",
);
}
#[test]
fn multi_transaction_varying_widths() {
assert_equivalent(
"varying widths",
"\
2024-01-15 * \"A\"
Assets:Bank -5.00 USD
Expenses:Food
2024-02-15 * \"B\"
Assets:Investment:Long:Path -123456.78 USD
Expenses:Tax 100.00 USD
",
);
}
#[test]
fn arithmetic_amounts() {
assert_equivalent(
"arithmetic amounts",
"\
2024-01-15 * \"Split\"
Assets:Bank -10.00 + 5.00 USD
Expenses:Misc
",
);
}
#[test]
fn parse_errors() {
assert_equivalent(
"broken",
"\
2024-01-15 * \"x\"
Assets:Bank -5.00 USD
}}}garbage
2024-02-15 * \"y\"
Assets:Other 100.00 USD
",
);
}
#[test]
fn mid_transaction_error_node() {
assert_equivalent(
"mid-transaction breakage",
"\
2024-01-15 * \"wide broken\"
Assets:Investment:Long:Path -123456.78 USD }}}
Expenses:Tax
2024-02-15 * \"narrow clean\"
Assets:Bank -5.00 USD
Expenses:Food
",
);
}
}