use crate::cst::ast::{self, AstNode, AstToken, MetaEntry, SourceFile};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct PostingAlignment {
pub number_col: usize,
pub number_width: usize,
}
const INDENT: &str = " ";
#[must_use]
pub fn format_source(source: &str) -> String {
let (stripped, _had_bom) = crate::bom::strip_leading(source);
let normalized = crlf_to_lf_outside_strings(stripped);
let parsed = SourceFile::parse(&normalized);
format_node(parsed.syntax())
}
#[must_use]
pub fn format_source_with_parsed(parse_result: &crate::ParseResult, source: &str) -> String {
if !parse_result.errors.is_empty() {
return format_source(source);
}
let node = parse_result.syntax_node();
let cst_len =
usize::from(node.text_range().len()) + if parse_result.has_leading_bom { 3 } else { 0 };
debug_assert_eq!(
cst_len,
source.len(),
"format_source_with_parsed called with a `source` whose length doesn't \
match the CST stored in `parse_result`. The two arguments came from \
different documents — the cache path will emit text for the wrong \
buffer. See `ParseResult::alignment` rustdoc for the producer-only \
invariant.",
);
format_node_with_alignment(&node, parse_result.alignment)
}
pub fn try_format_source(source: &str) -> Result<String, Vec<crate::ParseError>> {
let result = crate::parse(source);
if !result.errors.is_empty() {
return Err(result.errors);
}
Ok(format_source_with_parsed(&result, source))
}
#[must_use]
pub fn lf_to_crlf_outside_strings(s: &str) -> String {
let mut out = String::with_capacity(s.len() + s.matches('\n').count());
let (body, bom) = match s.strip_prefix('\u{FEFF}') {
Some(rest) => (rest, "\u{FEFF}"),
None => (s, ""),
};
out.push_str(bom);
let mut chars = body.chars().peekable();
let mut state = SourceState::Code;
let mut prev_was_backslash = false;
while let Some(ch) = chars.next() {
let peek = chars.peek().copied();
match state {
SourceState::InString => out.push(ch),
SourceState::InComment | SourceState::Code => {
if ch == '\n' {
out.push_str("\r\n");
} else {
out.push(ch);
}
}
}
state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
}
out
}
pub fn canonicalize_directives<'a, I>(
directives: I,
config: &rustledger_core::format::FormatConfig,
) -> Result<String, CanonicalizeError>
where
I: IntoIterator<Item = &'a rustledger_core::Directive>,
I::IntoIter: ExactSizeIterator,
{
let iter = directives.into_iter();
let input_count = iter.len();
let raw = rustledger_core::format::format_directives(iter, config);
let parse_result = crate::parse(&raw);
if !parse_result.errors.is_empty() {
return Err(CanonicalizeError::ReparseFailed {
errors: parse_result
.errors
.iter()
.map(ToString::to_string)
.collect(),
});
}
let reparsed_count = parse_result.directives.len();
if reparsed_count != input_count {
return Err(CanonicalizeError::DirectiveCountMismatch {
input: input_count,
reparsed: reparsed_count,
});
}
Ok(format_source(&raw))
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum CanonicalizeError {
ReparseFailed {
errors: Vec<String>,
},
DirectiveCountMismatch {
input: usize,
reparsed: usize,
},
}
impl std::fmt::Display for CanonicalizeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReparseFailed { errors } => {
let preview: Vec<&str> = errors.iter().take(3).map(String::as_str).collect();
write!(
f,
"canonical formatter failed to re-parse the synthesized \
directive text ({} error(s)): {}",
errors.len(),
preview.join("; ")
)
}
Self::DirectiveCountMismatch { input, reparsed } => write!(
f,
"the canonical formatter could not emit {input} directive(s) \
without loss ({reparsed} survived the round-trip). This is \
an rledger bug; please report it with the input directives.",
),
}
}
}
impl std::error::Error for CanonicalizeError {}
pub fn crlf_to_lf_outside_strings(src: &str) -> std::borrow::Cow<'_, str> {
if !src.contains('\r') {
return std::borrow::Cow::Borrowed(src);
}
let (body, bom) = match src.strip_prefix('\u{FEFF}') {
Some(rest) => (rest, "\u{FEFF}"),
None => (src, ""),
};
let mut out = String::with_capacity(src.len());
out.push_str(bom);
let mut chars = body.chars().peekable();
let mut state = SourceState::Code;
let mut prev_was_backslash = false;
while let Some(ch) = chars.next() {
let peek = chars.peek().copied();
match state {
SourceState::InString => out.push(ch),
_ => {
if ch == '\r' {
out.push('\n');
if peek == Some('\n') {
chars.next();
}
} else {
out.push(ch);
}
}
}
state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
}
std::borrow::Cow::Owned(out)
}
#[must_use]
pub fn cr_outside_strings_present(src: &str) -> bool {
if !src.contains('\r') {
return false;
}
let body = src.strip_prefix('\u{FEFF}').unwrap_or(src);
let mut chars = body.chars().peekable();
let mut state = SourceState::Code;
let mut prev_was_backslash = false;
while let Some(ch) = chars.next() {
let peek = chars.peek().copied();
if matches!(state, SourceState::Code | SourceState::InComment) && ch == '\r' {
return true;
}
state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SourceState {
Code,
InString,
InComment,
}
const fn advance_source_state(
ch: char,
peek: Option<char>,
state: SourceState,
prev_was_backslash: &mut bool,
) -> SourceState {
match state {
SourceState::InString => {
let is_close = ch == '"' && !*prev_was_backslash;
*prev_was_backslash = ch == '\\' && !*prev_was_backslash;
if is_close {
SourceState::Code
} else {
SourceState::InString
}
}
SourceState::InComment => {
if matches!(ch, '\n' | '\r') {
SourceState::Code
} else {
SourceState::InComment
}
}
SourceState::Code => {
let is_hash_line_comment = ch == '#' && matches!(peek, Some('!' | '+'));
if ch == '"' {
*prev_was_backslash = false;
SourceState::InString
} else if matches!(ch, ';' | '%') || is_hash_line_comment {
SourceState::InComment
} else {
SourceState::Code
}
}
}
}
#[must_use]
pub fn format_node(node: &crate::SyntaxNode) -> String {
let source_file =
SourceFile::cast(node.clone()).expect("format_node called on non-SOURCE_FILE node");
let alignment = compute_alignment(&source_file);
format_node_with_alignment(node, alignment)
}
#[must_use]
pub fn format_node_with_alignment(node: &crate::SyntaxNode, alignment: PostingAlignment) -> String {
debug_assert_eq!(
node.kind(),
crate::SyntaxKind::SOURCE_FILE,
"format_node_with_alignment called on non-SOURCE_FILE node (got {:?})",
node.kind(),
);
let mut out = String::new();
let mut prev_was_directive = false;
for el in node.children_with_tokens() {
match el {
rowan::NodeOrToken::Node(n) => {
if let Some(directive) = ast::Directive::cast(n.clone()) {
if prev_was_directive {
for _ in 0..leading_blank_lines(directive.syntax()) {
out.push('\n');
}
}
emit_directive(&directive, alignment, &mut out);
prev_was_directive = true;
} else if n.kind() == crate::SyntaxKind::ERROR_NODE {
if prev_was_directive {
for _ in 0..leading_blank_lines(&n) {
out.push('\n');
}
}
emit_error_node(&n, &mut out);
prev_was_directive = true;
}
}
rowan::NodeOrToken::Token(t) => {
if matches!(
t.kind(),
crate::SyntaxKind::COMMENT
| crate::SyntaxKind::PERCENT_COMMENT
| crate::SyntaxKind::SHEBANG
| crate::SyntaxKind::EMACS_DIRECTIVE
) {
out.push_str(t.text().trim_end_matches(['\n', '\r']));
out.push('\n');
prev_was_directive = false;
}
}
}
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
#[must_use]
pub fn format_node_range(
node: &crate::SyntaxNode,
range: rowan::TextRange,
) -> Option<(rowan::TextRange, String)> {
let source_file =
SourceFile::cast(node.clone()).expect("format_node_range called on non-SOURCE_FILE node");
let alignment = compute_alignment(&source_file);
format_node_range_with_alignment(node, range, alignment)
}
#[must_use]
pub fn format_node_range_with_alignment(
node: &crate::SyntaxNode,
range: rowan::TextRange,
alignment: PostingAlignment,
) -> Option<(rowan::TextRange, String)> {
debug_assert_eq!(
node.kind(),
crate::SyntaxKind::SOURCE_FILE,
"format_node_range_with_alignment called on non-SOURCE_FILE node (got {:?})",
node.kind(),
);
let mut snap_start: Option<rowan::TextSize> = None;
let mut snap_end: Option<rowan::TextSize> = None;
let mut any_included = false;
for el in node.children_with_tokens() {
let (kind, child_range) = (el.kind(), el.text_range());
let is_formattable = match &el {
rowan::NodeOrToken::Node(n) => ast::Directive::cast(n.clone()).is_some(),
rowan::NodeOrToken::Token(_) => matches!(
kind,
crate::SyntaxKind::COMMENT
| crate::SyntaxKind::PERCENT_COMMENT
| crate::SyntaxKind::SHEBANG
| crate::SyntaxKind::EMACS_DIRECTIVE
),
};
if !is_formattable {
continue;
}
if !range_intersects(child_range, range) {
continue;
}
any_included = true;
snap_start = Some(snap_start.map_or(child_range.start(), |s| s.min(child_range.start())));
snap_end = Some(snap_end.map_or(child_range.end(), |e| e.max(child_range.end())));
}
if !any_included {
return None;
}
let snap = rowan::TextRange::new(snap_start.unwrap(), snap_end.unwrap());
for el in node.children_with_tokens() {
if !matches!(el.kind(), crate::SyntaxKind::ERROR_NODE) {
continue;
}
let er = el.text_range();
if er.end() > snap.start() && er.start() < snap.end() {
return None;
}
}
let mut out = String::new();
let mut prev_was_directive = false;
for el in node.children_with_tokens() {
let child_range = el.text_range();
if child_range.end() <= snap.start() || child_range.start() >= snap.end() {
continue;
}
match el {
rowan::NodeOrToken::Node(n) => {
let Some(directive) = ast::Directive::cast(n) else {
continue;
};
let preceded_by_directive = prev_was_directive
|| directive
.syntax()
.prev_sibling()
.and_then(ast::Directive::cast)
.is_some();
if preceded_by_directive {
for _ in 0..leading_blank_lines(directive.syntax()) {
out.push('\n');
}
}
emit_directive(&directive, alignment, &mut out);
prev_was_directive = true;
}
rowan::NodeOrToken::Token(t) => {
if matches!(
t.kind(),
crate::SyntaxKind::COMMENT
| crate::SyntaxKind::PERCENT_COMMENT
| crate::SyntaxKind::SHEBANG
| crate::SyntaxKind::EMACS_DIRECTIVE
) {
out.push_str(t.text().trim_end_matches(['\n', '\r']));
out.push('\n');
prev_was_directive = false;
}
}
}
}
if !out.ends_with('\n') {
out.push('\n');
}
Some((snap, out))
}
fn range_intersects(child: rowan::TextRange, sel: rowan::TextRange) -> bool {
if sel.is_empty() {
child.contains(sel.start()) || sel.start() == child.start()
} else {
child.start() < sel.end() && sel.start() < child.end()
}
}
#[must_use]
pub fn compute_alignment(sf: &SourceFile) -> PostingAlignment {
let mut max_lhs: usize = 0;
let mut max_num: usize = 0;
let mut any_aligned_posting = false;
for directive in sf.directives() {
let ast::Directive::Transaction(t) = directive else {
continue;
};
for child in t.syntax().children() {
let Some(p) = ast::Posting::cast(child) else {
continue;
};
let mut lhs = 0usize;
if let Some(flag) = p.flag() {
lhs += flag.text().chars().count() + 1; }
if let Some(account) = p.account() {
lhs += account.text().chars().count();
}
if let Some(amt) = p.amount()
&& let Some(text) = amount_number_text(&amt)
{
any_aligned_posting = true;
max_lhs = max_lhs.max(lhs);
max_num = max_num.max(text.chars().count());
}
}
}
if !any_aligned_posting {
return PostingAlignment::default();
}
PostingAlignment {
number_col: INDENT.len() + max_lhs + 2,
number_width: max_num,
}
}
fn amount_number_text(amt: &ast::Amount) -> Option<String> {
let text = amount_value_text(amt);
(!text.is_empty()).then_some(text)
}
fn amount_value_text(amt: &ast::Amount) -> String {
let mut buf = String::new();
if amt.is_arithmetic() {
emit_amount_subnode_expression(amt.syntax(), &mut buf);
return buf;
}
if let Some(sign) = amt.sign()
&& sign.is_minus()
{
buf.push('-');
}
if let Some(n) = amt.number() {
buf.push_str(&canonical_number(n.text()));
}
buf
}
fn emit_directive(d: &ast::Directive, align: PostingAlignment, out: &mut String) {
emit_leading_comments(d.syntax(), out);
let trailing = collect_trailing_comment(d.syntax());
let len_before = out.len();
match d {
ast::Directive::Open(d) => emit_open(d, out),
ast::Directive::Close(d) => emit_close(d, out),
ast::Directive::Commodity(d) => emit_commodity(d, out),
ast::Directive::Note(d) => emit_note(d, out),
ast::Directive::Event(d) => emit_event(d, out),
ast::Directive::Query(d) => emit_query(d, out),
ast::Directive::Pad(d) => emit_pad(d, out),
ast::Directive::Document(d) => emit_document(d, out),
ast::Directive::Price(d) => emit_price(d, out),
ast::Directive::Balance(d) => emit_balance(d, out),
ast::Directive::Custom(d) => emit_custom(d, out),
ast::Directive::Option(d) => emit_option(d, out),
ast::Directive::Include(d) => emit_include(d, out),
ast::Directive::Plugin(d) => emit_plugin(d, out),
ast::Directive::Pushtag(d) => emit_pushtag(d, out),
ast::Directive::Poptag(d) => emit_poptag(d, out),
ast::Directive::Pushmeta(d) => emit_pushmeta(d, out),
ast::Directive::Popmeta(d) => emit_popmeta(d, out),
ast::Directive::Transaction(d) => emit_transaction(d, align, out),
}
if let Some(c) = trailing
&& let Some(newline_rel) = out[len_before..].find('\n')
{
let insert_at = len_before + newline_rel;
let mut splice = String::with_capacity(c.len() + 1);
splice.push(' ');
splice.push_str(&c);
out.insert_str(insert_at, &splice);
}
}
fn emit_error_node(node: &crate::SyntaxNode, out: &mut String) {
let text = node.text().to_string();
for line in text.trim_matches(['\n', '\r']).split('\n') {
out.push_str(line.trim_end());
out.push('\n');
}
}
fn leading_blank_lines(node: &crate::SyntaxNode) -> usize {
let mut blanks = 0;
for el in node.children_with_tokens() {
let rowan::NodeOrToken::Token(t) = el else {
break;
};
match t.kind() {
crate::SyntaxKind::NEWLINE => blanks += 1,
crate::SyntaxKind::WHITESPACE => {}
_ => break,
}
}
blanks
}
fn emit_leading_comments(node: &crate::SyntaxNode, out: &mut String) {
for el in node.children_with_tokens() {
let rowan::NodeOrToken::Token(t) = el else {
break;
};
match t.kind() {
crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT => {
out.push_str(t.text().trim_end_matches(['\n', '\r']));
out.push('\n');
}
crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::NEWLINE => {}
_ => break,
}
}
}
fn collect_trailing_comment(node: &crate::SyntaxNode) -> Option<String> {
let mut header_nl_idx: Option<usize> = None;
let mut saw_content = false;
let tokens: Vec<crate::SyntaxToken> = node
.children_with_tokens()
.filter_map(rowan::NodeOrToken::into_token)
.collect();
for (i, t) in tokens.iter().enumerate() {
let k = t.kind();
if k == crate::SyntaxKind::NEWLINE && saw_content {
header_nl_idx = Some(i);
break;
}
if !matches!(
k,
crate::SyntaxKind::WHITESPACE
| crate::SyntaxKind::NEWLINE
| crate::SyntaxKind::COMMENT
| crate::SyntaxKind::PERCENT_COMMENT
) {
saw_content = true;
}
}
let nl_idx = header_nl_idx.unwrap_or(tokens.len());
for i in (0..nl_idx).rev() {
let k = tokens[i].kind();
if matches!(
k,
crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
) {
return Some(tokens[i].text().to_string());
}
if k != crate::SyntaxKind::WHITESPACE {
return None;
}
}
None
}
fn emit_open(d: &ast::OpenDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let account = d
.account()
.map(|t| t.text().to_string())
.unwrap_or_default();
out.push_str(&date);
out.push_str(" open ");
out.push_str(&account);
for currency in d.currencies() {
out.push(' ');
out.push_str(currency.text());
}
if let Some(booking) = d.booking_method() {
out.push(' ');
out.push_str(booking.text());
}
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_close(d: &ast::CloseDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let account = d
.account()
.map(|t| t.text().to_string())
.unwrap_or_default();
out.push_str(&date);
out.push_str(" close ");
out.push_str(&account);
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_commodity(d: &ast::CommodityDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let currency = d
.currency()
.map(|t| t.text().to_string())
.unwrap_or_default();
out.push_str(&date);
out.push_str(" commodity ");
out.push_str(¤cy);
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_note(d: &ast::NoteDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let account = d
.account()
.map(|t| t.text().to_string())
.unwrap_or_default();
let text = d.text().map(|s| s.text().to_string()).unwrap_or_default();
out.push_str(&date);
out.push_str(" note ");
out.push_str(&account);
out.push(' ');
out.push_str(&text);
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_event(d: &ast::EventDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let event_type = d
.event_type()
.map(|s| s.text().to_string())
.unwrap_or_default();
let value = d.value().map(|s| s.text().to_string()).unwrap_or_default();
out.push_str(&date);
out.push_str(" event ");
out.push_str(&event_type);
out.push(' ');
out.push_str(&value);
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_query(d: &ast::QueryDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let name = d.name().map(|s| s.text().to_string()).unwrap_or_default();
let query = d.query().map(|s| s.text().to_string()).unwrap_or_default();
out.push_str(&date);
out.push_str(" query ");
out.push_str(&name);
out.push(' ');
out.push_str(&query);
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_pad(d: &ast::PadDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let target = d
.target_account()
.map(|t| t.text().to_string())
.unwrap_or_default();
let source = d
.source_account()
.map(|t| t.text().to_string())
.unwrap_or_default();
out.push_str(&date);
out.push_str(" pad ");
out.push_str(&target);
out.push(' ');
out.push_str(&source);
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_document(d: &ast::DocumentDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let account = d
.account()
.map(|t| t.text().to_string())
.unwrap_or_default();
let path = d.path().map(|s| s.text().to_string()).unwrap_or_default();
out.push_str(&date);
out.push_str(" document ");
out.push_str(&account);
out.push(' ');
out.push_str(&path);
let mut seen_content = false;
for el in d.syntax().children_with_tokens() {
let rowan::NodeOrToken::Token(t) = el else {
break;
};
match t.kind() {
crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
out.push(' ');
out.push_str(t.text());
seen_content = true;
}
crate::SyntaxKind::NEWLINE if seen_content => break,
k if k.is_trivia() => {}
_ => seen_content = true,
}
}
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_price(d: &ast::PriceDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let base = d
.base_currency()
.map(|t| t.text().to_string())
.unwrap_or_default();
let quote = d
.quote_currency()
.map(|t| t.text().to_string())
.unwrap_or_default();
out.push_str(&date);
out.push_str(" price ");
out.push_str(&base);
out.push(' ');
emit_amount_expression(d.syntax(), out);
out.push(' ');
out.push_str("e);
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_balance(d: &ast::BalanceDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let account = d
.account()
.map(|t| t.text().to_string())
.unwrap_or_default();
let currency = d
.currency()
.map(|t| t.text().to_string())
.unwrap_or_default();
out.push_str(&date);
out.push_str(" balance ");
out.push_str(&account);
out.push(' ');
emit_amount_expression(d.syntax(), out);
out.push(' ');
out.push_str(¤cy);
if let Some((tolerance, tol_currency)) = balance_tolerance(d.syntax()) {
out.push_str(" ~ ");
out.push_str(&tolerance);
if let Some(c) = tol_currency {
out.push(' ');
out.push_str(&c);
}
}
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_custom(d: &ast::CustomDirective, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
let custom_type = d
.custom_type()
.map(|s| s.text().to_string())
.unwrap_or_default();
out.push_str(&date);
out.push_str(" custom ");
out.push_str(&custom_type);
let tokens: Vec<crate::SyntaxToken> = d
.syntax()
.children_with_tokens()
.filter_map(rowan::NodeOrToken::into_token)
.filter(|t| !is_trivia_kind(t.kind()))
.collect();
let mut seen_type = false;
let mut i = 0;
while i < tokens.len() {
let t = &tokens[i];
if !seen_type {
if t.kind() == crate::SyntaxKind::STRING {
seen_type = true;
}
i += 1;
continue;
}
out.push(' ');
if t.kind() == crate::SyntaxKind::NUMBER {
out.push_str(&canonical_number(t.text()));
if matches!(
tokens.get(i + 1).map(rowan::SyntaxToken::kind),
Some(crate::SyntaxKind::CURRENCY)
) {
out.push(' ');
out.push_str(tokens[i + 1].text());
i += 2;
continue;
}
} else {
out.push_str(t.text());
}
i += 1;
}
out.push('\n');
emit_meta_entries_of(d.syntax(), out);
}
fn emit_option(d: &ast::OptionDirective, out: &mut String) {
let key = d.key().map(|s| s.text().to_string()).unwrap_or_default();
let value = d.value().map(|s| s.text().to_string()).unwrap_or_default();
out.push_str("option ");
out.push_str(&key);
out.push(' ');
out.push_str(&value);
out.push('\n');
}
fn emit_include(d: &ast::IncludeDirective, out: &mut String) {
let path = d.path().map(|s| s.text().to_string()).unwrap_or_default();
out.push_str("include ");
out.push_str(&path);
out.push('\n');
}
fn emit_plugin(d: &ast::PluginDirective, out: &mut String) {
let module = d.module().map(|s| s.text().to_string()).unwrap_or_default();
out.push_str("plugin ");
out.push_str(&module);
if let Some(config) = d.config() {
out.push(' ');
out.push_str(config.text());
}
out.push('\n');
}
fn emit_pushtag(d: &ast::PushtagDirective, out: &mut String) {
let tag = d.tag().map(|t| t.text().to_string()).unwrap_or_default();
out.push_str("pushtag ");
out.push_str(&tag);
out.push('\n');
}
fn emit_poptag(d: &ast::PoptagDirective, out: &mut String) {
let tag = d.tag().map(|t| t.text().to_string()).unwrap_or_default();
out.push_str("poptag ");
out.push_str(&tag);
out.push('\n');
}
fn emit_pushmeta(d: &ast::PushmetaDirective, out: &mut String) {
let key = d.key().map(|t| t.text().to_string()).unwrap_or_default();
out.push_str("pushmeta ");
out.push_str(&key);
let mut past_key = false;
for el in d.syntax().children_with_tokens() {
let rowan::NodeOrToken::Token(t) = el else {
continue;
};
if !past_key {
if t.kind() == crate::SyntaxKind::META_KEY {
past_key = true;
}
continue;
}
if is_trivia_kind(t.kind()) {
continue;
}
out.push(' ');
if t.kind() == crate::SyntaxKind::NUMBER {
out.push_str(&canonical_number(t.text()));
} else {
out.push_str(t.text());
}
}
out.push('\n');
}
fn emit_popmeta(d: &ast::PopmetaDirective, out: &mut String) {
let key = d.key().map(|t| t.text().to_string()).unwrap_or_default();
out.push_str("popmeta ");
out.push_str(&key);
out.push('\n');
}
fn emit_transaction(d: &ast::Transaction, align: PostingAlignment, out: &mut String) {
let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
out.push_str(&date);
out.push(' ');
out.push_str(&transaction_flag_string(d));
if let Some(payee) = d.payee() {
out.push(' ');
out.push_str(payee.text());
}
if let Some(narration) = d.narration() {
out.push(' ');
out.push_str(narration.text());
}
let mut seen_content = false;
for el in d.syntax().children_with_tokens() {
let rowan::NodeOrToken::Token(t) = el else {
break;
};
match t.kind() {
crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
out.push(' ');
out.push_str(t.text());
seen_content = true;
}
crate::SyntaxKind::NEWLINE if seen_content => break,
k if k.is_trivia() => {}
_ => seen_content = true,
}
}
out.push('\n');
let mut past_header = false;
let mut seen_content = false;
for el in d.syntax().children_with_tokens() {
match el {
rowan::NodeOrToken::Node(n) => {
past_header = true;
if let Some(p) = ast::Posting::cast(n.clone()) {
emit_posting(&p, align, out);
} else if let Some(m) = ast::MetaEntry::cast(n) {
emit_meta_entry(&m, INDENT, out);
}
}
rowan::NodeOrToken::Token(t) => {
if !past_header {
match t.kind() {
crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
k if k.is_trivia() => {}
_ => seen_content = true,
}
continue;
}
match t.kind() {
crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT => {
out.push_str(INDENT);
out.push_str(t.text().trim_end_matches(['\n', '\r']));
out.push('\n');
}
crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
out.push_str(INDENT);
out.push_str(t.text());
out.push('\n');
}
_ => {}
}
}
}
}
}
fn transaction_flag_string(d: &ast::Transaction) -> String {
use crate::cst::ast::TransactionFlagKind;
match d.flag() {
None => "*".to_string(),
Some(f) => match f.classify() {
TransactionFlagKind::Star | TransactionFlagKind::Txn => "*".to_string(),
TransactionFlagKind::Pending => "!".to_string(),
TransactionFlagKind::Hash => "#".to_string(),
TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
f.text().to_string()
}
},
}
}
fn emit_posting(p: &ast::Posting, align: PostingAlignment, out: &mut String) {
let trailing = collect_trailing_comment(p.syntax());
let posting_start = out.len();
out.push_str(INDENT);
let mut col = INDENT.len();
if let Some(flag) = p.flag() {
out.push_str(flag.text());
out.push(' ');
col += flag.text().chars().count() + 1;
}
let account_text = p
.account()
.map(|a| a.text().to_string())
.unwrap_or_default();
out.push_str(&account_text);
col += account_text.chars().count();
if let Some(amt) = p.amount() {
if let Some(value) = amount_number_text(&amt) {
let field_pad = align.number_col.saturating_sub(col).max(2);
let justify_pad = align.number_width.saturating_sub(value.chars().count());
for _ in 0..(field_pad + justify_pad) {
out.push(' ');
}
out.push_str(&value);
if let Some(c) = amt.currency() {
out.push(' ');
out.push_str(c.text());
}
if let Some(cs) = p.cost_spec() {
out.push(' ');
out.push_str(&format_cost_spec(&cs));
}
if let Some(pa) = p.price_annotation() {
out.push(' ');
out.push_str(&format_price_annotation(&pa));
}
}
}
out.push('\n');
if let Some(c) = trailing
&& let Some(rel) = out[posting_start..].find('\n')
{
let mut splice = String::with_capacity(c.len() + 1);
splice.push(' ');
splice.push_str(&c);
out.insert_str(posting_start + rel, &splice);
}
let mut past_header = false;
let mut seen_content = false;
for el in p.syntax().children_with_tokens() {
match el {
rowan::NodeOrToken::Node(n) => {
if let Some(m) = ast::MetaEntry::cast(n) {
emit_meta_entry(&m, " ", out);
}
}
rowan::NodeOrToken::Token(t) => {
if !past_header {
match t.kind() {
crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
k if k.is_trivia() => {}
_ => seen_content = true,
}
continue;
}
if matches!(
t.kind(),
crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
) {
out.push_str(" ");
out.push_str(t.text().trim_end_matches(['\n', '\r']));
out.push('\n');
}
}
}
}
}
fn format_amount(amt: &ast::Amount) -> String {
let mut out = String::new();
if amt.is_arithmetic() {
emit_amount_subnode_expression(amt.syntax(), &mut out);
if let Some(c) = amt.currency() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(c.text());
}
return out;
}
if let Some(sign) = amt.sign()
&& sign.is_minus()
{
out.push('-');
}
if let Some(n) = amt.number() {
out.push_str(&canonical_number(n.text()));
}
if let Some(c) = amt.currency() {
if !out.is_empty() && !out.ends_with('-') {
out.push(' ');
}
out.push_str(c.text());
}
out
}
fn format_cost_spec(cs: &ast::CostSpec) -> String {
let (open, close) = if cs.is_total() {
("{{", "}}")
} else if cs.is_per_unit_plus_total() {
("{#", "}")
} else {
("{", "}")
};
let inner_tokens: Vec<crate::SyntaxToken> = cs
.syntax()
.children_with_tokens()
.filter_map(rowan::NodeOrToken::into_token)
.filter(|t| {
!matches!(
t.kind(),
crate::SyntaxKind::L_BRACE
| crate::SyntaxKind::R_BRACE
| crate::SyntaxKind::L_DOUBLE_BRACE
| crate::SyntaxKind::R_DOUBLE_BRACE
| crate::SyntaxKind::L_BRACE_HASH
| crate::SyntaxKind::WHITESPACE
| crate::SyntaxKind::NEWLINE
)
})
.collect();
let mut inner = String::new();
write_canonical_token_sequence(&inner_tokens, &mut inner);
if cs.is_per_unit_plus_total() && !inner.is_empty() {
format!("{open} {inner}{close}")
} else {
format!("{open}{inner}{close}")
}
}
fn format_price_annotation(pa: &ast::PriceAnnotation) -> String {
let op = if pa.is_total() { "@@" } else { "@" };
match pa.amount() {
Some(a) => format!("{op} {}", format_amount(&a)),
None => op.to_string(),
}
}
const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
matches!(
kind,
crate::SyntaxKind::WHITESPACE
| crate::SyntaxKind::NEWLINE
| crate::SyntaxKind::COMMENT
| crate::SyntaxKind::PERCENT_COMMENT
| crate::SyntaxKind::SHEBANG
| crate::SyntaxKind::EMACS_DIRECTIVE
| crate::SyntaxKind::BOM
)
}
fn canonical_number(text: &str) -> String {
if text.contains(',') {
text.replace(',', "")
} else {
text.to_string()
}
}
fn emit_amount_expression(node: &crate::SyntaxNode, out: &mut String) {
let raw: Vec<crate::SyntaxToken> = node
.children_with_tokens()
.filter_map(rowan::NodeOrToken::into_token)
.filter(|t| !is_trivia_kind(t.kind()))
.skip_while(|t| {
!matches!(
t.kind(),
crate::SyntaxKind::NUMBER
| crate::SyntaxKind::PLUS
| crate::SyntaxKind::MINUS
| crate::SyntaxKind::L_PAREN
)
})
.collect();
let mut depth: i32 = 0;
let mut first_currency_idx: Option<usize> = None;
for (i, t) in raw.iter().enumerate() {
match t.kind() {
crate::SyntaxKind::L_PAREN => depth += 1,
crate::SyntaxKind::R_PAREN => depth -= 1,
crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
first_currency_idx = Some(i);
}
_ => {}
}
}
let end = first_currency_idx.unwrap_or(raw.len());
write_canonical_token_sequence(&raw[..end], out);
}
fn emit_amount_subnode_expression(node: &crate::SyntaxNode, out: &mut String) {
let mut tokens: Vec<crate::SyntaxToken> = node
.children_with_tokens()
.filter_map(rowan::NodeOrToken::into_token)
.filter(|t| !is_trivia_kind(t.kind()))
.collect();
if let Some(last) = tokens.last()
&& last.kind() == crate::SyntaxKind::CURRENCY
{
tokens.pop();
}
write_canonical_token_sequence(&tokens, out);
}
fn write_canonical_token_sequence(tokens: &[crate::SyntaxToken], out: &mut String) {
let is_op = |k: crate::SyntaxKind| {
matches!(
k,
crate::SyntaxKind::PLUS
| crate::SyntaxKind::MINUS
| crate::SyntaxKind::STAR
| crate::SyntaxKind::SLASH
)
};
let mut prev_kind: Option<crate::SyntaxKind> = None;
let mut prev_was_unary = false;
for t in tokens {
let kind = t.kind();
let is_unary = is_op(kind)
&& match prev_kind {
None => true,
Some(p) => p == crate::SyntaxKind::L_PAREN || is_op(p),
};
let need_space = match prev_kind {
None => false,
Some(prev) => {
prev != crate::SyntaxKind::L_PAREN
&& kind != crate::SyntaxKind::R_PAREN
&& kind != crate::SyntaxKind::COMMA
&& !prev_was_unary
}
};
if need_space {
out.push(' ');
}
if kind == crate::SyntaxKind::NUMBER {
out.push_str(&canonical_number(t.text()));
} else {
out.push_str(t.text());
}
prev_kind = Some(kind);
prev_was_unary = is_unary;
}
}
fn balance_tolerance(node: &crate::SyntaxNode) -> Option<(String, Option<String>)> {
let mut past_tilde = false;
let mut number: Option<String> = None;
let mut currency: Option<String> = None;
for el in node.children_with_tokens() {
let rowan::NodeOrToken::Token(t) = el else {
continue;
};
if !past_tilde {
if t.kind() == crate::SyntaxKind::TILDE {
past_tilde = true;
}
continue;
}
match t.kind() {
crate::SyntaxKind::NUMBER if number.is_none() => {
number = Some(canonical_number(t.text()));
}
crate::SyntaxKind::CURRENCY if number.is_some() && currency.is_none() => {
currency = Some(t.text().to_string());
}
_ => {}
}
}
number.map(|n| (n, currency))
}
fn emit_meta_entries_of(node: &crate::SyntaxNode, out: &mut String) {
let mut past_header = false;
let mut seen_content = false;
for el in node.children_with_tokens() {
match el {
rowan::NodeOrToken::Node(n) => {
past_header = true;
if let Some(entry) = MetaEntry::cast(n) {
emit_meta_entry(&entry, INDENT, out);
}
}
rowan::NodeOrToken::Token(t) => {
if !past_header {
match t.kind() {
crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
k if k.is_trivia() => {}
_ => seen_content = true,
}
continue;
}
if matches!(
t.kind(),
crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
) {
out.push_str(INDENT);
out.push_str(t.text().trim_end_matches(['\n', '\r']));
out.push('\n');
}
}
}
}
}
fn emit_meta_entry(m: &MetaEntry, indent: &str, out: &mut String) {
out.push_str(indent);
let content: Vec<crate::SyntaxToken> = m
.syntax()
.children_with_tokens()
.filter_map(rowan::NodeOrToken::into_token)
.filter(|t| {
!matches!(
t.kind(),
crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::NEWLINE
)
})
.collect();
let mut iter = content.iter();
if let Some(key) = iter.next() {
out.push_str(key.text());
}
let value_tokens: Vec<crate::SyntaxToken> = iter.cloned().collect();
if !value_tokens.is_empty() {
out.push(' ');
write_canonical_token_sequence(&value_tokens, out);
}
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_yields_single_newline() {
assert_eq!(format_source(""), "\n");
}
#[test]
fn open_directive_canonical() {
let src = "2024-01-15 open Assets:Cash\n";
assert_eq!(format_source(src), "2024-01-15 open Assets:Cash\n");
}
#[test]
fn open_with_currencies_and_booking_canonical() {
let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
assert_eq!(
format_source(src),
"2024-01-15 open Assets:Brokerage USD EUR \"STRICT\"\n"
);
}
#[test]
fn close_directive_canonical() {
let src = "2024-12-31 close Assets:Cash\n";
assert_eq!(format_source(src), "2024-12-31 close Assets:Cash\n");
}
#[test]
fn commodity_directive_canonical() {
let src = "2024-01-01 commodity HOOL\n";
assert_eq!(format_source(src), "2024-01-01 commodity HOOL\n");
}
#[test]
fn blank_lines_between_directives_preserved() {
let grouped = "2024-01-01 open Assets:A\n2024-01-02 open Assets:B\n";
assert_eq!(format_source(grouped), grouped);
let one = "2024-01-01 open Assets:A\n\n2024-01-02 open Assets:B\n";
assert_eq!(format_source(one), one);
let two = "2024-01-01 open Assets:A\n\n\n2024-01-02 open Assets:B\n";
assert_eq!(format_source(two), two);
let ws_blank = "2024-01-01 open Assets:A\n \n2024-01-02 open Assets:B\n";
assert_eq!(
format_source(ws_blank),
"2024-01-01 open Assets:A\n\n2024-01-02 open Assets:B\n"
);
}
#[test]
fn trailing_newline_always_present() {
let src = "2024-01-01 open Assets:A";
let formatted = format_source(src);
assert!(formatted.ends_with('\n'));
assert!(!formatted.ends_with("\n\n"));
}
#[test]
fn idempotent_on_canonical_input() {
let src = "2024-01-01 open Assets:A\n\n2024-01-02 close Assets:A\n";
let once = format_source(src);
let twice = format_source(&once);
assert_eq!(once, twice);
}
#[test]
fn note_canonical() {
let src = "2024-01-15 note Assets:Cash \"a note\"\n";
assert_eq!(
format_source(src),
"2024-01-15 note Assets:Cash \"a note\"\n"
);
}
#[test]
fn event_canonical() {
let src = "2024-01-15 event \"location\" \"NYC\"\n";
assert_eq!(
format_source(src),
"2024-01-15 event \"location\" \"NYC\"\n"
);
}
#[test]
fn query_canonical() {
let src = "2024-01-15 query \"q1\" \"SELECT account\"\n";
assert_eq!(
format_source(src),
"2024-01-15 query \"q1\" \"SELECT account\"\n"
);
}
#[test]
fn pad_canonical() {
let src = "2024-01-15 pad Assets:A Equity:Opening\n";
assert_eq!(
format_source(src),
"2024-01-15 pad Assets:A Equity:Opening\n"
);
}
#[test]
fn document_with_tags_and_links_canonical() {
let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #q1 ^scan42 #urgent\n";
assert_eq!(
format_source(src),
"2024-06-01 document Assets:Bank \"stmt.pdf\" #q1 ^scan42 #urgent\n"
);
}
#[test]
fn issue_1321_document_tags_links_idempotent_across_directives() {
let src = "\
2013-05-18 document Assets:Bank \"/a.pdf\" #tag1 ^link1
2013-05-19 document Assets:Bank \"/b.pdf\" #tag2 ^link2
";
let once = format_source(src);
assert_eq!(format_source(&once), once, "format must be idempotent");
assert!(
once.contains("#tag2") && once.contains("^link2"),
"the second document's tags/links must survive formatting; got:\n{once}"
);
}
#[test]
fn issue_1321_header_tags_links_idempotent_across_transactions() {
let src = "\
2024-01-15 * \"x\" #tag1 ^link1 #tag2 ^link2
Assets:Cash -1.00 USD
Expenses:Misc 1.00 USD
2024-01-16 * \"x\" #tag1 ^link1 #tag2 ^link2
Assets:Cash -1.00 USD
Expenses:Misc 1.00 USD
";
assert_eq!(
format_source(src),
src,
"format must be a no-op (idempotent)"
);
}
#[test]
fn issue_1321_comment_before_transaction_keeps_header_tags() {
let src = "\
2024-01-15 * \"first\" #h1 ^l1
Assets:Cash -1.00 USD
Expenses:Misc 1.00 USD
; a comment before the second transaction
2024-01-16 * \"second\" #tag1 ^link1
Assets:Cash -2.00 USD
Expenses:Misc 2.00 USD
";
assert_eq!(
format_source(src),
src,
"a leading comment must not migrate header tags/links to continuation lines"
);
}
#[test]
fn issue_1321_comment_before_document_keeps_tags() {
let src = "\
2013-05-18 document Assets:Bank \"/a.pdf\" #tag1 ^link1
; a comment before the second document
2013-05-19 document Assets:Bank \"/b.pdf\" #tag2 ^link2
";
let once = format_source(src);
assert_eq!(format_source(&once), once, "format must be idempotent");
assert!(
once.contains("\"/b.pdf\" #tag2 ^link2"),
"the second document's tags/links must stay on its header line; got:\n{once}"
);
}
#[test]
fn issue_1332_body_comments_in_metadata_preserved() {
let src = "\
2023-06-04 commodity EAM-VEUR ; cSpell: word VEUR
name: \"Vanguard FTSE Developed Europe UCITS ETF EUR Dist\"
; price: \"EUR:alphavantage/price:VEUR.AS:EUR\"
; price: \"EUR:yahoo/VEUR.AS\"
price: \"EUR:pricehist.beanprice.yahoo/VEUR.AS\"
";
assert_eq!(
format_source(src),
src,
"body comments must be preserved verbatim"
);
assert_eq!(format_source(&format_source(src)), format_source(src));
}
#[test]
fn issue_1332_body_comments_between_postings_preserved() {
let src = "\
2024-01-15 * \"Cafe\" \"Latte\"
Expenses:Coffee 4.50 USD
; was 5.00 before the discount
Assets:Checking
";
let out = format_source(src);
assert!(
out.contains("\n ; was 5.00 before the discount\n"),
"the body comment must be preserved on its own indented line; got:\n{out}"
);
let coffee = out.find("Expenses:Coffee").unwrap();
let comment = out.find("; was 5.00").unwrap();
let checking = out.find("Assets:Checking").unwrap();
assert!(
coffee < comment && comment < checking,
"comment must stay between postings:\n{out}"
);
assert_eq!(format_source(&out), out, "format must be idempotent");
}
#[test]
fn issue_1335_org_headers_and_grouped_comments_preserved() {
let src = "\
* Section A
;; comment between headers
;; second line
* Section B
2013-01-01 open Assets:X
";
let out = format_source(src);
for needle in [
"* Section A",
";; comment between headers",
";; second line",
"* Section B",
"2013-01-01 open Assets:X",
] {
assert!(
out.contains(needle),
"lost {needle:?} on format; got:\n{out}"
);
}
assert_eq!(format_source(&out), out, "format must be idempotent");
}
#[test]
fn issue_1335_org_header_then_directive_keeps_header() {
let src = "* Accounts\n2013-01-01 open Assets:X\n";
let out = format_source(src);
assert!(
out.contains("* Accounts"),
"org header dropped; got:\n{out}"
);
assert_eq!(format_source(&out), out);
}
#[test]
fn issue_1335_blank_lines_around_org_header_preserved() {
let src = "* Accounts\n\n2013-01-01 open Assets:X\n";
assert_eq!(
format_source(src),
src,
"blank around org header must be kept"
);
assert_eq!(format_source(&format_source(src)), format_source(src));
}
#[test]
fn issue_1337_posting_internal_comments_preserved() {
let src = "\
2024-01-15 * \"x\"
Assets:A 1.00 USD
; posting-internal note
Assets:B
";
let out = format_source(src);
assert!(
out.contains("; posting-internal note"),
"posting-internal comment dropped; got:\n{out}"
);
let a = out.find("Assets:A").unwrap();
let c = out.find("; posting-internal note").unwrap();
let b = out.find("Assets:B").unwrap();
assert!(a < c && c < b, "comment must stay between postings:\n{out}");
assert_eq!(format_source(&out), out, "format must be idempotent");
}
#[test]
fn price_canonical_strips_thousands_separators() {
let src = "2024-01-15 price USD 1,234.56 EUR\n";
assert_eq!(format_source(src), "2024-01-15 price USD 1234.56 EUR\n");
}
#[test]
fn price_arithmetic_canonicalizes_spacing() {
let src = "2024-01-15 price USD 1/2 EUR\n";
assert_eq!(format_source(src), "2024-01-15 price USD 1 / 2 EUR\n");
}
#[test]
fn balance_canonical() {
let src = "2024-01-15 balance Assets:Cash 100.00 USD\n";
assert_eq!(
format_source(src),
"2024-01-15 balance Assets:Cash 100.00 USD\n"
);
}
#[test]
fn balance_with_tolerance_canonical() {
let src = "2024-01-15 balance Assets:Cash 100.00 USD ~ 0.01 USD\n";
assert_eq!(
format_source(src),
"2024-01-15 balance Assets:Cash 100.00 USD ~ 0.01 USD\n"
);
}
#[test]
fn balance_arithmetic_canonical() {
let src = "2024-01-15 balance Assets:Cash 0.25 + 0.75 USD\n";
assert_eq!(
format_source(src),
"2024-01-15 balance Assets:Cash 0.25 + 0.75 USD\n"
);
}
#[test]
fn custom_canonical() {
let src = "2024-01-01 custom \"budget\" Expenses:Food 500.00 USD\n";
assert_eq!(
format_source(src),
"2024-01-01 custom \"budget\" Expenses:Food 500.00 USD\n"
);
}
#[test]
fn option_canonical() {
let src = "option \"title\" \"My Ledger\"\n";
assert_eq!(format_source(src), "option \"title\" \"My Ledger\"\n");
}
#[test]
fn include_canonical() {
let src = "include \"other.beancount\"\n";
assert_eq!(format_source(src), "include \"other.beancount\"\n");
}
#[test]
fn plugin_canonical_with_config() {
let src = "plugin \"beancount.plugins.unrealized\" \"Unrealized\"\n";
assert_eq!(
format_source(src),
"plugin \"beancount.plugins.unrealized\" \"Unrealized\"\n"
);
}
#[test]
fn plugin_canonical_without_config() {
let src = "plugin \"my.plugin\"\n";
assert_eq!(format_source(src), "plugin \"my.plugin\"\n");
}
#[test]
fn pushtag_poptag_canonical() {
let src = "pushtag #active\npoptag #active\n";
assert_eq!(format_source(src), "pushtag #active\npoptag #active\n");
}
#[test]
fn pushmeta_popmeta_canonical() {
let src = "pushmeta location: \"NYC\"\npopmeta location:\n";
assert_eq!(
format_source(src),
"pushmeta location: \"NYC\"\npopmeta location:\n"
);
}
#[test]
fn transaction_minimal_two_postings_aligns_amounts() {
let src = "\
2024-01-15 * \"Coffee\"
Assets:Cash -5.00 USD
Expenses:Coffee 5.00 USD
";
let expected = "\
2024-01-15 * \"Coffee\"
Assets:Cash -5.00 USD
Expenses:Coffee 5.00 USD
";
assert_eq!(format_source(src), expected);
}
#[test]
fn transaction_elided_posting_does_not_widen_amount_column() {
let src = "\
2024-01-15 * \"Coffee\"
Assets:Cash -5.00 USD
Expenses:Food
";
let expected = "\
2024-01-15 * \"Coffee\"
Assets:Cash -5.00 USD
Expenses:Food
";
assert_eq!(format_source(src), expected);
assert_eq!(format_source(expected), expected);
}
#[test]
fn transaction_long_elided_account_matches_bean_format() {
let src = "\
2024-07-20 * \"Commas should stay\"
Assets:Money -1,024 USD
Expenses:Thingamabobs
";
let expected = "\
2024-07-20 * \"Commas should stay\"
Assets:Money -1024 USD
Expenses:Thingamabobs
";
assert_eq!(format_source(src), expected);
assert_eq!(format_source(expected), expected);
}
#[test]
fn transaction_currency_only_posting_does_not_widen_amount_column() {
let out = format_source(
"2024-01-15 * \"x\"\n Assets:Bank -5.00 USD\n Assets:LongCashReserve USD\n",
);
assert!(
out.contains(" Assets:Bank -5.00 USD"),
"number column must align to the numbered posting, not the longer \
currency-only one; got:\n{out}"
);
}
#[test]
fn transaction_payee_and_narration() {
let src =
"2024-01-15 * \"Starbucks\" \"Coffee\"\n Assets:Cash -5.00 USD\n Expenses:Coffee\n";
let out = format_source(src);
assert!(
out.contains("2024-01-15 * \"Starbucks\" \"Coffee\"\n"),
"got: {out}"
);
}
#[test]
fn transaction_pending_flag() {
let src = "2024-01-15 ! \"Pending\"\n Assets:Cash -5.00 USD\n Expenses:Misc\n";
let out = format_source(src);
assert!(out.starts_with("2024-01-15 ! \"Pending\"\n"), "got: {out}");
}
#[test]
fn transaction_txn_keyword_normalized_to_star() {
let src = "2024-01-15 txn \"x\"\n Assets:Cash -1.00 USD\n Expenses:Misc\n";
let out = format_source(src);
assert!(out.starts_with("2024-01-15 * \"x\"\n"), "got: {out}");
}
#[test]
fn transaction_header_tags_and_links() {
let src =
"2024-01-15 * \"x\" #tag1 ^link1 #tag2\n Assets:Cash -1.00 USD\n Expenses:Misc\n";
let out = format_source(src);
assert!(
out.starts_with("2024-01-15 * \"x\" #tag1 ^link1 #tag2\n"),
"got: {out}"
);
}
#[test]
fn transaction_auto_balance_posting_no_amount() {
let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n Expenses:Misc\n";
let out = format_source(src);
assert!(out.contains("\n Expenses:Misc\n"), "got: {out}");
}
#[test]
fn transaction_posting_with_cost_spec() {
let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL {500.00 USD}\n Assets:Cash -5000.00 USD\n";
let out = format_source(src);
assert!(out.contains("10 HOOL {500.00 USD}"), "got: {out}");
}
#[test]
fn transaction_posting_with_total_cost_spec() {
let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL {{5000.00 USD}}\n Assets:Cash -5000.00 USD\n";
let out = format_source(src);
assert!(out.contains("10 HOOL {{5000.00 USD}}"), "got: {out}");
}
#[test]
fn transaction_posting_with_per_unit_price() {
let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL @ 500.00 USD\n Assets:Cash -5000.00 USD\n";
let out = format_source(src);
assert!(out.contains("10 HOOL @ 500.00 USD"), "got: {out}");
}
#[test]
fn transaction_posting_with_total_price() {
let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL @@ 5000.00 USD\n Assets:Cash -5000.00 USD\n";
let out = format_source(src);
assert!(out.contains("10 HOOL @@ 5000.00 USD"), "got: {out}");
}
#[test]
fn transaction_posting_with_flag() {
let src = "2024-01-15 * \"x\"\n ! Assets:Cash -5.00 USD\n Expenses:Misc 5.00 USD\n";
let out = format_source(src);
assert!(out.contains("\n ! Assets:Cash"), "got: {out}");
}
#[test]
fn transaction_negative_amount() {
let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n Expenses:Misc 5.00 USD\n";
let out = format_source(src);
assert!(out.contains("-5.00 USD"), "got: {out}");
assert!(out.contains(" 5.00 USD"), "got: {out}");
}
#[test]
fn transaction_strips_thousands_separators_in_postings() {
let src = "2024-01-15 * \"x\"\n Assets:Cash -1,000.00 USD\n Expenses:Misc 1,000.00 USD\n";
let out = format_source(src);
assert!(out.contains("-1000.00 USD"), "got: {out}");
assert!(!out.contains("1,000"), "got: {out}");
}
#[test]
fn transaction_arithmetic_amount() {
let src =
"2024-01-15 * \"x\"\n Assets:Cash -(1.00 + 2.00) USD\n Expenses:Misc 3.00 USD\n";
let out = format_source(src);
assert!(
out.contains("(1.00 + 2.00) USD") || out.contains("-(1.00 + 2.00) USD"),
"got: {out}"
);
}
#[test]
fn transaction_idempotent() {
let src = "\
2024-01-15 * \"Coffee\"
Assets:Cash -5.00 USD
Expenses:Coffee 5.00 USD
";
let once = format_source(src);
let twice = format_source(&once);
assert_eq!(once, twice);
}
#[test]
fn transaction_file_wide_alignment_across_transactions() {
let src = "\
2024-01-15 * \"x\"
Assets:Cash -5.00 USD
Expenses:Misc 5.00 USD
2024-01-16 * \"y\"
Liabilities:CreditCard:Visa -100.00 USD
Expenses:Big 100.00 USD
";
let out = format_source(src);
let usd_cols: Vec<usize> = out
.lines()
.filter(|l| l.starts_with(" ") && l.contains(" USD"))
.filter_map(|l| l.find("USD"))
.collect();
assert!(
usd_cols.len() >= 4,
"expected ≥4 posting lines, got {usd_cols:?} in {out}"
);
let first = usd_cols[0];
assert!(
usd_cols.iter().all(|&c| c == first),
"expected USD column uniform at {first}, got {usd_cols:?} in:\n{out}"
);
}
#[test]
fn transaction_posting_metadata_indented_four() {
let src =
"2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n foo: \"bar\"\n Expenses:Misc\n";
let out = format_source(src);
assert!(out.contains("\n foo: \"bar\"\n"), "got: {out}");
}
#[test]
fn cost_spec_per_unit_plus_total_opener_preserved() {
let src = "2024-01-01 * \"buy\"\n Assets:Brokerage 10 HOOL {# 500.00 USD}\n Assets:Cash -5000.00 USD\n";
let out = format_source(src);
assert!(
out.contains("{# 500.00 USD}"),
"expected `{{#` opener preserved; got:\n{out}"
);
assert!(!out.contains("{500.00 USD}"), "got:\n{out}");
}
#[test]
fn cost_spec_comma_stays_tight_to_prev_token() {
let src = "2024-01-01 * \"buy\"\n Assets:Brokerage 10 HOOL {500.00 USD, 2024-01-15}\n Assets:Cash -5000.00 USD\n";
let out = format_source(src);
assert!(
out.contains("{500.00 USD, 2024-01-15}"),
"comma must stay tight to USD; got:\n{out}"
);
assert!(
!out.contains("USD ,"),
"no space allowed before comma; got:\n{out}"
);
}
#[test]
fn custom_directive_preserves_date_value_arguments() {
let src = "2024-01-01 custom \"budget\" \"name\" 2024-06-15 100.00 USD\n";
let out = format_source(src);
assert!(
out.contains("2024-06-15"),
"value-position DATE must survive; got: {out}"
);
}
#[test]
fn file_level_adjacent_comments_stay_tight() {
let src = "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n";
let expected = "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n";
assert_eq!(format_source(src), expected);
}
#[test]
fn metadata_internal_whitespace_normalized() {
let a = "2024-01-01 open Assets:Bank\n starting: \"foo\"\n";
let b = "2024-01-01 open Assets:Bank\n starting: \"foo\"\n";
assert_eq!(format_source(a), format_source(b));
}
#[test]
fn metadata_number_thousands_separator_stripped() {
let src = "2024-01-01 open Assets:Bank\n starting_balance: 1,000.00 USD\n";
let out = format_source(src);
assert!(
out.contains("1000.00 USD"),
"thousands-sep should strip in metadata too; got: {out}"
);
assert!(!out.contains("1,000"), "got: {out}");
}
#[test]
fn bare_cr_line_endings_normalized_to_lf_before_parse() {
let src = "2024-01-01 open Assets:A\r2024-01-02 open Assets:B\r";
let out = format_source(src);
assert!(
out.contains("2024-01-01 open Assets:A"),
"first directive lost: {out:?}"
);
assert!(
out.contains("2024-01-02 open Assets:B"),
"second directive lost on bare-CR input: {out:?}"
);
}
#[test]
fn crlf_input_canonicalizes_to_lf() {
let src = "2024-01-01 open Assets:A\r\n2024-01-02 open Assets:B\r\n";
let out = format_source(src);
assert!(
!out.contains('\r'),
"canonical output must be LF-only: {out:?}"
);
assert!(out.contains("2024-01-01 open Assets:A\n"), "got: {out:?}");
assert!(out.contains("2024-01-02 open Assets:B\n"), "got: {out:?}");
}
#[test]
fn metadata_value_with_unary_minus_stays_tight() {
let src = "2024-01-01 open Assets:Bank\n threshold: -5.00 USD\n";
let out = format_source(src);
assert!(
out.contains("threshold: -5.00 USD"),
"unary minus must stay tight in metadata; got: {out}"
);
assert!(
!out.contains("- 5.00"),
"no space after unary minus; got: {out}"
);
}
#[test]
fn metadata_value_with_unary_plus_stays_tight() {
let src = "2024-01-01 open Assets:Bank\n min: +1.00 USD\n";
let out = format_source(src);
assert!(out.contains("min: +1.00 USD"), "got: {out}");
assert!(!out.contains("+ 1.00"), "got: {out}");
}
#[test]
fn cost_spec_negative_cost_stays_tight() {
let src = "2024-01-01 * \"x\"\n Assets:Brokerage 10 HOOL {-500 USD}\n Assets:Cash -5000.00 USD\n";
let out = format_source(src);
assert!(
out.contains("{-500 USD}"),
"negative cost spec must stay tight; got:\n{out}"
);
assert!(!out.contains("{- "), "got:\n{out}");
}
#[test]
fn cost_spec_arithmetic_with_unary_stays_tight() {
let src = "2024-01-01 * \"x\"\n Assets:Brokerage 10 HOOL {500 * -2 USD}\n Assets:Cash -1000.00 USD\n";
let out = format_source(src);
assert!(
out.contains("{500 * -2 USD}"),
"cost-spec arithmetic unary must stay tight; got:\n{out}"
);
}
const IDEMPOTENCE_MATRIX: &[(&str, &str)] = &[
("empty", ""),
("only_comment", "; header comment\n"),
("only_directive", "2024-01-01 open Assets:Cash\n"),
(
"two_open_directives",
"2024-01-01 open Assets:A\n2024-01-02 open Assets:B\n",
),
(
"transaction_with_cost_and_price",
"2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL {500.00 USD} @ 510.00 USD\n Assets:Cash -5000.00 USD\n",
),
(
"transaction_with_per_unit_plus_total_cost",
"2024-01-15 * \"x\"\n Assets:Brokerage 10 HOOL {# 500.00 USD}\n Assets:Cash -5000.00 USD\n",
),
(
"transaction_with_arithmetic_amount",
"2024-01-15 * \"x\"\n Assets:Cash -(1.00 + 2.00) USD\n Expenses:Misc 3.00 USD\n",
),
(
"balance_with_arithmetic_and_tolerance",
"2024-01-15 balance Assets:Cash 0.25 + 0.75 USD ~ 0.01 USD\n",
),
(
"balance_leading_unary_minus",
"2024-01-15 balance Assets:A -1.00 USD\n",
),
(
"balance_leading_parenthesized_expression",
"2024-01-15 balance Assets:A (1 + 2) USD\n",
),
(
"price_leading_unary_minus",
"2024-01-15 price USD -1.00 EUR\n",
),
(
"price_with_thousands_separator",
"2024-01-15 price USD 1,234.56 EUR\n",
),
(
"metadata_unary_minus",
"2024-01-01 open Assets:Bank\n threshold: -5.00 USD\n",
),
(
"metadata_arithmetic",
"2024-01-01 open Assets:Bank\n total: 1000 + 500 USD\n",
),
(
"cost_spec_with_comma_and_date",
"2024-01-15 * \"x\"\n Assets:Brokerage 10 HOOL {500.00 USD, 2024-01-15}\n Assets:Cash -5000.00 USD\n",
),
(
"cost_spec_with_negative",
"2024-01-15 * \"x\"\n Assets:Brokerage 10 HOOL {-500 USD}\n Assets:Cash 5000.00 USD\n",
),
(
"transaction_with_tags_and_links",
"2024-01-15 * \"x\" #tag1 ^link1 #tag2\n Assets:Cash -1.00 USD\n Expenses:Misc 1.00 USD\n",
),
(
"custom_with_date_value",
"2024-01-01 custom \"budget\" \"name\" 2024-06-15 100.00 USD\n",
),
(
"non_latin_account_name",
"2024-01-15 * \"x\"\n Активы:Банк -5.00 USD\n Expenses:Misc 5.00 USD\n",
),
(
"section_header_comments",
"; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n",
),
(
"multiline_note_string",
"2024-01-15 note Assets:Bank \"line 1\nline 2\"\n",
),
(
"comment_containing_quote",
"; comment with \"a quote\n2024-01-01 open Assets:A\n",
),
(
"crlf_input",
"2024-01-01 open Assets:A\r\n2024-01-02 open Assets:B\r\n",
),
(
"bare_cr_input",
"2024-01-01 open Assets:A\r2024-01-02 open Assets:B\r",
),
(
"file_with_trailing_newlines",
"2024-01-01 open Assets:A\n\n\n",
),
("file_without_trailing_newline", "2024-01-01 open Assets:A"),
(
"trailing_comment_no_final_newline",
"2024-01-15 open Assets:A ; trailing",
),
(
"posting_with_trailing_comment",
"2024-01-15 * \"x\"\n Assets:Cash -5.00 USD ; pocket\n Expenses:Misc 5.00 USD\n",
),
(
"balance_assertion_with_meta",
"2024-01-15 balance Assets:Cash 100.00 USD\n source: \"bank\"\n",
),
(
"options_and_includes",
"option \"title\" \"My Ledger\"\ninclude \"sub.beancount\"\nplugin \"my.plugin\" \"cfg\"\n",
),
("close_directive", "2024-12-31 close Assets:Cash\n"),
("commodity_directive", "2024-01-01 commodity HOOL\n"),
("note_directive", "2024-01-15 note Assets:Cash \"a note\"\n"),
("event_directive", "2024-01-15 event \"location\" \"NYC\"\n"),
(
"query_directive",
"2024-01-15 query \"q1\" \"SELECT account\"\n",
),
("pad_directive", "2024-01-15 pad Assets:A Equity:Opening\n"),
(
"document_directive",
"2024-06-01 document Assets:Bank \"stmt.pdf\" #q1\n",
),
(
"emacs_directive_mid_line_with_quote",
"2024-01-15 open Assets:A #+stray \"q\n",
),
("pushtag_directive", "pushtag #active\n"),
("poptag_directive", "poptag #active\n"),
("pushmeta_directive", "pushmeta location: \"NYC\"\n"),
("popmeta_directive", "popmeta location:\n"),
];
const ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES: usize = 8;
#[test]
fn lf_to_crlf_outside_strings_preserves_string_interior() {
let s = "2024-01-15 note Assets:Bank \"line 1\nline 2\"\n";
let out = lf_to_crlf_outside_strings(s);
assert!(out.contains("line 1\nline 2"), "got: {out:?}");
assert!(out.ends_with("\r\n"), "got: {out:?}");
}
#[test]
fn lf_to_crlf_outside_strings_handles_comment_with_quote() {
let s = "; comment with \"a quote\n2024-01-01 open Assets:A\n";
let out = lf_to_crlf_outside_strings(s);
assert_eq!(
out,
"; comment with \"a quote\r\n2024-01-01 open Assets:A\r\n",
);
}
#[test]
fn lf_to_crlf_outside_strings_handles_percent_comment_with_quote() {
let s = "% percent \"quote\n2024-01-01 open Assets:A\n";
let out = lf_to_crlf_outside_strings(s);
assert_eq!(out, "% percent \"quote\r\n2024-01-01 open Assets:A\r\n");
}
#[test]
fn crlf_to_lf_preserves_crlf_inside_strings() {
let s = "2024-01-15 note Assets:Bank \"line1\r\nline2\"\r\n";
let normalized = crlf_to_lf_outside_strings(s);
assert!(
normalized.contains("\"line1\r\nline2\""),
"got: {:?}",
&*normalized
);
assert!(normalized.ends_with('\n') && !normalized.ends_with("\r\n"));
}
#[test]
fn idempotence_matrix() {
for (name, src) in IDEMPOTENCE_MATRIX {
let once = format_source(src);
let twice = format_source(&once);
assert_eq!(
once, twice,
"idempotence broken on fixture `{name}`\n--- once ---\n{once}\n--- twice ---\n{twice}",
);
}
}
#[test]
fn canonicalize_directives_roundtrips_every_synthesized_directive() {
use rustledger_core::format::FormatConfig;
let cfg = FormatConfig::default();
let mut exercised = 0usize;
for (name, src) in IDEMPOTENCE_MATRIX {
let parsed = crate::parse(src);
if parsed.errors.is_empty() && !parsed.directives.is_empty() {
let dirs: Vec<&rustledger_core::Directive> =
parsed.directives.iter().map(|s| &s.value).collect();
let formatted = super::canonicalize_directives(dirs.iter().copied(), &cfg)
.unwrap_or_else(|e| {
panic!("canonicalize_directives error on fixture `{name}`: {e}")
});
let reparsed = crate::parse(&formatted);
assert!(
reparsed.errors.is_empty(),
"round-trip parse errors on fixture `{name}`:\n--- formatted ---\n{formatted}\n--- errors ---\n{:?}",
reparsed.errors,
);
assert_eq!(
parsed.directives.len(),
reparsed.directives.len(),
"directive count drifted on fixture `{name}`\n--- formatted ---\n{formatted}",
);
exercised += 1;
}
}
let expected = IDEMPOTENCE_MATRIX
.len()
.saturating_sub(ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES);
assert!(
exercised >= expected,
"only {exercised} fixtures exercised the round-trip body, \
expected at least {expected} (= IDEMPOTENCE_MATRIX.len() - \
{ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES}). A parser \
regression or a broken fixture is silently dropping coverage."
);
}
#[test]
fn lf_to_crlf_outside_strings_handles_emacs_directive_with_quote() {
let s = "#+title: \"My Book\n2024-01-01 open Assets:A\n";
let out = lf_to_crlf_outside_strings(s);
assert_eq!(out, "#+title: \"My Book\r\n2024-01-01 open Assets:A\r\n");
}
#[test]
fn lf_to_crlf_outside_strings_handles_shebang_with_quote() {
let s = "#!shebang \"quote\n2024-01-01 open Assets:A\n";
let out = lf_to_crlf_outside_strings(s);
assert_eq!(out, "#!shebang \"quote\r\n2024-01-01 open Assets:A\r\n");
}
#[test]
fn lf_to_crlf_outside_strings_hash_mid_line_is_not_comment() {
let s = "2024-01-15 * \"x\" #tag1\n Assets:A 1 USD\n";
let out = lf_to_crlf_outside_strings(s);
assert!(out.contains("#tag1\r\n"), "got: {out:?}");
assert!(out.ends_with("\r\n"), "got: {out:?}");
}
#[test]
fn balance_price_preserve_leading_unary_and_parens() {
let src = "2024-01-15 balance Assets:A -1.00 USD\n";
assert_eq!(
format_source(src),
"2024-01-15 balance Assets:A -1.00 USD\n"
);
let src = "2024-01-15 price USD -1.00 EUR\n";
assert_eq!(format_source(src), "2024-01-15 price USD -1.00 EUR\n");
let src = "2024-01-15 balance Assets:A (1 + 2) USD\n";
assert_eq!(
format_source(src),
"2024-01-15 balance Assets:A (1 + 2) USD\n"
);
let src = "2024-01-15 balance Assets:A -(1 + 2) USD\n";
assert_eq!(
format_source(src),
"2024-01-15 balance Assets:A -(1 + 2) USD\n"
);
}
#[test]
fn trailing_comment_preserved_at_eof_without_newline() {
let src = "2024-01-15 open Assets:A ; trailing";
assert_eq!(format_source(src), "2024-01-15 open Assets:A ; trailing\n");
}
#[test]
fn try_format_source_returns_ok_on_clean_input() {
let src = "2024-01-15 open Assets:Cash\n";
let out = super::try_format_source(src).expect("clean input should format");
assert_eq!(out, super::format_source(src));
}
#[test]
fn try_format_source_returns_err_on_parse_error() {
let src = "this is not a directive at all\n";
let err = super::try_format_source(src).expect_err("garbage should error");
assert!(!err.is_empty(), "errors must not be empty");
}
#[test]
fn cr_outside_strings_present_distinguishes_in_string_cr() {
let in_string_only = "2024-01-15 note Assets:Bank \"line1\r\nline2\"\n";
assert!(!super::cr_outside_strings_present(in_string_only));
let crlf_terminator = "2024-01-01 open Assets:A\r\n";
assert!(super::cr_outside_strings_present(crlf_terminator));
let lf_only = "2024-01-01 open Assets:A\n";
assert!(!super::cr_outside_strings_present(lf_only));
let comment_with_cr = "; comment with \"quote\rstuff\n";
assert!(super::cr_outside_strings_present(comment_with_cr));
}
#[test]
fn canonicalize_directives_directive_count_mismatch_is_reported() {
let err = super::CanonicalizeError::DirectiveCountMismatch {
input: 3,
reparsed: 2,
};
let msg = format!("{err}");
assert!(msg.contains("3 directive(s)"), "got: {msg}");
assert!(msg.contains("2 survived"), "got: {msg}");
assert!(msg.contains("rledger bug"), "got: {msg}");
}
const DIRECTIVE_VARIANT_FIXTURE_MAP: &[(&str, &str)] = &[
("Transaction", "transaction_with_cost_and_price"),
("Balance", "balance_with_arithmetic_and_tolerance"),
("Open", "only_directive"),
("Close", "close_directive"),
("Commodity", "commodity_directive"),
("Pad", "pad_directive"),
("Event", "event_directive"),
("Query", "query_directive"),
("Note", "note_directive"),
("Document", "document_directive"),
("Price", "price_with_thousands_separator"),
("Custom", "custom_with_date_value"),
];
const fn fixture_for_variant(tag: &str) -> &'static str {
let mut i = 0;
while i < DIRECTIVE_VARIANT_FIXTURE_MAP.len() {
let (v, f) = DIRECTIVE_VARIANT_FIXTURE_MAP[i];
let v_bytes = v.as_bytes();
let t_bytes = tag.as_bytes();
if v_bytes.len() == t_bytes.len() {
let mut k = 0;
let mut eq = true;
while k < v_bytes.len() {
if v_bytes[k] != t_bytes[k] {
eq = false;
break;
}
k += 1;
}
if eq {
return f;
}
}
i += 1;
}
panic!("DIRECTIVE_VARIANT_FIXTURE_MAP missing entry for variant tag");
}
#[allow(dead_code)]
fn _directive_variant_fixture_coverage(d: &rustledger_core::Directive) -> &'static str {
match d {
rustledger_core::Directive::Transaction(_) => fixture_for_variant("Transaction"),
rustledger_core::Directive::Balance(_) => fixture_for_variant("Balance"),
rustledger_core::Directive::Open(_) => fixture_for_variant("Open"),
rustledger_core::Directive::Close(_) => fixture_for_variant("Close"),
rustledger_core::Directive::Commodity(_) => fixture_for_variant("Commodity"),
rustledger_core::Directive::Pad(_) => fixture_for_variant("Pad"),
rustledger_core::Directive::Event(_) => fixture_for_variant("Event"),
rustledger_core::Directive::Query(_) => fixture_for_variant("Query"),
rustledger_core::Directive::Note(_) => fixture_for_variant("Note"),
rustledger_core::Directive::Document(_) => fixture_for_variant("Document"),
rustledger_core::Directive::Price(_) => fixture_for_variant("Price"),
rustledger_core::Directive::Custom(_) => fixture_for_variant("Custom"),
}
}
#[test]
fn directive_variant_fixture_names_resolve_in_matrix() {
use rustledger_core::Directive;
fn matches_variant(d: &Directive, expected: &str) -> bool {
matches!(
(d, expected),
(Directive::Transaction(_), "Transaction")
| (Directive::Balance(_), "Balance")
| (Directive::Open(_), "Open")
| (Directive::Close(_), "Close")
| (Directive::Commodity(_), "Commodity")
| (Directive::Pad(_), "Pad")
| (Directive::Event(_), "Event")
| (Directive::Query(_), "Query")
| (Directive::Note(_), "Note")
| (Directive::Document(_), "Document")
| (Directive::Price(_), "Price")
| (Directive::Custom(_), "Custom")
)
}
for (variant, name) in DIRECTIVE_VARIANT_FIXTURE_MAP {
let (_, src) = IDEMPOTENCE_MATRIX
.iter()
.find(|(n, _)| *n == *name)
.unwrap_or_else(|| {
panic!(
"fixture `{name}` is named by \
DIRECTIVE_VARIANT_FIXTURE_MAP but missing from \
IDEMPOTENCE_MATRIX"
)
});
let parsed = crate::parse(src);
let found = parsed
.directives
.iter()
.any(|s| matches_variant(&s.value, variant));
assert!(
found,
"fixture `{name}` is mapped to `Directive::{variant}` by \
DIRECTIVE_VARIANT_FIXTURE_MAP, but parsing it produced \
no directive of that variant (got {:?}). This silently \
leaves the variant without canonical-form coverage.",
parsed
.directives
.iter()
.map(|s| std::mem::discriminant(&s.value))
.collect::<Vec<_>>()
);
}
}
#[test]
fn idempotence_matrix_mirrors_format_compat_pairs() {
const MIRROR_PAIRS_MATRIX_HALF: &[&str] = &[
"balance_leading_unary_minus",
"balance_leading_parenthesized_expression",
"price_leading_unary_minus",
"cost_spec_with_negative",
"cost_spec_with_comma_and_date",
"transaction_with_per_unit_plus_total_cost",
"metadata_unary_minus",
"metadata_arithmetic",
"non_latin_account_name",
"posting_with_trailing_comment",
"multiline_note_string",
"comment_containing_quote",
"transaction_with_tags_and_links",
"custom_with_date_value",
"options_and_includes",
"balance_assertion_with_meta",
"crlf_input",
];
let matrix_names: std::collections::BTreeSet<&str> =
IDEMPOTENCE_MATRIX.iter().map(|(name, _)| *name).collect();
let missing: Vec<&&str> = MIRROR_PAIRS_MATRIX_HALF
.iter()
.filter(|name| !matrix_names.contains(*name))
.collect();
assert!(
missing.is_empty(),
"IDEMPOTENCE_MATRIX is missing the matrix-half of MIRROR_PAIRS: {missing:?}. \
Either re-add the entry to IDEMPOTENCE_MATRIX, or edit MIRROR_PAIRS in \
tests/format_compat.rs to retire the pair from BOTH sides.",
);
}
#[test]
fn source_state_classification_agrees_with_lexer() {
use crate::logos_lexer::{Token, tokenize_lossless};
for (name, src) in IDEMPOTENCE_MATRIX {
let tokens = tokenize_lossless(src);
let mut expected = vec![SourceState::Code; src.len()];
for (token, span) in &tokens {
let classify = match token {
Token::String(_) => Some(SourceState::InString),
Token::Comment(_) | Token::Shebang(_) | Token::EmacsDirective(_) => {
Some(SourceState::InComment)
}
_ => None,
};
if let Some(state) = classify {
for byte in &mut expected[span.start..span.end] {
*byte = state;
}
}
}
let (actual, transitions) = classify_source_bytes_with_transitions(src);
for (i, (&want, &got)) in expected.iter().zip(actual.iter()).enumerate() {
if transitions.contains(&i) {
continue;
}
assert_eq!(
want,
got,
"state-machine / lexer disagreement on fixture `{name}` \
at byte {i} ({:?}): lexer said {want:?}, state machine said {got:?}",
src.as_bytes()[i] as char
);
}
}
}
fn classify_source_bytes_with_transitions(
s: &str,
) -> (Vec<SourceState>, std::collections::HashSet<usize>) {
let (body, bom_len) = match s.strip_prefix('\u{FEFF}') {
Some(rest) => (rest, '\u{FEFF}'.len_utf8()),
None => (s, 0),
};
let mut out: Vec<SourceState> = vec![SourceState::Code; s.len()];
let mut transitions = std::collections::HashSet::new();
let mut chars = body.char_indices().peekable();
let mut state = SourceState::Code;
let mut prev_was_backslash = false;
while let Some((rel_i, ch)) = chars.next() {
let i = bom_len + rel_i;
let peek = chars.peek().map(|&(_, c)| c);
for byte in &mut out[i..i + ch.len_utf8()] {
*byte = state;
}
let prev_state = state;
let next_state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
if next_state != state {
let opening = matches!(prev_state, SourceState::Code)
&& matches!(next_state, SourceState::InString | SourceState::InComment);
let comment_close = matches!(prev_state, SourceState::InComment)
&& matches!(next_state, SourceState::Code);
if opening || comment_close {
transitions.insert(i);
if matches!(ch, '#') && matches!(peek, Some('!' | '+')) {
transitions.insert(i + 1);
}
}
}
state = next_state;
}
(out, transitions)
}
#[test]
fn canonicalize_directives_positive_count_check() {
use rustledger_core::format::FormatConfig;
let cfg = FormatConfig::default();
let src = "2024-01-01 open Assets:Cash\n2024-01-02 open Assets:Bank\n2024-01-03 close Assets:Cash\n";
let parsed = crate::parse(src);
assert_eq!(
parsed.directives.len(),
3,
"fixture must parse to 3 directives"
);
let dirs: Vec<&rustledger_core::Directive> =
parsed.directives.iter().map(|s| &s.value).collect();
let formatted = super::canonicalize_directives(dirs.iter().copied(), &cfg)
.expect("canonicalize_directives should succeed on this input");
let reparsed = crate::parse(&formatted);
assert_eq!(
reparsed.directives.len(),
3,
"count check accepted but round-trip dropped directives: {formatted}"
);
}
fn parse_for_range(source: &str) -> (crate::SyntaxNode, String) {
let (stripped, _bom) = crate::bom::strip_leading(source);
let normalized = crlf_to_lf_outside_strings(stripped).to_string();
let sf = SourceFile::parse(&normalized);
(sf.syntax().clone(), normalized)
}
fn ts(n: usize) -> rowan::TextSize {
rowan::TextSize::try_from(n).expect("offset fits TextSize")
}
#[test]
fn format_node_range_full_range_matches_format_node() {
let source = "\
2024-01-01 open Assets:Bank USD
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
2024-01-31 close Assets:Bank
";
let (node, src) = parse_for_range(source);
let full = rowan::TextRange::new(ts(0), ts(src.len()));
let (snap, formatted) =
format_node_range(&node, full).expect("full range must include all directives");
assert_eq!(
snap,
rowan::TextRange::new(ts(0), ts(src.len())),
"snap range should be the whole file's textual span"
);
assert_eq!(formatted, format_node(&node));
}
#[test]
fn format_node_range_trivia_only_returns_none() {
let (empty, _) = parse_for_range("\n\n\n");
let sel = rowan::TextRange::new(ts(0), ts(3));
assert!(format_node_range(&empty, sel).is_none());
}
#[test]
fn format_node_range_single_directive() {
let source = "\
2024-01-01 open Assets:Bank USD
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
";
let (node, src) = parse_for_range(source);
let open_byte = src.find("open").expect("fixture contains 'open'");
let sel = rowan::TextRange::new(ts(open_byte), ts(open_byte + "open".len()));
let (snap, formatted) = format_node_range(&node, sel).expect("intersects 1 directive");
let open_end = src.find('\n').expect("first directive has terminator") + 1;
assert_eq!(snap.start(), ts(0));
assert_eq!(snap.end(), ts(open_end));
assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn format_node_range_multi_directive_preserves_blank_lines() {
let spaced = "\
2024-01-01 open Assets:Bank USD
2024-01-31 close Assets:Bank
";
let (node, src) = parse_for_range(spaced);
let sel = rowan::TextRange::new(ts(0), ts(src.len()));
let (snap, formatted) = format_node_range(&node, sel).expect("intersects 2 directives");
assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
assert_eq!(formatted, spaced, "the blank separator must be preserved");
let grouped = "\
2024-01-01 open Assets:Bank USD
2024-01-31 close Assets:Bank
";
let (node2, src2) = parse_for_range(grouped);
let sel2 = rowan::TextRange::new(ts(0), ts(src2.len()));
let (_, formatted2) = format_node_range(&node2, sel2).expect("intersects 2 directives");
assert_eq!(formatted2, grouped, "grouped directives must stay grouped");
}
#[test]
fn format_node_range_first_directive_in_snap_keeps_leading_blank() {
let source = "2024-01-01 open Assets:Bank USD\n\n2024-01-31 close Assets:Bank\n";
let (node, src) = parse_for_range(source);
let close_byte = src.find("close").expect("fixture has 'close'");
let cursor = rowan::TextRange::new(ts(close_byte), ts(close_byte));
let (snap, formatted) = format_node_range(&node, cursor).expect("intersects close");
assert_eq!(formatted, "\n2024-01-31 close Assets:Bank\n");
let mut result = src;
result.replace_range(
usize::from(snap.start())..usize::from(snap.end()),
&formatted,
);
assert_eq!(
result, source,
"range-formatting the second directive must not delete the blank above it"
);
}
#[test]
fn format_node_range_cursor_inside_directive() {
let source = "\
2024-01-01 open Assets:Bank USD
2024-01-31 close Assets:Bank
";
let (node, src) = parse_for_range(source);
let close_byte = src.find("close").expect("fixture has 'close'");
let cursor = rowan::TextRange::new(ts(close_byte), ts(close_byte));
let (snap, formatted) = format_node_range(&node, cursor).expect("intersects close");
let close_dir_start = src
.find("\n2024-01-31")
.map(|n| n + 1)
.expect("close directive starts on its own line");
assert_eq!(snap.start(), ts(close_dir_start));
assert_eq!(snap.end(), ts(src.len()));
assert_eq!(formatted, "2024-01-31 close Assets:Bank\n");
}
#[test]
fn format_node_range_cursor_at_directive_start_includes_directive() {
let source = "\
2024-01-01 open Assets:Bank USD
2024-01-31 close Assets:Bank
";
let (node, _src) = parse_for_range(source);
let cursor = rowan::TextRange::new(ts(0), ts(0));
let (_snap, formatted) = format_node_range(&node, cursor).expect("intersects open");
assert!(formatted.starts_with("2024-01-01 open"));
assert!(!formatted.contains("close"));
}
#[test]
fn format_node_range_includes_top_level_comments() {
let source = "\
; header
2024-01-01 open Assets:Bank USD
";
let (node, src) = parse_for_range(source);
let sel = rowan::TextRange::new(ts(0), ts(src.len()));
let (snap, formatted) = format_node_range(&node, sel).expect("intersects both");
assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
assert_eq!(formatted, "; header\n2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn format_node_range_error_node_only_returns_none() {
let source = "}}}\n";
let (node, src) = parse_for_range(source);
let sel = rowan::TextRange::new(ts(0), ts(src.len()));
assert!(format_node_range(&node, sel).is_none());
}
#[test]
fn format_node_range_past_eof_clamps() {
let source = "2024-01-01 open Assets:Bank USD\n";
let (node, src) = parse_for_range(source);
let past_eof = rowan::TextRange::new(ts(src.len()), ts(src.len() + 1000));
assert!(format_node_range(&node, past_eof).is_none());
let straddle = rowan::TextRange::new(ts(0), ts(src.len() + 1000));
let (snap, formatted) = format_node_range(&node, straddle).expect("intersects open");
assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn format_node_range_cursor_in_posting_snaps_to_transaction() {
let source = "\
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
";
let (node, src) = parse_for_range(source);
let bank_byte = src.find("Bank").expect("fixture has Bank");
let cursor = rowan::TextRange::new(ts(bank_byte), ts(bank_byte));
let (snap, _formatted) = format_node_range(&node, cursor).expect("intersects transaction");
assert_eq!(snap.start(), ts(0));
assert_eq!(snap.end(), ts(src.len()));
}
#[test]
fn format_node_range_bails_when_snap_covers_error_node() {
let source = "\
2024-01-01 open Assets:Bank USD
}}}garbage{{{
2024-01-31 close Assets:Bank
";
let (node, src) = parse_for_range(source);
let sel = rowan::TextRange::new(ts(0), ts(src.len()));
assert!(
format_node_range(&node, sel).is_none(),
"selection covering both directives + ERROR_NODE between them must bail \
to avoid silently deleting the garbage line — got Some output",
);
}
#[test]
fn format_node_range_formats_directive_when_snap_does_not_cover_error_node() {
let source = "\
2024-01-01 open Assets:Bank USD
}}}garbage{{{
2024-01-31 close Assets:Bank
";
let (node, src) = parse_for_range(source);
let open_end = src.find('\n').expect("first directive has newline") + 1;
let sel = rowan::TextRange::new(ts(0), ts(open_end));
let (snap, formatted) =
format_node_range(&node, sel).expect("selection covers only the open");
assert_eq!(snap.start(), ts(0));
assert_eq!(snap.end(), ts(open_end));
assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
}
#[test]
fn format_node_equals_format_node_with_alignment() {
let fixtures: &[(&str, &str)] = &[
("empty", ""),
("open only", "2024-01-01 open Assets:Bank USD\n"),
(
"single txn",
"\
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
",
),
(
"multi txn 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
",
),
];
for (label, source) in fixtures {
let (node, _src) = parse_for_range(source);
let source_file = SourceFile::cast(node.clone()).unwrap();
let alignment = compute_alignment(&source_file);
assert_eq!(
format_node(&node),
format_node_with_alignment(&node, alignment),
"format_node_with_alignment must match format_node for {label}",
);
}
}
#[test]
fn format_node_range_matches_format_node_range_with_alignment() {
let source = "\
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
";
let (node, src) = parse_for_range(source);
let source_file = SourceFile::cast(node.clone()).unwrap();
let alignment = compute_alignment(&source_file);
let sels = [
rowan::TextRange::new(ts(0), ts(src.len())),
rowan::TextRange::new(ts(0), ts(10)),
rowan::TextRange::new(ts(src.len() - 10), ts(src.len())),
];
for sel in sels {
let uncached = format_node_range(&node, sel);
let cached = format_node_range_with_alignment(&node, sel, alignment);
assert_eq!(
uncached, cached,
"format_node_range_with_alignment must match \
format_node_range for range {sel:?}",
);
}
}
#[test]
fn parse_result_alignment_drives_identical_format_output() {
let source = "\
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
";
let parse_result = crate::parse(source);
let node = parse_result.syntax_node();
assert_eq!(
format_node(&node),
format_node_with_alignment(&node, parse_result.alignment),
"ParseResult::alignment must drive identical format output to format_node",
);
}
#[test]
fn format_source_with_parsed_matches_format_source() {
let fixtures: &[(&str, &str)] = &[
("empty", ""),
("comment only", "; hello\n"),
(
"single transaction LF",
"\
2024-01-15 * \"Coffee\"
Assets:Bank -5.00 USD
Expenses:Food
",
),
(
"multi transaction varying widths LF",
"\
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
",
),
(
"arithmetic amounts LF",
"\
2024-01-15 * \"Split\"
Assets:Bank -10.00 + 5.00 USD
Expenses:Misc
",
),
(
"CRLF source",
"2024-01-15 * \"Coffee\"\r\n Assets:Bank -5.00 USD\r\n Expenses:Food\r\n",
),
("BOM-prefixed", "\u{FEFF}2024-01-01 open Assets:Bank USD\n"),
(
"BOM + CRLF combination",
"\u{FEFF}2024-01-15 * \"Coffee\"\r\n Assets:Bank -5.00 USD\r\n Expenses:Food\r\n",
),
(
"parse errors (exercises fallback)",
"2024-01-15 * \"x\"\n Assets:Bank -5.00 USD\n}}}garbage\n",
),
(
"bare CR line terminators (exercises fallback)",
"2024-01-01 open Assets:Bank USD\r2024-01-02 open Assets:Cash USD\r",
),
];
for (label, source) in fixtures {
let parse_result = crate::parse(source);
let baseline = format_source(source);
let cached = format_source_with_parsed(&parse_result, source);
assert_eq!(
cached, baseline,
"format_source_with_parsed must match format_source for {label}: \
baseline {baseline:?}, cached {cached:?}",
);
}
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "source` whose length doesn't match")]
fn format_source_with_parsed_panics_on_length_mismatch() {
let parse_result = crate::parse("2024-01-01 open Assets:Bank USD\n");
let _ = format_source_with_parsed(&parse_result, "different");
}
}