use std::borrow::Cow;
use rust_decimal::Decimal;
use rustledger_core::NaiveDate;
use std::str::FromStr;
use rustledger_core::{
Amount, Balance, Close, Commodity, CostSpec, Custom, Directive, Document, Event,
IncompleteAmount, InternedStr, MetaValue, Metadata, Note, Open, Pad, Posting, Price,
PriceAnnotation, Query, Transaction,
};
const MAX_PREALLOC_DIRECTIVES: usize = 16_384;
const MAX_PREALLOC_COMMENTS: usize = 8_192;
use crate::ParseResult;
use crate::error::{ParseError, ParseErrorKind};
use crate::logos_lexer::{Token, tokenize};
use crate::span::{Span, Spanned};
#[derive(Debug, Clone)]
struct SpannedToken<'src> {
token: Token<'src>,
span: (usize, usize),
}
struct TokenStream<'src> {
tokens: &'src [SpannedToken<'src>],
pos: usize,
deferred_error: Option<ParseError>,
interner: rustledger_core::intern::StringInterner,
}
impl<'src> TokenStream<'src> {
fn new(tokens: &'src [SpannedToken<'src>]) -> Self {
Self {
tokens,
pos: 0,
deferred_error: None,
interner: rustledger_core::intern::StringInterner::new(),
}
}
const fn is_empty(&self) -> bool {
self.pos >= self.tokens.len()
}
fn peek(&self) -> Option<&SpannedToken<'src>> {
self.tokens.get(self.pos)
}
fn peek_token(&self) -> Option<&Token<'src>> {
self.tokens.get(self.pos).map(|t| &t.token)
}
const fn advance(&mut self) {
if self.pos < self.tokens.len() {
self.pos += 1;
}
}
fn span_from(&self, start_pos: usize) -> Span {
let start = self.tokens.get(start_pos).map_or(0, |t| t.span.0);
let end = if self.pos > 0 {
self.tokens.get(self.pos - 1).map_or(0, |t| t.span.1)
} else {
start
};
Span::new(start, end)
}
fn skip_to_newline(&mut self) {
while let Some(t) = self.peek() {
if matches!(t.token, Token::Newline) {
self.advance();
break;
}
self.advance();
}
}
}
type ParseRes<T> = Result<T, ()>;
fn parse_date(stream: &mut TokenStream<'_>) -> ParseRes<NaiveDate> {
if let Some(t) = stream.peek()
&& let Token::Date(s) = &t.token
{
let span = Span::new(t.span.0, t.span.1);
if s.len() == 10
&& s.as_bytes()[4] == b'-'
&& s.as_bytes()[7] == b'-'
&& let (Ok(y), Ok(m), Ok(d)) = (
s[0..4].parse::<i32>(),
s[5..7].parse::<u32>(),
s[8..10].parse::<u32>(),
)
&& let Some(date) = rustledger_core::naive_date(y, m, d)
{
stream.advance();
return Ok(date);
}
let normalized = normalize_date_str(s);
if let Ok(date) = normalized.parse::<NaiveDate>() {
stream.advance();
return Ok(date);
}
let msg = describe_invalid_date(s);
stream.deferred_error = Some(ParseError::new(ParseErrorKind::InvalidDateValue(msg), span));
}
Err(())
}
fn normalize_date_str(s: &str) -> Cow<'_, str> {
if !s.contains('/') && s.len() == 10 {
return Cow::Borrowed(s);
}
let s = s.replace('/', "-");
if let Some((year, rest)) = s.split_once('-')
&& let Some((month, day)) = rest.split_once('-')
{
return Cow::Owned(format!("{year}-{month:0>2}-{day:0>2}"));
}
Cow::Owned(s)
}
fn describe_invalid_date(s: &str) -> String {
let parts: Vec<&str> = s.split(['-', '/']).collect();
if parts.len() == 3
&& let (Ok(year), Ok(month), Ok(day)) = (
parts[0].parse::<i32>(),
parts[1].parse::<u32>(),
parts[2].parse::<u32>(),
)
{
if !(1..=12).contains(&month) {
return format!("month {month} out of range");
}
let year_month = format!("{year}-{month:02}");
return format!("day {day} out of range for {year_month}");
}
format!("invalid date '{s}'")
}
fn parse_number(stream: &mut TokenStream<'_>) -> ParseRes<Decimal> {
if let Some(t) = stream.peek()
&& let Token::Number(s) = &t.token
{
let has_commas = s.contains(',');
if !has_commas && let Some(num) = fast_parse_decimal(s) {
stream.advance();
return Ok(num);
}
let cleaned = if has_commas {
Cow::Owned(s.replace(',', ""))
} else {
Cow::Borrowed(*s)
};
if let Ok(num) = Decimal::from_str(&cleaned) {
stream.advance();
return Ok(num);
}
}
Err(())
}
fn fast_parse_decimal(s: &str) -> Option<Decimal> {
let bytes = s.as_bytes();
if bytes.is_empty() {
return None;
}
let mut mantissa: u128 = 0;
let mut scale: u32 = 0;
let mut in_decimal = false;
for &b in bytes {
match b {
b'0'..=b'9' => {
mantissa = mantissa
.checked_mul(10)?
.checked_add(u128::from(b - b'0'))?;
if in_decimal {
scale += 1;
}
}
b'.' if !in_decimal => {
in_decimal = true;
}
_ => return None, }
}
let mantissa_i128 = i128::try_from(mantissa).ok()?;
Decimal::try_from_i128_with_scale(mantissa_i128, scale).ok()
}
fn parse_signed_number(stream: &mut TokenStream<'_>) -> ParseRes<Decimal> {
let negate = stream
.peek()
.is_some_and(|t| matches!(t.token, Token::Minus));
if negate {
stream.advance();
}
let n = parse_number(stream)?;
Ok(if negate { -n } else { n })
}
fn parse_string<'a>(stream: &mut TokenStream<'a>) -> ParseRes<Cow<'a, str>> {
if let Some(t) = stream.peek()
&& let Token::String(s) = &t.token
{
let inner = &s[1..s.len() - 1];
let result = if inner.contains('\\') {
Cow::Owned(process_string_escapes(inner))
} else {
Cow::Borrowed(inner)
};
stream.advance();
return Ok(result);
}
Err(())
}
fn parse_string_owned(stream: &mut TokenStream<'_>) -> ParseRes<String> {
parse_string(stream).map(Cow::into_owned)
}
fn process_string_escapes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some('\\') => result.push('\\'),
Some('"') => result.push('"'),
Some(other) => {
result.push('\\');
result.push(other);
}
None => result.push('\\'),
}
} else {
result.push(c);
}
}
result
}
fn parse_account(stream: &mut TokenStream<'_>) -> ParseRes<InternedStr> {
if let Some(t) = stream.peek()
&& let Token::Account(s) = &t.token
{
let result = stream.interner.intern(s);
stream.advance();
return Ok(result);
}
Err(())
}
fn parse_currency(stream: &mut TokenStream<'_>) -> ParseRes<InternedStr> {
if let Some(t) = stream.peek()
&& let Token::Currency(s) = &t.token
{
let result = stream.interner.intern(s);
stream.advance();
return Ok(result);
}
Err(())
}
fn parse_tag(stream: &mut TokenStream<'_>) -> ParseRes<InternedStr> {
if let Some(t) = stream.peek()
&& let Token::Tag(s) = &t.token
{
let result = stream.interner.intern(&s[1..]); stream.advance();
return Ok(result);
}
Err(())
}
fn parse_link(stream: &mut TokenStream<'_>) -> ParseRes<InternedStr> {
if let Some(t) = stream.peek()
&& let Token::Link(s) = &t.token
{
let result = stream.interner.intern(&s[1..]); stream.advance();
return Ok(result);
}
Err(())
}
fn parse_flag(stream: &mut TokenStream<'_>) -> ParseRes<char> {
if let Some(t) = stream.peek() {
match &t.token {
Token::Star => {
stream.advance();
return Ok('*');
}
Token::Pending => {
stream.advance();
return Ok('!');
}
Token::Hash => {
stream.advance();
return Ok('#');
}
Token::Flag(s) => {
let c = s.chars().next().unwrap_or('*');
stream.advance();
return Ok(c);
}
Token::Currency(s) if s.len() == 1 => {
let c = s.chars().next().unwrap();
stream.advance();
return Ok(c);
}
_ => {}
}
}
Err(())
}
fn parse_meta_key(stream: &mut TokenStream<'_>) -> ParseRes<String> {
if let Some(t) = stream.peek()
&& let Token::MetaKey(s) = &t.token
{
let result = s[..s.len() - 1].to_string(); stream.advance();
return Ok(result);
}
Err(())
}
fn parse_boolean(stream: &mut TokenStream<'_>) -> ParseRes<bool> {
if let Some(t) = stream.peek() {
match &t.token {
Token::True => {
stream.advance();
return Ok(true);
}
Token::False => {
stream.advance();
return Ok(false);
}
_ => {}
}
}
Err(())
}
macro_rules! expect_token {
($stream:expr, $pat:pat) => {
if let Some(t) = $stream.peek() {
if matches!(t.token, $pat) {
$stream.advance();
Ok(())
} else {
Err(())
}
} else {
Err(())
}
};
}
fn skip_newlines(stream: &mut TokenStream<'_>) {
while let Some(t) = stream.peek() {
if matches!(t.token, Token::Newline) {
stream.advance();
} else {
break;
}
}
}
fn skip_comment(stream: &mut TokenStream<'_>) {
if let Some(t) = stream.peek()
&& matches!(t.token, Token::Comment(_) | Token::PercentComment(_))
{
stream.advance();
}
}
fn capture_comment(stream: &mut TokenStream<'_>) -> Option<String> {
if let Some(t) = stream.peek() {
match &t.token {
Token::Comment(c) | Token::PercentComment(c) => {
let comment = c.to_string();
stream.advance();
return Some(comment);
}
_ => {}
}
}
None
}
fn parse_primary(stream: &mut TokenStream<'_>) -> ParseRes<Decimal> {
if let Some(t) = stream.peek() {
if matches!(t.token, Token::LParen) {
stream.advance();
let expr = parse_expr(stream)?;
expect_token!(stream, Token::RParen)?;
return Ok(expr);
}
if matches!(t.token, Token::Minus) {
stream.advance();
let n = parse_primary(stream)?;
return Ok(-n);
}
if matches!(t.token, Token::Plus) {
stream.advance();
return parse_primary(stream);
}
if let Token::Date(s) = &t.token {
if s.contains('/') {
return Err(());
}
let parts: Vec<&str> = s.splitn(3, '-').collect();
if parts.len() == 3
&& let (Ok(a), Ok(b), Ok(c)) = (
Decimal::from_str(parts[0]),
Decimal::from_str(parts[1]),
Decimal::from_str(parts[2]),
)
{
stream.advance();
let result = a.checked_sub(b).and_then(|r| r.checked_sub(c)).ok_or(())?;
return Ok(result);
}
}
}
parse_number(stream)
}
fn parse_term(stream: &mut TokenStream<'_>) -> ParseRes<Decimal> {
let mut result = parse_primary(stream)?;
while let Some(t) = stream.peek() {
match &t.token {
Token::Star => {
stream.advance();
let rhs = parse_primary(stream)?;
result = result.checked_mul(rhs).ok_or(())?;
}
Token::Slash => {
stream.advance();
let rhs = parse_primary(stream)?;
if rhs.is_zero() {
return Err(());
}
result = result.checked_div(rhs).ok_or(())?;
}
_ => break,
}
}
Ok(result)
}
fn parse_expr(stream: &mut TokenStream<'_>) -> ParseRes<Decimal> {
let mut result = parse_term(stream)?;
while let Some(t) = stream.peek() {
match &t.token {
Token::Plus => {
stream.advance();
let rhs = parse_term(stream)?;
result = result.checked_add(rhs).ok_or(())?;
}
Token::Minus => {
stream.advance();
let rhs = parse_term(stream)?;
result = result.checked_sub(rhs).ok_or(())?;
}
_ => break,
}
}
Ok(result)
}
fn parse_amount(stream: &mut TokenStream<'_>) -> ParseRes<Amount> {
let number = parse_expr(stream)?;
let currency = parse_currency(stream)?;
Ok(Amount::new(number, currency))
}
fn parse_incomplete_amount(stream: &mut TokenStream<'_>) -> ParseRes<IncompleteAmount> {
let start_pos = stream.pos;
if let Ok(number) = parse_expr(stream) {
if let Ok(currency) = parse_currency(stream) {
return Ok(IncompleteAmount::Complete(Amount::new(number, currency)));
}
return Ok(IncompleteAmount::NumberOnly(number));
}
stream.pos = start_pos;
if let Ok(currency) = parse_currency(stream) {
return Ok(IncompleteAmount::CurrencyOnly(currency));
}
Err(())
}
fn parse_cost_spec(stream: &mut TokenStream<'_>) -> ParseRes<CostSpec> {
let is_total;
let brace_span = stream.peek().map_or((0, 0), |t| t.span);
if let Some(t) = stream.peek() {
match &t.token {
Token::LDoubleBrace => {
stream.advance();
is_total = true;
}
Token::LBraceHash => {
stream.advance();
is_total = true;
}
Token::LBrace => {
stream.advance();
is_total = false;
}
_ => return Err(()),
}
} else {
return Err(());
}
let mut spec = CostSpec::default();
let set_unclosed_error = |stream: &mut TokenStream<'_>| {
stream.deferred_error = Some(ParseError::new(
ParseErrorKind::SyntaxError("unclosed cost specification: missing '}'".to_string()),
Span::new(brace_span.0, brace_span.1),
));
};
loop {
if let Some(t) = stream.peek() {
match &t.token {
Token::RBrace | Token::RDoubleBrace => {
stream.advance();
break;
}
Token::Comma => {
stream.advance();
continue;
}
Token::Newline => {
set_unclosed_error(stream);
return Err(());
}
_ => {}
}
} else {
set_unclosed_error(stream);
return Err(());
}
if let Some(t) = stream.peek()
&& matches!(t.token, Token::Star)
{
stream.advance();
spec.merge = true;
continue;
}
if let Ok(date) = parse_date(stream) {
spec.date = Some(date);
} else if let Ok(label) = parse_string_owned(stream) {
spec.label = Some(label);
} else if let Ok(number) = parse_expr(stream) {
if let Some(t) = stream.peek()
&& matches!(t.token, Token::Hash)
{
stream.advance();
if let Ok(total) = parse_expr(stream) {
spec.number_total = Some(total);
if let Ok(c) = parse_currency(stream) {
spec.currency = Some(c);
}
continue;
}
}
if is_total {
spec.number_total = Some(number);
} else {
spec.number_per = Some(number);
}
if let Ok(c) = parse_currency(stream) {
spec.currency = Some(c);
}
} else {
stream.advance();
}
}
Ok(spec)
}
fn parse_price_annotation(stream: &mut TokenStream<'_>) -> ParseRes<PriceAnnotation> {
let is_total = if let Some(t) = stream.peek() {
match &t.token {
Token::AtAt => {
stream.advance();
true
}
Token::At => {
stream.advance();
false
}
_ => return Err(()),
}
} else {
return Err(());
};
let save_pos = stream.pos;
if let Ok(amount) = parse_amount(stream) {
return Ok(if is_total {
PriceAnnotation::Total(amount)
} else {
PriceAnnotation::Unit(amount)
});
}
stream.pos = save_pos;
if let Ok(currency) = parse_currency(stream) {
let incomplete = IncompleteAmount::CurrencyOnly(currency);
return Ok(if is_total {
PriceAnnotation::TotalIncomplete(incomplete)
} else {
PriceAnnotation::UnitIncomplete(incomplete)
});
}
stream.pos = save_pos;
if let Ok(number) = parse_expr(stream) {
let incomplete = IncompleteAmount::NumberOnly(number);
return Ok(if is_total {
PriceAnnotation::TotalIncomplete(incomplete)
} else {
PriceAnnotation::UnitIncomplete(incomplete)
});
}
stream.pos = save_pos;
Err(())
}
fn parse_posting(stream: &mut TokenStream<'_>) -> ParseRes<Posting> {
if let Some(t) = stream.peek() {
if !matches!(t.token, Token::Indent(_) | Token::DeepIndent(_)) {
return Err(());
}
stream.advance();
} else {
return Err(());
}
let flag = parse_flag(stream).ok();
let account = parse_account(stream)?;
let amount = parse_incomplete_amount(stream).ok();
let cost = if matches!(
stream.peek_token(),
Some(Token::LBrace | Token::LBraceHash | Token::LDoubleBrace)
) {
Some(parse_cost_spec(stream)?)
} else {
None
};
let price = parse_price_annotation(stream).ok();
let trailing_comment = capture_comment(stream);
let posting_meta = parse_posting_metadata(stream);
let mut posting = if let Some(amt) = amount {
Posting::with_incomplete(account, amt)
} else {
Posting::auto(account)
};
if let Some(f) = flag {
posting.flag = Some(f);
}
if let Some(c) = cost {
posting.cost = Some(c);
}
if let Some(p) = price {
posting.price = Some(p);
}
posting.meta = posting_meta;
if let Some(c) = trailing_comment {
posting.trailing_comments.push(c);
}
Ok(posting)
}
fn parse_posting_metadata_line(stream: &mut TokenStream<'_>) -> ParseRes<(String, MetaValue)> {
if let Some(t) = stream.peek() {
if !matches!(t.token, Token::DeepIndent(_)) {
return Err(());
}
stream.advance();
} else {
return Err(());
}
let key = parse_meta_key(stream)?;
let value = parse_meta_value(stream)?;
skip_comment(stream);
Ok((key, value))
}
fn parse_posting_metadata(stream: &mut TokenStream<'_>) -> Metadata {
let mut meta: Metadata = Metadata::default();
loop {
skip_newlines(stream);
let save_pos = stream.pos;
if let Ok((key, value)) = parse_posting_metadata_line(stream) {
meta.insert(key, value);
} else {
stream.pos = save_pos;
break;
}
}
meta
}
fn parse_meta_value(stream: &mut TokenStream<'_>) -> ParseRes<MetaValue> {
if let Ok(s) = parse_string_owned(stream) {
return Ok(MetaValue::String(s));
}
if let Ok(b) = parse_boolean(stream) {
return Ok(MetaValue::Bool(b));
}
if let Ok(a) = parse_account(stream) {
return Ok(MetaValue::Account(a.to_string()));
}
if let Ok(d) = parse_date(stream) {
return Ok(MetaValue::Date(d));
}
if let Ok(tag) = parse_tag(stream) {
return Ok(MetaValue::Tag(tag.to_string()));
}
if let Ok(link) = parse_link(stream) {
return Ok(MetaValue::Link(link.to_string()));
}
let start_pos = stream.pos;
if let Ok(amt) = parse_amount(stream) {
return Ok(MetaValue::Amount(amt));
}
stream.pos = start_pos;
if let Ok(n) = parse_expr(stream) {
return Ok(MetaValue::Number(n));
}
if let Ok(c) = parse_currency(stream) {
return Ok(MetaValue::Currency(c.to_string()));
}
Err(())
}
fn parse_metadata_with_comments(stream: &mut TokenStream<'_>) -> Metadata {
let mut meta: Metadata = Metadata::default();
loop {
skip_newlines(stream);
let save_pos = stream.pos;
let Some(t) = stream.peek() else {
break;
};
match &t.token {
Token::Indent(_) | Token::DeepIndent(_) => {
stream.advance();
if let Some(t) = stream.peek()
&& matches!(t.token, Token::Comment(_) | Token::PercentComment(_))
{
stream.advance();
continue;
}
if let Ok(key) = parse_meta_key(stream) {
let value = parse_meta_value(stream).ok();
if let Some(v) = value {
meta.insert(key, v);
} else {
meta.insert(key, MetaValue::None);
}
skip_comment(stream);
continue;
}
stream.pos = save_pos;
break;
}
_ => break,
}
}
meta
}
enum ParsedItem {
Directive(Directive, Span),
DirectiveWithPipe(Directive, Span),
DirectiveError(ParseError, Span),
Option(String, String, Span),
Include(String, Span),
Plugin(String, Option<String>, Span),
Pushtag(InternedStr, Span),
Poptag(InternedStr, Span),
Pushmeta(String, MetaValue, Span),
Popmeta(String, Span),
Comment(String, Span),
}
fn parse_option_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
expect_token!(stream, Token::Option_)?;
let key = parse_string_owned(stream)?;
let value = parse_string_owned(stream)?;
let span = stream.span_from(start_pos);
Ok(ParsedItem::Option(key, value, span))
}
fn parse_include_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
expect_token!(stream, Token::Include)?;
let path = parse_string_owned(stream)?;
let span = stream.span_from(start_pos);
Ok(ParsedItem::Include(path, span))
}
fn parse_plugin_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
expect_token!(stream, Token::Plugin)?;
let name = parse_string_owned(stream)?;
let config = parse_string_owned(stream).ok();
let span = stream.span_from(start_pos);
Ok(ParsedItem::Plugin(name, config, span))
}
fn parse_pushtag_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
expect_token!(stream, Token::Pushtag)?;
let tag = parse_tag(stream)?;
let span = stream.span_from(start_pos);
Ok(ParsedItem::Pushtag(tag, span))
}
fn parse_poptag_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
expect_token!(stream, Token::Poptag)?;
let tag = parse_tag(stream)?;
let span = stream.span_from(start_pos);
Ok(ParsedItem::Poptag(tag, span))
}
fn parse_pushmeta_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
expect_token!(stream, Token::Pushmeta)?;
let key = parse_meta_key(stream)?;
let value = parse_meta_value(stream)?;
let span = stream.span_from(start_pos);
Ok(ParsedItem::Pushmeta(key, value, span))
}
fn parse_popmeta_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
expect_token!(stream, Token::Popmeta)?;
let key = parse_meta_key(stream)?;
let span = stream.span_from(start_pos);
Ok(ParsedItem::Popmeta(key, span))
}
fn parse_transaction_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
let flag = if let Some(t) = stream.peek() {
match &t.token {
Token::Txn => {
stream.advance();
'*'
}
Token::Star | Token::Pending | Token::Hash | Token::Flag(_) => parse_flag(stream)?,
Token::Currency(s) if s.len() == 1 => parse_flag(stream)?,
Token::String(_) => '*', _ => return Err(()),
}
} else {
return Err(());
};
let mut strings: Vec<Cow<'_, str>> = Vec::with_capacity(2);
let mut has_pipe = false;
while let Ok(s) = parse_string(stream) {
strings.push(s);
if let Some(t) = stream.peek()
&& matches!(t.token, Token::Pipe)
{
stream.advance();
has_pipe = true;
}
}
let mut tags: Vec<InternedStr> = Vec::new();
let mut links: Vec<InternedStr> = Vec::new();
loop {
if let Ok(tag) = parse_tag(stream) {
tags.push(tag);
} else if let Ok(link) = parse_link(stream) {
links.push(link);
} else {
break;
}
}
skip_comment(stream);
let mut txn_meta: Metadata = Metadata::default();
let mut postings = Vec::with_capacity(4);
let mut pending_comments: Vec<String> = Vec::new();
loop {
skip_newlines(stream);
let save_pos = stream.pos;
if let Some(t) = stream.peek() {
match &t.token {
Token::Indent(_) | Token::DeepIndent(_) => {
stream.advance();
if let Some(t) = stream.peek()
&& let Token::Comment(c) | Token::PercentComment(c) = &t.token
{
pending_comments.push(c.to_string());
stream.advance();
continue;
}
let mut found_tag_or_link = false;
loop {
if let Ok(tag) = parse_tag(stream) {
tags.push(tag);
found_tag_or_link = true;
} else if let Ok(link) = parse_link(stream) {
links.push(link);
found_tag_or_link = true;
} else {
break;
}
}
if found_tag_or_link {
skip_comment(stream);
continue;
}
if let Ok(key) = parse_meta_key(stream) {
let value = parse_meta_value(stream).ok();
if let Some(v) = value {
txn_meta.insert(key, v);
} else {
txn_meta.insert(key, MetaValue::None);
}
skip_comment(stream);
continue;
}
stream.pos = save_pos;
}
_ => {}
}
}
if let Ok(mut posting) = parse_posting(stream) {
if !pending_comments.is_empty() {
posting.comments = std::mem::take(&mut pending_comments);
}
postings.push(posting);
} else {
if stream.deferred_error.is_some() {
return Err(());
}
break;
}
}
let txn_trailing_comments = pending_comments;
let (payee, narration): (Option<InternedStr>, InternedStr) = if has_pipe && strings.len() >= 2 {
let p: InternedStr = strings.remove(0).as_ref().into();
let n: InternedStr = strings.remove(0).as_ref().into();
(Some(p), n)
} else {
match strings.len() {
0 => (None, InternedStr::from("")),
1 => {
let n: InternedStr = strings.remove(0).as_ref().into();
(None, n)
}
_ => {
let p: InternedStr = strings.remove(0).as_ref().into();
let n: InternedStr = strings.remove(0).as_ref().into();
(Some(p), n)
}
}
};
let mut txn = Transaction::new(date, narration).with_flag(flag);
if let Some(p) = payee {
txn = txn.with_payee(p);
}
for t in tags {
txn = txn.with_tag(t);
}
for l in links {
txn = txn.with_link(l);
}
for p in postings {
txn = txn.with_posting(p);
}
txn.meta = txn_meta;
txn.trailing_comments = txn_trailing_comments;
let span = stream.span_from(start_pos);
if has_pipe {
Ok(ParsedItem::DirectiveWithPipe(
Directive::Transaction(txn),
span,
))
} else {
Ok(ParsedItem::Directive(Directive::Transaction(txn), span))
}
}
fn parse_balance_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Balance)?;
let account = parse_account(stream)?;
let number = parse_expr(stream)?;
let tolerance = if let Some(t) = stream.peek() {
if matches!(t.token, Token::Tilde) {
stream.advance();
parse_signed_number(stream).ok()
} else {
None
}
} else {
None
};
let currency = parse_currency(stream)?;
let amount = Amount::new(number, currency);
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let balance = Balance {
date,
account,
amount,
tolerance,
meta,
};
Ok(ParsedItem::Directive(Directive::Balance(balance), span))
}
fn parse_open_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Open)?;
let account = parse_account(stream)?;
let mut currencies: Vec<InternedStr> = Vec::with_capacity(3);
while let Ok(c) = parse_currency(stream) {
currencies.push(c);
if let Some(t) = stream.peek()
&& matches!(t.token, Token::Comma)
{
stream.advance();
}
}
let booking = if let Ok(s) = parse_string_owned(stream) {
const VALID_BOOKING_METHODS: &[&str] = &[
"FIFO",
"STRICT",
"STRICT_WITH_SIZE",
"LIFO",
"HIFO",
"NONE",
"AVERAGE",
];
if !VALID_BOOKING_METHODS.contains(&s.as_str()) {
skip_comment(stream);
let span = stream.span_from(start_pos);
let err = ParseError::new(ParseErrorKind::InvalidBookingMethod(s), span);
stream.skip_to_newline();
parse_metadata_with_comments(stream);
return Ok(ParsedItem::DirectiveError(err, span));
}
Some(s)
} else {
None
};
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let open = Open {
date,
account,
currencies,
booking,
meta,
};
Ok(ParsedItem::Directive(Directive::Open(open), span))
}
fn parse_close_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Close)?;
let account = parse_account(stream)?;
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let close = Close {
date,
account,
meta,
};
Ok(ParsedItem::Directive(Directive::Close(close), span))
}
fn parse_commodity_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Commodity)?;
let currency = parse_currency(stream)?;
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let commodity = Commodity {
date,
currency,
meta,
};
Ok(ParsedItem::Directive(Directive::Commodity(commodity), span))
}
fn parse_pad_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Pad)?;
let account = parse_account(stream)?;
let source = parse_account(stream)?;
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let pad = Pad {
date,
account,
source_account: source,
meta,
};
Ok(ParsedItem::Directive(Directive::Pad(pad), span))
}
fn parse_event_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Event)?;
let event_type = parse_string_owned(stream)?;
let value = parse_string_owned(stream)?;
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let event = Event {
date,
event_type,
value,
meta,
};
Ok(ParsedItem::Directive(Directive::Event(event), span))
}
fn parse_query_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Query)?;
let name = parse_string_owned(stream)?;
let query = parse_string_owned(stream)?;
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let query_directive = Query {
date,
name,
query,
meta,
};
Ok(ParsedItem::Directive(
Directive::Query(query_directive),
span,
))
}
fn parse_note_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Note)?;
let account = parse_account(stream)?;
let comment = parse_string_owned(stream)?;
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let note = Note {
date,
account,
comment,
meta,
};
Ok(ParsedItem::Directive(Directive::Note(note), span))
}
fn parse_document_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Document)?;
let account = parse_account(stream)?;
let path = parse_string_owned(stream)?;
let mut tags: Vec<InternedStr> = Vec::new();
let mut links: Vec<InternedStr> = Vec::new();
loop {
if let Ok(tag) = parse_tag(stream) {
tags.push(tag);
} else if let Ok(link) = parse_link(stream) {
links.push(link);
} else {
break;
}
}
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let doc = Document {
date,
account,
path,
tags,
links,
meta,
};
Ok(ParsedItem::Directive(Directive::Document(doc), span))
}
fn parse_price_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Price)?;
let currency = parse_currency(stream)?;
let amount = parse_amount(stream)?;
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let price = Price {
date,
currency,
amount,
meta,
};
Ok(ParsedItem::Directive(Directive::Price(price), span))
}
fn parse_custom_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
let start_pos = stream.pos;
let date = parse_date(stream)?;
expect_token!(stream, Token::Custom)?;
let name = parse_string_owned(stream)?;
let mut values = Vec::with_capacity(4);
loop {
if let Ok(s) = parse_string_owned(stream) {
values.push(MetaValue::String(s));
continue;
}
if let Ok(a) = parse_account(stream) {
values.push(MetaValue::Account(a.to_string()));
continue;
}
if let Ok(b) = parse_boolean(stream) {
values.push(MetaValue::Bool(b));
continue;
}
let save_pos = stream.pos;
if let Ok(amt) = parse_amount(stream) {
values.push(MetaValue::Amount(amt));
continue;
}
stream.pos = save_pos;
if let Ok(n) = parse_signed_number(stream) {
values.push(MetaValue::Number(n));
continue;
}
if let Ok(d) = parse_date(stream) {
values.push(MetaValue::Date(d));
continue;
}
if let Ok(c) = parse_currency(stream) {
values.push(MetaValue::Currency(c.to_string()));
continue;
}
break;
}
skip_comment(stream);
let meta = parse_metadata_with_comments(stream);
let span = stream.span_from(start_pos);
let custom = Custom {
date,
custom_type: name,
values,
meta,
};
Ok(ParsedItem::Directive(Directive::Custom(custom), span))
}
fn parse_dated_directive(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
if stream.tokens.get(stream.pos + 1).is_none() {
return Err(());
}
let second = &stream.tokens[stream.pos + 1].token;
match second {
Token::Txn
| Token::Star
| Token::Pending
| Token::Hash
| Token::Flag(_)
| Token::String(_) => parse_transaction_directive(stream),
Token::Currency(s) if s.len() == 1 => parse_transaction_directive(stream),
Token::Balance => parse_balance_directive(stream),
Token::Open => parse_open_directive(stream),
Token::Close => parse_close_directive(stream),
Token::Commodity => parse_commodity_directive(stream),
Token::Pad => parse_pad_directive(stream),
Token::Event => parse_event_directive(stream),
Token::Query => parse_query_directive(stream),
Token::Note => parse_note_directive(stream),
Token::Document => parse_document_directive(stream),
Token::Price => parse_price_directive(stream),
Token::Custom => parse_custom_directive(stream),
_ => Err(()),
}
}
fn parse_entry(stream: &mut TokenStream<'_>) -> ParseRes<ParsedItem> {
skip_newlines(stream);
if stream.is_empty() {
return Err(());
}
let first = stream.peek_token().ok_or(())?;
match first {
Token::Option_ => parse_option_directive(stream),
Token::Include => parse_include_directive(stream),
Token::Plugin => parse_plugin_directive(stream),
Token::Pushtag => parse_pushtag_directive(stream),
Token::Poptag => parse_poptag_directive(stream),
Token::Pushmeta => parse_pushmeta_directive(stream),
Token::Popmeta => parse_popmeta_directive(stream),
Token::Date(_) => parse_dated_directive(stream),
Token::Comment(text) | Token::PercentComment(text) => {
let start_pos = stream.pos;
let text = text.to_string();
stream.advance();
let span = stream.span_from(start_pos);
Ok(ParsedItem::Comment(text, span))
}
Token::Shebang(text) | Token::EmacsDirective(text) => {
let start_pos = stream.pos;
let text = text.to_string();
stream.advance();
let span = stream.span_from(start_pos);
Ok(ParsedItem::Comment(text, span))
}
Token::Star => {
let start_pos = stream.pos;
stream.skip_to_newline();
let span = stream.span_from(start_pos);
Ok(ParsedItem::Comment(String::new(), span))
}
_ => Err(()),
}
}
fn apply_pushed_tags(directive: &mut Directive, tag_stack: &[(InternedStr, Span)]) {
if tag_stack.is_empty() {
return;
}
if let Directive::Transaction(txn) = directive {
for (tag, _) in tag_stack {
if !txn.tags.contains(tag) {
txn.tags.push(tag.clone());
}
}
}
}
fn apply_pushed_meta(directive: &mut Directive, meta_stack: &[(String, MetaValue, Span)]) {
if meta_stack.is_empty() {
return;
}
let meta = match directive {
Directive::Transaction(d) => &mut d.meta,
Directive::Balance(d) => &mut d.meta,
Directive::Open(d) => &mut d.meta,
Directive::Close(d) => &mut d.meta,
Directive::Commodity(d) => &mut d.meta,
Directive::Pad(d) => &mut d.meta,
Directive::Event(d) => &mut d.meta,
Directive::Query(d) => &mut d.meta,
Directive::Note(d) => &mut d.meta,
Directive::Document(d) => &mut d.meta,
Directive::Price(d) => &mut d.meta,
Directive::Custom(d) => &mut d.meta,
};
for (key, value, _) in meta_stack {
meta.insert(key.clone(), value.clone());
}
}
pub fn parse(source: &str) -> ParseResult {
let raw_tokens: Vec<SpannedToken<'_>> = tokenize(source)
.into_iter()
.map(|(token, span)| SpannedToken {
token,
span: (span.start, span.end),
})
.collect();
let mut stream = TokenStream::new(&raw_tokens);
let mut directives = Vec::with_capacity((source.len() / 50).min(MAX_PREALLOC_DIRECTIVES));
let mut options = Vec::with_capacity(4);
let mut includes = Vec::with_capacity(4);
let mut plugins = Vec::with_capacity(4);
let mut comments = Vec::with_capacity((source.len() / 100).min(MAX_PREALLOC_COMMENTS));
let mut errors = Vec::with_capacity(4);
let mut tag_stack: Vec<(InternedStr, Span)> = Vec::with_capacity(8);
let mut meta_stack: Vec<(String, MetaValue, Span)> = Vec::with_capacity(8);
while !stream.is_empty() {
skip_newlines(&mut stream);
if stream.is_empty() {
break;
}
let error_start = stream.pos;
if let Ok(item) = parse_entry(&mut stream) {
stream.deferred_error = None;
match item {
ParsedItem::Directive(mut d, span) => {
apply_pushed_tags(&mut d, &tag_stack);
apply_pushed_meta(&mut d, &meta_stack);
directives.push(Spanned::new(d, span));
}
ParsedItem::DirectiveWithPipe(mut d, span) => {
errors.push(ParseError::new(ParseErrorKind::DeprecatedPipeSymbol, span));
apply_pushed_tags(&mut d, &tag_stack);
apply_pushed_meta(&mut d, &meta_stack);
directives.push(Spanned::new(d, span));
}
ParsedItem::DirectiveError(err, _span) => {
errors.push(err);
}
ParsedItem::Option(k, v, span) => options.push((k, v, span)),
ParsedItem::Include(p, span) => includes.push((p, span)),
ParsedItem::Plugin(p, c, span) => plugins.push((p, c, span)),
ParsedItem::Pushtag(tag, span) => tag_stack.push((tag, span)),
ParsedItem::Poptag(tag, span) => {
if let Some(pos) = tag_stack.iter().rposition(|(t, _)| t == &tag) {
tag_stack.remove(pos);
} else {
errors.push(ParseError::new(
ParseErrorKind::InvalidPoptag(tag.to_string()),
span,
));
}
}
ParsedItem::Pushmeta(key, value, span) => meta_stack.push((key, value, span)),
ParsedItem::Popmeta(key, span) => {
if let Some(pos) = meta_stack.iter().rposition(|(k, _, _)| k == &key) {
meta_stack.remove(pos);
} else {
errors.push(ParseError::new(ParseErrorKind::InvalidPopmeta(key), span));
}
}
ParsedItem::Comment(text, span) => {
comments.push(Spanned::new(text, span));
}
}
} else {
stream.skip_to_newline();
let span = stream.span_from(error_start);
if let Some(err) = stream.deferred_error.take() {
errors.push(err);
} else {
let error_text = &source[span.start..span.end.min(source.len())];
let kind = if error_text.starts_with('\u{FEFF}') {
ParseErrorKind::SyntaxError("Invalid token: UTF-8 BOM detected; remove the BOM from the beginning of the file".to_string())
} else if let Some(account) = find_unicode_account(error_text) {
ParseErrorKind::InvalidAccount(account.to_string())
} else {
ParseErrorKind::SyntaxError("unexpected input".to_string())
};
errors.push(ParseError::new(kind, span));
}
}
}
for (tag, span) in &tag_stack {
errors.push(ParseError::new(
ParseErrorKind::UnclosedPushtag(tag.to_string()),
*span,
));
}
for (key, _, span) in &meta_stack {
errors.push(ParseError::new(
ParseErrorKind::UnclosedPushmeta(key.clone()),
*span,
));
}
ParseResult {
directives,
options,
includes,
plugins,
comments,
errors,
warnings: Vec::new(),
}
}
fn find_unicode_account(text: &str) -> Option<&str> {
for token in text.split_whitespace() {
if !token.contains(':') {
continue;
}
let first_char = token.chars().next().unwrap_or(' ');
if !first_char.is_uppercase() {
continue;
}
if !token.is_ascii() {
return Some(token);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_transaction() {
let source = r#"
2024-01-15 * "Coffee Shop" "Morning coffee"
Expenses:Food:Coffee 5.00 USD
Assets:Cash
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
}
#[test]
fn test_parse_balance() {
let source = "2024-01-01 balance Assets:Bank 1000.00 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
}
#[test]
fn test_parse_open() {
let source = "2024-01-01 open Assets:Bank USD EUR\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
}
#[test]
fn test_parse_option() {
let source = "option \"title\" \"My Ledger\"\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.options.len(), 1);
assert_eq!(result.options[0].0, "title");
assert_eq!(result.options[0].1, "My Ledger");
}
#[test]
fn test_parse_include() {
let source = "include \"other.beancount\"\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.includes.len(), 1);
assert_eq!(result.includes[0].0, "other.beancount");
}
#[test]
fn test_parse_plugin() {
let source = "plugin \"beancount.plugins.auto_accounts\"\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.plugins.len(), 1);
}
#[test]
fn test_parse_arithmetic() {
let source = "2024-01-01 balance Assets:Bank 1000 + 500 - 200 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Balance(b) = &result.directives[0].value {
assert_eq!(b.amount.number, Decimal::from(1300));
} else {
panic!("Expected Balance directive");
}
}
#[test]
fn test_parse_division_by_zero_does_not_panic() {
let source = "2024-01-01 balance Assets:Bank 1/0 USD\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for division by zero"
);
}
#[test]
fn test_parse_inline_comment_before_posting() {
let source = r#"2024-01-15 * "Test"
; This is an inline comment
Expenses:Food 50.00 USD
Assets:Bank
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(txn.postings.len(), 2);
assert_eq!(
txn.postings[0].comments,
vec!["; This is an inline comment".to_string()]
);
assert!(txn.postings[1].comments.is_empty());
} else {
panic!("Expected Transaction directive");
}
}
#[test]
fn test_parse_multiple_comments_before_posting() {
let source = r#"2024-01-15 * "Test"
; Comment 1
; Comment 2
Expenses:Food 50.00 USD
Assets:Bank
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(
txn.postings[0].comments,
vec!["; Comment 1".to_string(), "; Comment 2".to_string()]
);
} else {
panic!("Expected Transaction directive");
}
}
#[test]
fn test_parse_trailing_comment_on_posting() {
let source = r#"2024-01-15 * "Test"
Expenses:Food 50.00 USD ; trailing comment
Assets:Bank
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(
txn.postings[0].trailing_comments,
vec!["; trailing comment".to_string()]
);
} else {
panic!("Expected Transaction directive");
}
}
#[test]
fn test_parse_transaction_trailing_comments() {
let source = r#"2024-01-15 * "Test"
Expenses:Food 50.00 USD
Assets:Bank
; Comment after last posting
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(
txn.trailing_comments,
vec!["; Comment after last posting".to_string()]
);
} else {
panic!("Expected Transaction directive");
}
}
#[test]
fn test_issue_364_comment_preservation_roundtrip() {
use rustledger_core::format::{FormatConfig, format_directive};
let source = r#"2024-01-15 * "Groceries"
; Pre-comment 1 for first posting
; Pre-comment 2 for first posting
Expenses:Food 50.00 USD ; trailing comment on first posting
; Pre-comment for second posting
Assets:Bank
; Transaction trailing comment 1
; Transaction trailing comment 2
"#;
let result1 = parse(source);
assert!(
result1.errors.is_empty(),
"parse errors: {:?}",
result1.errors
);
assert_eq!(result1.directives.len(), 1);
let txn1 = match &result1.directives[0].value {
Directive::Transaction(t) => t,
_ => panic!("Expected Transaction"),
};
assert_eq!(
txn1.postings[0].comments,
vec![
"; Pre-comment 1 for first posting".to_string(),
"; Pre-comment 2 for first posting".to_string()
],
"First posting should have 2 pre-comments"
);
assert_eq!(
txn1.postings[0].trailing_comments,
vec!["; trailing comment on first posting".to_string()],
"First posting should have trailing comment"
);
assert_eq!(
txn1.postings[1].comments,
vec!["; Pre-comment for second posting".to_string()],
"Second posting should have 1 pre-comment"
);
assert_eq!(
txn1.trailing_comments,
vec![
"; Transaction trailing comment 1".to_string(),
"; Transaction trailing comment 2".to_string()
],
"Transaction should have 2 trailing comments"
);
let config = FormatConfig::default();
let formatted = format_directive(&result1.directives[0].value, &config);
let result2 = parse(&formatted);
assert!(
result2.errors.is_empty(),
"re-parse errors: {:?}\nformatted:\n{}",
result2.errors,
formatted
);
assert_eq!(result2.directives.len(), 1);
let txn2 = match &result2.directives[0].value {
Directive::Transaction(t) => t,
_ => panic!("Expected Transaction after roundtrip"),
};
assert_eq!(
txn2.postings[0].comments, txn1.postings[0].comments,
"Roundtrip should preserve first posting pre-comments"
);
assert_eq!(
txn2.postings[0].trailing_comments, txn1.postings[0].trailing_comments,
"Roundtrip should preserve first posting trailing comment"
);
assert_eq!(
txn2.postings[1].comments, txn1.postings[1].comments,
"Roundtrip should preserve second posting pre-comments"
);
assert_eq!(
txn2.trailing_comments, txn1.trailing_comments,
"Roundtrip should preserve transaction trailing comments"
);
}
#[test]
fn test_issue_364_blank_lines_preserved() {
let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Transaction 1"
Expenses:Food 50.00 USD
Assets:Bank
2024-01-16 * "Transaction 2"
Expenses:Food 25.00 USD
Assets:Bank
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 3);
for (i, dir) in result.directives.iter().enumerate() {
assert!(
dir.span.end > dir.span.start,
"Directive {i} should have non-empty span"
);
}
}
#[test]
fn test_bom_produces_invalid_token_error() {
let source = "\u{FEFF}2024-01-01 open Assets:Bank USD\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"BOM should produce a parse error"
);
let msg = result.errors[0].message();
assert!(
msg.contains("Invalid token"),
"BOM error should contain 'Invalid token', got: {msg}"
);
}
#[test]
fn test_unicode_account_parses_successfully() {
let source = "2024-01-01 open Активы:Банк\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Cyrillic account should parse without errors, got: {:?}",
result
.errors
.iter()
.map(ParseError::message)
.collect::<Vec<_>>()
);
assert_eq!(result.directives.len(), 1, "Should have 1 directive");
}
#[test]
fn test_parse_date_two_digit_year_is_rejected() {
let source = "24-01-15 open Assets:Bank USD\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"2-digit years should produce a parse error"
);
assert!(
result.directives.is_empty(),
"2-digit years should not produce any directives, got: {:?}",
result.directives
);
}
#[test]
fn test_parse_date_single_digit_month() {
let source = "2024-1-15 open Assets:Bank USD\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(result.directives.len(), 1, "Expected exactly one directive");
match &result.directives[0].value {
Directive::Open(open) => assert_eq!(
open.date,
rustledger_core::naive_date(2024, 1, 15).unwrap(),
"Single-digit month should normalize to 2024-01-15"
),
other => panic!("Expected Directive::Open, got: {other:?}"),
}
}
#[test]
fn test_process_string_escapes() {
assert_eq!(process_string_escapes("hello\\nworld"), "hello\nworld");
assert_eq!(process_string_escapes("tab\\t"), "tab\t");
assert_eq!(process_string_escapes("say \\\"hello\\\""), "say \"hello\"");
assert_eq!(process_string_escapes("back\\\\slash"), "back\\slash");
assert_eq!(process_string_escapes("plain text"), "plain text");
}
#[test]
fn test_parse_signed_number_in_balance_tolerance() {
let source = "2024-01-01 open Assets:Cash USD\n\
2024-01-15 balance Assets:Cash 100 ~ -1 USD\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(result.directives.len(), 2);
match &result.directives[1].value {
Directive::Balance(balance) => {
assert_eq!(
balance.tolerance,
Some(Decimal::from(-1)),
"Balance tolerance should parse as -1 via parse_signed_number"
);
}
other => panic!("Expected Directive::Balance, got: {other:?}"),
}
}
#[test]
fn test_parse_flag_star() {
let source = r#"
2024-01-15 * "Test"
Assets:Cash 100 USD
Expenses:Test
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Transaction(txn) => assert_eq!(txn.flag, '*'),
other => panic!("Expected Directive::Transaction, got: {other:?}"),
}
}
#[test]
fn test_parse_flag_exclamation() {
let source = r#"
2024-01-15 ! "Test"
Assets:Cash 100 USD
Expenses:Test
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Transaction(txn) => assert_eq!(txn.flag, '!'),
other => panic!("Expected Directive::Transaction, got: {other:?}"),
}
}
#[test]
fn test_parse_option_with_true_string_value() {
let source = "option \"bool\" \"True\"\n";
let result = parse(source);
assert_eq!(result.options.len(), 1);
assert_eq!(result.options[0].1, "True");
}
#[test]
fn test_parse_option_with_false_string_value() {
let source = "option \"bool\" \"False\"\n";
let result = parse(source);
assert_eq!(result.options.len(), 1);
assert_eq!(result.options[0].1, "False");
}
#[test]
fn test_parse_boolean_metadata_value() {
let source = concat!(
"2024-01-01 open Assets:Bank USD\n",
" flag_true: TRUE\n",
" flag_false: FALSE\n",
);
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Open(open) => {
assert_eq!(
open.meta.get("flag_true"),
Some(&MetaValue::Bool(true)),
"TRUE should parse as MetaValue::Bool(true), got: {:?}",
open.meta.get("flag_true")
);
assert_eq!(
open.meta.get("flag_false"),
Some(&MetaValue::Bool(false)),
"FALSE should parse as MetaValue::Bool(false), got: {:?}",
open.meta.get("flag_false")
);
}
other => panic!("Expected Directive::Open, got: {other:?}"),
}
}
#[test]
fn test_parse_arithmetic_multiplication() {
let source = "2024-01-01 balance Assets:Bank 10 * 5 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Balance(b) => assert_eq!(b.amount.number, Decimal::from(50)),
other => panic!("Expected Directive::Balance, got: {other:?}"),
}
}
#[test]
fn test_parse_arithmetic_parentheses() {
let source = "2024-01-01 balance Assets:Bank (10 + 5) * 2 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Balance(b) => assert_eq!(b.amount.number, Decimal::from(30)),
other => panic!("Expected Directive::Balance, got: {other:?}"),
}
}
#[test]
fn test_parse_incomplete_amount_number_only() {
let source = r#"
2024-01-15 * "Test"
Assets:Cash 100
Expenses:Test
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Transaction(txn) => {
assert_eq!(txn.postings.len(), 2);
assert_eq!(
txn.postings[0].units,
Some(IncompleteAmount::NumberOnly(Decimal::from(100))),
"first posting should have units as NumberOnly(100), got: {:?}",
txn.postings[0].units
);
}
other => panic!("Expected Directive::Transaction, got: {other:?}"),
}
}
#[test]
fn test_parse_pushtag_and_poptag_directive() {
let source = "pushtag #tag1\n2024-01-01 open Assets:Bank USD\npoptag #tag1\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
}
#[test]
fn test_parse_poptag_without_push_errors() {
let source = "poptag #neverpushed\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"poptag without pushtag should error"
);
let msg = result.errors[0].message();
assert!(
msg.contains("poptag") || msg.contains("never pushed"),
"error should mention poptag issue, got: {msg}"
);
}
#[test]
fn test_parse_pushmeta_and_popmeta_directive() {
let source = concat!(
"pushmeta key: \"value\"\n",
"2024-01-01 open Assets:Bank USD\n",
"popmeta key:\n",
"2024-01-02 close Assets:Bank\n",
);
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(
result.directives.len(),
2,
"pushmeta/popmeta should not appear as directives; expected just open + close, got: {:?}",
result
.directives
.iter()
.map(|d| format!("{:?}", d.value))
.collect::<Vec<_>>()
);
match &result.directives[0].value {
Directive::Open(open) => {
assert_eq!(
open.meta.get("key"),
Some(&MetaValue::String("value".to_string())),
"Enclosed directive should have pushed metadata applied"
);
}
other => panic!("Expected Directive::Open, got: {other:?}"),
}
match &result.directives[1].value {
Directive::Close(close) => {
assert!(
!close.meta.contains_key("key"),
"Directive after popmeta should not have the popped key, got meta: {:?}",
close.meta
);
}
other => panic!("Expected Directive::Close, got: {other:?}"),
}
}
#[test]
fn test_parse_close_directive() {
let source = "2024-01-01 close Assets:Bank\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
assert!(matches!(result.directives[0].value, Directive::Close(_)));
}
#[test]
fn test_parse_commodity_directive() {
let source = "2024-01-01 commodity USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
assert!(matches!(
result.directives[0].value,
Directive::Commodity(_)
));
}
#[test]
fn test_parse_pad_directive() {
let source = "2024-01-01 pad Assets:Bank Equity:Opening-Balances\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Pad(pad) => {
assert_eq!(pad.account.as_ref(), "Assets:Bank");
assert_eq!(pad.source_account.as_ref(), "Equity:Opening-Balances");
}
other => panic!("Expected Directive::Pad, got: {other:?}"),
}
}
#[test]
fn test_parse_event_directive() {
let source = "2024-01-01 event \"location\" \"Paris\"\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Event(event) => {
assert_eq!(event.event_type, "location");
assert_eq!(event.value, "Paris");
}
other => panic!("Expected Directive::Event, got: {other:?}"),
}
}
#[test]
fn test_parse_note_directive() {
let source = "2024-01-01 note Assets:Bank \"This is a note\"\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Note(note) => {
assert_eq!(note.account.as_ref(), "Assets:Bank");
assert_eq!(note.comment, "This is a note");
}
other => panic!("Expected Directive::Note, got: {other:?}"),
}
}
#[test]
fn test_parse_document_directive() {
let source = "2024-01-01 document Assets:Bank \"2024/report.pdf\"\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Expected no parse errors, got: {:?}",
result.errors
);
assert_eq!(result.directives.len(), 1);
match &result.directives[0].value {
Directive::Document(document) => {
assert_eq!(document.account.as_ref(), "Assets:Bank");
assert_eq!(document.path, "2024/report.pdf");
}
other => panic!("Expected Directive::Document, got: {other:?}"),
}
}
#[test]
fn test_parse_price_directive() {
let source = "2024-01-01 price AAPL 150.00 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
assert!(matches!(result.directives[0].value, Directive::Price(_)));
}
#[test]
fn test_parse_cost_spec_per_unit() {
let source = r#"
2024-01-15 * "Test"
Assets:Stock -10 AAPL {150.00 USD}
Assets:Cash
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
}
#[test]
fn test_parse_cost_spec_date() {
let source = r#"
2024-01-15 * "Test"
Assets:Stock -10 AAPL {2024-01-01}
Assets:Cash
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
}
#[test]
fn test_parse_cost_spec_label() {
let source = r#"
2024-01-15 * "Test"
Assets:Stock -10 AAPL {"purchase"}
Assets:Cash
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
}
#[test]
fn test_parse_cost_spec_merge() {
let source = r#"
2024-01-15 * "Test"
Assets:Stock -10 AAPL {*}
Assets:Cash
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
let txn = result.directives.iter().find_map(|d| {
if let Directive::Transaction(t) = &d.value {
Some(t)
} else {
None
}
});
let posting = &txn.unwrap().postings[0];
let cost = posting.cost.as_ref().expect("should have cost spec");
assert!(cost.merge, "merge flag should be true for {{*}}");
}
#[test]
fn test_parse_price_annotation_unit() {
let source = r#"
2024-01-15 * "Test"
Assets:Stock 10 AAPL @ 150.00 USD
Assets:Cash
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
}
#[test]
fn test_parse_price_annotation_total() {
let source = r#"
2024-01-15 * "Test"
Assets:Stock 10 AAPL @@ 1500.00 USD
Assets:Cash
"#;
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
}
#[test]
fn test_parse_standalone_comment() {
let source = "; This is a standalone comment\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(
result.comments.len(),
1,
"Single-line comment source should produce exactly one comment"
);
}
#[test]
fn test_parse_multiple_standalone_comments() {
let source = "; Comment 1\n; Comment 2\n; Comment 3\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.comments.len(), 3);
}
#[test]
fn test_date_arithmetic_ambiguity_subtraction() {
let source = "2024-01-15 balance Assets:Bank 1000-12-32 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Balance(b) = &result.directives[0].value {
assert_eq!(
b.amount.number,
Decimal::from(956),
"1000-12-32 should evaluate to 956"
);
assert_eq!(b.amount.currency.as_str(), "USD");
} else {
panic!("Expected Balance directive");
}
}
#[test]
fn test_date_arithmetic_ambiguity_single_digit() {
let source = "2024-01-15 balance Assets:Bank 2000-6-4 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Balance(b) = &result.directives[0].value {
assert_eq!(
b.amount.number,
Decimal::from(1990),
"2000-6-4 should evaluate to 1990"
);
} else {
panic!("Expected Balance directive");
}
}
#[test]
fn test_date_at_line_start_still_works() {
let source = "2024-01-15 * \"Test\"\n Assets:Bank 100 USD\n Expenses:Other\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(txn.postings.len(), 2);
} else {
panic!("Expected Transaction directive");
}
}
#[test]
fn test_date_arithmetic_valid_date_in_expression() {
let source = "2024-01-15 balance Assets:Bank 2024-01-15 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Balance(b) = &result.directives[0].value {
assert_eq!(
b.amount.number,
Decimal::from(2008),
"2024-01-15 in amount context should evaluate to 2024 - 1 - 15 = 2008"
);
assert_eq!(b.amount.currency.as_str(), "USD");
} else {
panic!("Expected Balance directive");
}
}
#[test]
fn test_date_arithmetic_precedence_limitation() {
let source = "2024-01-15 balance Assets:Bank 1000-12-32*2 USD\n";
let result = parse(source);
assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
if let Directive::Balance(b) = &result.directives[0].value {
assert_eq!(
b.amount.number,
Decimal::from(1912),
"1000-12-32*2 evaluates as (1000-12-32)*2 = 1912 due to lexer-level tokenization"
);
} else {
panic!("Expected Balance directive");
}
}
#[test]
fn test_slash_date_in_expression_is_error() {
let source = "2024-01-15 balance Assets:Bank 2024/1/5 USD\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"slash-separated date in expression context should produce a parse error"
);
}
fn assert_fast_path_matches_oracle(s: &str) {
let fast = fast_parse_decimal(s);
let oracle = Decimal::from_str(s).ok();
match (fast, oracle) {
(Some(f), Some(o)) => assert_eq!(
f, o,
"fast_parse_decimal({s:?})={f} disagreed with Decimal::from_str={o}"
),
(Some(f), None) => {
panic!("fast_parse_decimal accepted {s:?} as {f} but Decimal::from_str rejected")
}
(None, _) => {} }
}
#[test]
fn fast_parse_decimal_matches_oracle_on_known_inputs() {
for s in [
"0",
"1",
"10",
"100",
"1000",
"0.0",
"0.00",
"0.000",
"0.1",
"0.01",
"0.001",
"0.0001",
"1.0",
"1.00",
"1.23",
"1.230",
"100.50",
"1234.56",
"0.1234567890123456789", "9223372036854775807", "9223372036854775806.0", "99999999999999999999", "5.", ] {
assert_fast_path_matches_oracle(s);
}
}
#[test]
fn fast_parse_decimal_handles_u128_range() {
for s in [
"18446744073709551616",
"123456789.12345678",
"1.234567890123456789012345678",
"79228162514264337593543950335",
] {
let fast = fast_parse_decimal(s);
let oracle = Decimal::from_str(s).ok();
assert!(
fast.is_some(),
"fast path should accept {s:?} now that mantissa is u128"
);
assert_eq!(fast, oracle, "fast vs slow disagree on {s:?}");
}
}
#[test]
fn fast_parse_decimal_opts_out_past_decimal_max() {
for s in [
"79228162514264337593543950336", "1000000000000000000000000000000", ] {
assert_eq!(
fast_parse_decimal(s),
None,
"fast path should opt out on {s:?} so slow path can handle / reject it"
);
}
}
#[test]
fn fast_parse_decimal_rejects_malformed() {
for s in ["", "1.2.3", "abc", "1e5", "-1", "+1", "1,000"] {
assert_eq!(fast_parse_decimal(s), None, "should reject {s:?}");
}
}
#[test]
fn fast_parse_decimal_zero_with_leading_zeros() {
assert_eq!(
fast_parse_decimal("0.01"),
Some(Decimal::from_str("0.01").unwrap())
);
assert_eq!(
fast_parse_decimal("0.001"),
Some(Decimal::from_str("0.001").unwrap())
);
assert_eq!(fast_parse_decimal("0.00"), Some(Decimal::ZERO));
}
proptest::proptest! {
#[test]
fn fast_parse_decimal_agrees_with_decimal_from_str(
s in "[0-9]{1,28}(\\.[0-9]{1,28})?"
) {
let fast = fast_parse_decimal(&s);
let oracle = Decimal::from_str(&s).ok();
if let Some(f) = fast {
proptest::prop_assert_eq!(Some(f), oracle);
}
}
#[test]
fn fast_parse_decimal_round_trips_through_display(
mantissa in 0i64..=i64::MAX,
scale in 0u32..=18
) {
let original = Decimal::new(mantissa, scale);
let s = original.to_string();
let fast = fast_parse_decimal(&s);
let oracle = Decimal::from_str(&s).ok();
if let Some(f) = fast {
proptest::prop_assert_eq!(f, original);
}
if fast.is_none() {
proptest::prop_assert_eq!(oracle, Some(original));
}
}
}
}