#![allow(clippy::enum_glob_use)]
use rustledger_parser::{SyntaxKind, SyntaxNode, parse_structured};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Element {
Tok(SyntaxKind),
Node(SyntaxKind),
}
fn elements_of(node: &SyntaxNode) -> Vec<Element> {
node.children_with_tokens()
.map(|el| match el {
rowan::NodeOrToken::Token(t) => Element::Tok(t.kind()),
rowan::NodeOrToken::Node(n) => Element::Node(n.kind()),
})
.collect()
}
fn tok_seq(kinds: &[SyntaxKind]) -> Vec<Element> {
kinds.iter().copied().map(Element::Tok).collect()
}
fn directives(root: &SyntaxNode) -> Vec<SyntaxNode> {
root.children()
.filter(|c| {
matches!(
c.kind(),
SyntaxKind::OPEN_DIRECTIVE
| SyntaxKind::CLOSE_DIRECTIVE
| SyntaxKind::BALANCE_DIRECTIVE
| SyntaxKind::PAD_DIRECTIVE
| SyntaxKind::EVENT_DIRECTIVE
| SyntaxKind::QUERY_DIRECTIVE
| SyntaxKind::NOTE_DIRECTIVE
| SyntaxKind::DOCUMENT_DIRECTIVE
| SyntaxKind::PRICE_DIRECTIVE
| SyntaxKind::COMMODITY_DIRECTIVE
| SyntaxKind::PUSHTAG_DIRECTIVE
| SyntaxKind::POPTAG_DIRECTIVE
| SyntaxKind::PUSHMETA_DIRECTIVE
| SyntaxKind::POPMETA_DIRECTIVE
| SyntaxKind::OPTION_DIRECTIVE
| SyntaxKind::INCLUDE_DIRECTIVE
| SyntaxKind::PLUGIN_DIRECTIVE
| SyntaxKind::CUSTOM_DIRECTIVE
| SyntaxKind::TRANSACTION
)
})
.collect()
}
fn assert_round_trip(source: &str, tree: &SyntaxNode) {
assert_eq!(
tree.text().to_string(),
source,
"structured parser must round-trip byte-identically",
);
}
#[test]
fn open_directive_with_currency() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT, WHITESPACE, CURRENCY, NEWLINE
]),
);
}
#[test]
fn close_directive() {
use SyntaxKind::*;
let source = "2024-12-31 close Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), CLOSE_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[DATE, WHITESPACE, CLOSE_KW, WHITESPACE, ACCOUNT, NEWLINE]),
);
}
#[test]
fn balance_directive() {
use SyntaxKind::*;
let source = "2024-06-30 balance Assets:Cash 100.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), BALANCE_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, BALANCE_KW, WHITESPACE, ACCOUNT, WHITESPACE, NUMBER, WHITESPACE,
CURRENCY, NEWLINE,
]),
);
}
#[test]
fn pad_directive() {
use SyntaxKind::*;
let source = "2024-01-01 pad Assets:Cash Equity:Opening-Balances\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PAD_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, PAD_KW, WHITESPACE, ACCOUNT, WHITESPACE, ACCOUNT, NEWLINE,
]),
);
}
#[test]
fn event_directive() {
use SyntaxKind::*;
let source = "2024-01-15 event \"location\" \"Berlin\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), EVENT_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, EVENT_KW, WHITESPACE, STRING, WHITESPACE, STRING, NEWLINE,
]),
);
}
#[test]
fn query_directive() {
use SyntaxKind::*;
let source = "2024-01-01 query \"income\" \"SELECT *\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), QUERY_DIRECTIVE);
}
#[test]
fn note_directive() {
use SyntaxKind::*;
let source = "2024-01-15 note Assets:Cash \"deposit\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), NOTE_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, NOTE_KW, WHITESPACE, ACCOUNT, WHITESPACE, STRING, NEWLINE,
]),
);
}
#[test]
fn document_directive() {
use SyntaxKind::*;
let source = "2024-01-15 document Assets:Cash \"/path/to/file.pdf\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), DOCUMENT_DIRECTIVE);
}
#[test]
fn price_directive() {
use SyntaxKind::*;
let source = "2024-01-15 price USD 1.10 EUR\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PRICE_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, PRICE_KW, WHITESPACE, CURRENCY, WHITESPACE, NUMBER, WHITESPACE,
CURRENCY, NEWLINE,
]),
);
}
#[test]
fn commodity_directive() {
use SyntaxKind::*;
let source = "2024-01-01 commodity USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), COMMODITY_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE,
WHITESPACE,
COMMODITY_KW,
WHITESPACE,
CURRENCY,
NEWLINE
]),
);
}
#[test]
fn pushtag_directive() {
use SyntaxKind::*;
let source = "pushtag #project-x\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PUSHTAG_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[PUSHTAG_KW, WHITESPACE, TAG, NEWLINE]),
);
}
#[test]
fn poptag_directive() {
use SyntaxKind::*;
let source = "poptag #project-x\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), POPTAG_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[POPTAG_KW, WHITESPACE, TAG, NEWLINE]),
);
}
#[test]
fn pushmeta_directive() {
use SyntaxKind::*;
let source = "pushmeta key: \"value\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PUSHMETA_DIRECTIVE);
}
#[test]
fn popmeta_directive() {
use SyntaxKind::*;
let source = "popmeta key:\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), POPMETA_DIRECTIVE);
}
#[test]
fn rule_1_same_line_trailing_comment_inside_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash ; main checking\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT, WHITESPACE, COMMENT, NEWLINE,
]),
);
}
#[test]
fn rule_2_blank_line_leads_following_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\n\
2024-01-02 open Assets:Bank\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT, NEWLINE]),
);
assert_eq!(
elements_of(&ds[1]),
tok_seq(&[
NEWLINE, DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT, NEWLINE
]),
);
}
#[test]
fn rule_3_copyright_header_under_source_file() {
use SyntaxKind::*;
let source = ";; Copyright 2024\n\
2024-01-01 open Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
assert_eq!(
elements_of(&tree),
vec![
Element::Tok(COMMENT),
Element::Tok(NEWLINE),
Element::Node(OPEN_DIRECTIVE),
],
);
}
#[test]
fn rule_4_trailing_comment_block_under_source_file() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
;; closing remarks\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
assert_eq!(
elements_of(&tree),
vec![
Element::Node(OPEN_DIRECTIVE),
Element::Tok(COMMENT),
Element::Tok(NEWLINE),
],
);
}
#[test]
fn rule_5_unterminated_final_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT]),
);
}
#[test]
fn rule_5_unterminated_with_same_line_trailing_trivia() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash ; eol-no-nl";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[
DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT, WHITESPACE, COMMENT,
]),
);
}
#[test]
fn mixed_directive_kinds_each_get_their_own_node() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash USD\n\
pushtag #x\n\
2024-01-02 close Assets:Cash\n\
poptag #x\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 4);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(ds[1].kind(), PUSHTAG_DIRECTIVE);
assert_eq!(ds[2].kind(), CLOSE_DIRECTIVE);
assert_eq!(ds[3].kind(), POPTAG_DIRECTIVE);
}
#[test]
fn transaction_with_star_flag_header_only() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[DATE, WHITESPACE, STAR, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn transaction_with_pending_kw_flag() {
use SyntaxKind::*;
let source = "2024-01-15 ! \"WIP\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[DATE, WHITESPACE, PENDING_KW, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn transaction_with_txn_keyword() {
use SyntaxKind::*;
let source = "2024-01-15 txn \"explicit\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
}
#[test]
fn transaction_with_postings_wraps_full_multi_line_body() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(elements_of(&tree), vec![Element::Node(TRANSACTION)]);
}
#[test]
fn transaction_with_metadata_and_postings() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n\
\x20\x20note: \"morning\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(elements_of(&tree), vec![Element::Node(TRANSACTION)]);
}
#[test]
fn transaction_with_payee_and_narration() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee Shop\" \"Morning coffee\" #daily ^trip1\n\
\x20\x20Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
}
#[test]
fn transaction_terminates_at_next_top_level_directive() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
2024-01-16 open Assets:Bank\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(ds[1].kind(), OPEN_DIRECTIVE);
}
#[test]
fn transaction_terminates_at_blank_line_before_next_directive() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\n\
2024-01-16 open Assets:Bank\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(ds[1].kind(), OPEN_DIRECTIVE);
let d2_first = elements_of(&ds[1]).first().copied();
assert_eq!(d2_first, Some(Element::Tok(NEWLINE)));
}
#[test]
fn transaction_with_indented_comment_between_postings() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20; documentation comment\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(elements_of(&tree), vec![Element::Node(TRANSACTION)]);
}
#[test]
fn transaction_with_implied_flag_via_bare_string() {
use SyntaxKind::*;
let source = "2024-01-15 \"Coffee\"\n\
\x20\x20Assets:Cash 100 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(elements_of(&tree), vec![Element::Node(TRANSACTION)]);
}
#[test]
fn transaction_with_hash_flag() {
use SyntaxKind::*;
let source = "2024-01-15 # \"pending hash\"\n\
\x20\x20Assets:Cash 100 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(elements_of(&tree), vec![Element::Node(TRANSACTION)]);
}
#[test]
fn transaction_with_single_char_currency_as_flag() {
use SyntaxKind::*;
let source = "2024-01-15 T \"AT&T dividend\"\n\
\x20\x20Assets:Brokerage 10 T\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(elements_of(&tree), vec![Element::Node(TRANSACTION)]);
}
#[test]
fn transaction_multi_char_currency_after_date_is_not_a_flag() {
let source = "2024-01-15 USD \"garbled\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert!(
ds.is_empty(),
"multi-char CURRENCY after DATE must not be a transaction flag",
);
}
#[test]
fn transaction_blank_line_inside_body_terminates_and_orphans_subsequent_postings() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 100 USD\n\
\n\
\x20\x20Liab:Card -100 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
let header_kinds: Vec<SyntaxKind> = elements_of(&ds[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(
header_kinds.contains(&DATE) && header_kinds.contains(&STAR),
"tx contains header",
);
let postings_inside_tx = ds[0].children().filter(|n| n.kind() == POSTING).count();
assert_eq!(
postings_inside_tx, 1,
"only the FIRST posting is wrapped inside the tx; the second is orphaned",
);
let total_accounts = tree
.descendants_with_tokens()
.filter(|e| e.kind() == ACCOUNT)
.count();
assert_eq!(total_accounts, 2);
}
#[test]
fn transaction_trailing_indented_comment_at_eof_stays_inside() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 100 USD\n\
\x20\x20Liab:Card -100 USD\n\
\x20\x20; closing note for this transaction\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(elements_of(&tree), vec![Element::Node(TRANSACTION)]);
}
#[test]
fn transaction_unterminated_at_eof_with_postings() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n\
\x20\x20Assets:Cash -5.00 USD";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), TRANSACTION);
}
fn meta_entries(node: &SyntaxNode) -> Vec<SyntaxNode> {
node.descendants()
.filter(|n| n.kind() == SyntaxKind::META_ENTRY)
.collect()
}
#[test]
fn meta_entry_wraps_metadata_sub_line_inside_open_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n description: \"main checking\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let mes = meta_entries(&tree);
assert_eq!(mes.len(), 1);
assert_eq!(
elements_of(&mes[0]),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn meta_entry_wraps_each_of_multiple_metadata_sub_lines() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20key1: \"value1\"\n\
\x20\x20key2: \"value2\"\n\
\x20\x20key3: \"value3\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let mes = meta_entries(&tree);
assert_eq!(mes.len(), 3);
for me in &mes {
assert_eq!(
elements_of(me),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING, NEWLINE]),
);
}
}
#[test]
fn meta_entry_does_not_wrap_indented_comments() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20k1: \"v1\"\n\
\x20\x20; doc comment\n\
\x20\x20k2: \"v2\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let mes = meta_entries(&tree);
assert_eq!(mes.len(), 2, "only k1 and k2 are META_ENTRYs");
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
let kids: Vec<Element> = elements_of(&ds[0]);
let comment_pos = kids
.iter()
.position(|e| matches!(e, Element::Tok(COMMENT)))
.expect("indented COMMENT lives flat in the directive");
let n_me_before_comment = kids[..comment_pos]
.iter()
.filter(|e| matches!(e, Element::Node(META_ENTRY)))
.count();
let n_me_after_comment = kids[comment_pos..]
.iter()
.filter(|e| matches!(e, Element::Node(META_ENTRY)))
.count();
assert_eq!(n_me_before_comment, 1, "k1 META_ENTRY precedes the comment");
assert_eq!(n_me_after_comment, 1, "k2 META_ENTRY follows the comment");
}
#[test]
fn meta_entry_inside_transaction_body() {
use SyntaxKind::*;
let source = "2024-01-15 * \"Coffee\"\n\
\x20\x20note: \"morning\"\n\
\x20\x20Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let mes = meta_entries(&tree);
assert_eq!(mes.len(), 1);
assert_eq!(
elements_of(&mes[0]),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING, NEWLINE]),
);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
assert_eq!(txs.len(), 1);
let n_meta_entries_in_tx = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(n_meta_entries_in_tx, 1);
let n_postings_in_tx = txs[0].children().filter(|n| n.kind() == POSTING).count();
assert_eq!(n_postings_in_tx, 1);
}
#[test]
fn meta_entry_at_eof_without_trailing_newline() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n key: \"v\"";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let mes = meta_entries(&tree);
assert_eq!(mes.len(), 1);
assert_eq!(
elements_of(&mes[0]),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING]),
);
}
#[test]
fn meta_entry_with_value_kinds_other_than_string() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20count: 42\n\
\x20\x20since: 2024-01-01\n\
\x20\x20mirror: Assets:Mirror\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let mes = meta_entries(&tree);
assert_eq!(mes.len(), 3);
let date_me_kinds = elements_of(&mes[1]);
assert!(date_me_kinds.contains(&Element::Tok(DATE)));
}
fn postings(node: &SyntaxNode) -> Vec<SyntaxNode> {
node.descendants()
.filter(|n| n.kind() == SyntaxKind::POSTING)
.collect()
}
#[test]
fn posting_wraps_account_only_line() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
assert_eq!(
elements_of(&ps[0]),
tok_seq(&[WHITESPACE, ACCOUNT, NEWLINE]),
);
}
#[test]
fn posting_wraps_account_with_amount_and_currency() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
assert_eq!(
elements_of(&ps[0]),
vec![
Element::Tok(WHITESPACE),
Element::Tok(ACCOUNT),
Element::Tok(WHITESPACE),
Element::Node(AMOUNT),
Element::Tok(NEWLINE),
],
);
let amounts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amounts.len(), 1);
assert_eq!(
elements_of(&amounts[0]),
tok_seq(&[MINUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn posting_wraps_each_of_multiple_postings_in_a_transaction() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 2);
for p in &ps {
assert!(
elements_of(p)
.iter()
.any(|e| matches!(e, Element::Tok(ACCOUNT)))
);
let n_amounts = p.children().filter(|n| n.kind() == AMOUNT).count();
assert_eq!(n_amounts, 1, "each POSTING contains exactly one AMOUNT");
}
}
#[test]
fn posting_with_pending_flag_wraps_flag_inside_node() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20! Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
assert_eq!(
elements_of(&ps[0]),
vec![
Element::Tok(WHITESPACE),
Element::Tok(PENDING_KW),
Element::Tok(WHITESPACE),
Element::Tok(ACCOUNT),
Element::Tok(WHITESPACE),
Element::Node(AMOUNT),
Element::Tok(NEWLINE),
],
);
}
#[test]
fn posting_attached_meta_entry_lives_inside_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20\x20\x20note: \"posting-attached\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
assert_eq!(txs.len(), 1);
let tx_posting_count = txs[0].children().filter(|n| n.kind() == POSTING).count();
let tx_meta_count = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(tx_posting_count, 1);
assert_eq!(tx_meta_count, 0);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let posting_meta_count = ps[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(posting_meta_count, 1);
}
#[test]
fn same_indent_metadata_attaches_to_preceding_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20note: \"on cash\"\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
assert_eq!(txs.len(), 1);
let tx_posting_count = txs[0].children().filter(|n| n.kind() == POSTING).count();
let tx_meta_count = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(tx_posting_count, 2);
assert_eq!(tx_meta_count, 0);
let ps = postings(&tree);
assert_eq!(ps.len(), 2);
assert_eq!(
ps[0].children().filter(|n| n.kind() == META_ENTRY).count(),
1
);
assert_eq!(
ps[1].children().filter(|n| n.kind() == META_ENTRY).count(),
0
);
}
#[test]
fn posting_attached_multiple_meta_entries_all_inside_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20\x20\x20key1: \"v1\"\n\
\x20\x20\x20\x20key2: \"v2\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let posting_meta_count = ps[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(posting_meta_count, 2);
}
#[test]
fn posting_attached_meta_entry_terminates_at_next_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20\x20\x20note: \"on cash\"\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 2);
let first_meta = ps[0].children().filter(|n| n.kind() == META_ENTRY).count();
let second_meta = ps[1].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(first_meta, 1);
assert_eq!(second_meta, 0);
}
#[test]
fn postings_at_increasing_indents_produce_siblings_and_meta_attributes_to_latest() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:A\n\
\x20\x20\x20\x20Assets:B 10 USD\n\
\x20\x20note: \"transaction-level\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 2, "two POSTING siblings at different indents");
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
assert_eq!(txs.len(), 1);
let tx_meta = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(
tx_meta, 1,
"meta at shallower indent than the open POSTING lands at TRANSACTION level",
);
for p in &ps {
let inner_meta = p.children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(inner_meta, 0);
}
}
#[test]
fn meta_entry_before_first_posting_stays_at_transaction_level() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20\x20\x20note: \"before posting\"\n\
\x20\x20Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
assert_eq!(txs.len(), 1);
let tx_meta = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
let tx_posting = txs[0].children().filter(|n| n.kind() == POSTING).count();
assert_eq!(tx_meta, 1);
assert_eq!(tx_posting, 1);
let ps = postings(&tree);
assert_eq!(
ps[0].children().filter(|n| n.kind() == META_ENTRY).count(),
0
);
}
#[test]
fn deeper_indented_comment_stays_inside_posting_with_following_meta() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20\x20\x20; comment about note\n\
\x20\x20\x20\x20note: \"deeper\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let posting_meta_count = ps[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(posting_meta_count, 1);
let posting_comment_count = ps[0]
.children_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
assert_eq!(
posting_comment_count, 1,
"deeper-indented `;` comment stays inside POSTING with following meta",
);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
let tx_meta = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
let tx_comment = txs[0]
.children_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
assert_eq!(tx_meta, 0);
assert_eq!(tx_comment, 0);
}
#[test]
fn deeper_indented_comment_stays_inside_posting_even_without_following_meta() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20\x20\x20; trailing posting doc\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 2);
let first_comment_count = ps[0]
.children_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
let second_comment_count = ps[1]
.children_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
assert_eq!(first_comment_count, 1);
assert_eq!(second_comment_count, 0);
}
#[test]
fn posting_with_indented_comment_between_postings_terminates_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD\n\
\x20\x20; doc comment\n\
\x20\x20Expenses:Food 5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 2);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
let tx_kids = elements_of(&txs[0]);
let first_posting_idx = tx_kids
.iter()
.position(|e| matches!(e, Element::Node(POSTING)))
.unwrap();
let comment_idx = tx_kids
.iter()
.position(|e| matches!(e, Element::Tok(COMMENT)))
.expect("indented comment is a flat TRANSACTION child");
assert!(
comment_idx > first_posting_idx,
"comment follows first POSTING"
);
}
#[test]
fn posting_at_eof_without_trailing_newline_still_wrapped() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5.00 USD";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
assert_eq!(
elements_of(&ps[0]),
vec![
Element::Tok(WHITESPACE),
Element::Tok(ACCOUNT),
Element::Tok(WHITESPACE),
Element::Node(AMOUNT),
],
);
let amounts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(
elements_of(&amounts[0]),
tok_seq(&[MINUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn star_flagged_posting_wraps_flag_inside_node() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20* Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&ps[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.starts_with(&[WHITESPACE, STAR, WHITESPACE, ACCOUNT]));
}
#[test]
fn flagged_posting_with_question_mark_wraps_flag_inside_node() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20? Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&ps[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.starts_with(&[WHITESPACE, FLAG, WHITESPACE, ACCOUNT]));
}
#[test]
fn hash_flagged_posting_wraps_hash_inside_node() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20# Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&ps[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.starts_with(&[WHITESPACE, HASH, WHITESPACE, ACCOUNT]));
}
#[test]
fn hash_flagged_posting_attached_meta_entry_lives_inside_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20# Assets:Cash -5.00 USD\n\
\x20\x20\x20\x20note: \"hash-flagged\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let posting_meta_count = ps[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(posting_meta_count, 1);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
let tx_meta = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(tx_meta, 0);
}
#[test]
fn deeper_indented_trailing_comment_at_eof_stays_inside_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 1 USD\n\
\x20\x20\x20\x20; deep trailing";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let posting_comment_count = ps[0]
.children_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
assert_eq!(
posting_comment_count, 1,
"EOF-trailing deep `;` comment is a child of POSTING",
);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
let tx_comment = txs[0]
.children_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
assert_eq!(tx_comment, 0);
}
#[test]
fn deeper_indented_emacs_directive_attaches_to_open_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 1 USD\n\
\x20\x20\x20\x20#+STARTUP: overview\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let emacs_inside_posting = ps[0]
.children_with_tokens()
.filter(|e| e.kind() == EMACS_DIRECTIVE)
.count();
assert_eq!(
emacs_inside_posting, 1,
"EMACS_DIRECTIVE recognized as comment-class trivia, attaches by indent",
);
}
#[test]
fn deeper_indented_shebang_attaches_to_open_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 1 USD\n\
\x20\x20\x20\x20#!/usr/bin/env something\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let shebang_inside_posting = ps[0]
.children_with_tokens()
.filter(|e| e.kind() == SHEBANG)
.count();
assert_eq!(
shebang_inside_posting, 1,
"SHEBANG recognized as comment-class trivia, attaches by indent",
);
}
#[test]
fn deeper_indented_percent_comment_attaches_to_open_posting() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 1 USD\n\
\x20\x20\x20\x20% percent-style doc\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let pct_inside_posting = ps[0]
.children_with_tokens()
.filter(|e| e.kind() == PERCENT_COMMENT)
.count();
assert_eq!(
pct_inside_posting, 1,
"PERCENT_COMMENT recognized as comment-class trivia, attaches by indent",
);
}
#[test]
fn directive_body_absorbs_indented_emacs_directive_when_block_has_meta() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20#+STARTUP: overview\n\
\x20\x20key: \"v\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
let emacs_total = tree
.descendants_with_tokens()
.filter(|e| e.kind() == EMACS_DIRECTIVE)
.count();
let emacs_in_directive = ds[0]
.descendants_with_tokens()
.filter(|e| e.kind() == EMACS_DIRECTIVE)
.count();
assert_eq!(emacs_total, 1, "exactly one EMACS_DIRECTIVE in the tree");
assert_eq!(
emacs_in_directive, 1,
"EMACS_DIRECTIVE absorbed by OPEN_DIRECTIVE"
);
let meta_entries_in_directive = ds[0]
.descendants()
.filter(|n| n.kind() == META_ENTRY)
.count();
assert_eq!(meta_entries_in_directive, 1, "META_ENTRY also absorbed");
}
#[test]
fn directive_body_does_not_absorb_indented_emacs_directive_when_no_meta() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20#+STARTUP: trailing only\n\
2024-01-02 open Assets:Bank\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(
ds.len(),
2,
"two OPEN_DIRECTIVES, separated by EMACS_DIRECTIVE rule-2 trivia"
);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(ds[1].kind(), OPEN_DIRECTIVE);
let emacs_total = tree
.descendants_with_tokens()
.filter(|e| e.kind() == EMACS_DIRECTIVE)
.count();
let emacs_in_first = ds[0]
.descendants_with_tokens()
.filter(|e| e.kind() == EMACS_DIRECTIVE)
.count();
let emacs_in_second = ds[1]
.descendants_with_tokens()
.filter(|e| e.kind() == EMACS_DIRECTIVE)
.count();
assert_eq!(emacs_total, 1, "exactly one EMACS_DIRECTIVE in the tree");
assert_eq!(
emacs_in_first, 0,
"EMACS_DIRECTIVE is NOT absorbed by header-only directive"
);
assert_eq!(
emacs_in_second, 1,
"EMACS_DIRECTIVE leads the next directive as rule-2 trivia"
);
}
#[test]
fn catch_all_indented_unknown_content_closes_posting_and_emits_flat() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 1 USD\n\
\x20\x20\"stray string on own line\"\n\
\x20\x20Expenses:Food 1 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(
ps.len(),
2,
"stray indented STRING closes POSTING; next POSTING opens fresh"
);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
let tx_strings: usize = txs[0]
.children_with_tokens()
.filter(|e| e.kind() == STRING)
.count();
assert_eq!(tx_strings, 2);
for p in &ps {
let inside_string = p
.children_with_tokens()
.filter(|e| e.kind() == STRING)
.count();
assert_eq!(inside_string, 0);
}
}
#[test]
fn same_indent_comment_between_posting_and_deeper_meta_orphans_meta() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5 USD\n\
\x20\x20; explicit break at posting indent\n\
\x20\x20\x20\x20key: \"orphaned\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let posting_meta_count = ps[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(
posting_meta_count, 0,
"same-indent comment ends posting-attached meta block; deeper meta orphans to TRANSACTION",
);
let txs: Vec<SyntaxNode> = tree
.children()
.filter(|c| c.kind() == TRANSACTION)
.collect();
let tx_meta = txs[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(tx_meta, 1);
}
#[test]
fn single_char_currency_flagged_posting_wraps_currency_as_flag() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20P Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&ps[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.starts_with(&[WHITESPACE, CURRENCY, WHITESPACE, ACCOUNT]));
}
fn amounts(node: &SyntaxNode) -> Vec<SyntaxNode> {
node.descendants()
.filter(|n| n.kind() == SyntaxKind::AMOUNT)
.collect()
}
fn cost_specs(node: &SyntaxNode) -> Vec<SyntaxNode> {
node.descendants()
.filter(|n| n.kind() == SyntaxKind::COST_SPEC)
.collect()
}
fn price_annotations(node: &SyntaxNode) -> Vec<SyntaxNode> {
node.descendants()
.filter(|n| n.kind() == SyntaxKind::PRICE_ANNOTATION)
.collect()
}
#[test]
fn amount_wraps_positive_number_and_currency() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 100.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn amount_wraps_negative_number_and_currency_with_sign_token() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -100.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[MINUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn amount_wraps_explicit_plus_sign_and_currency() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash +100.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[PLUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn amount_wraps_number_only_no_currency() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 100.00\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
assert_eq!(elements_of(&amts[0]), tok_seq(&[NUMBER]));
}
#[test]
fn amount_wraps_currency_only_no_number() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
assert_eq!(elements_of(&amts[0]), tok_seq(&[CURRENCY]));
}
#[test]
fn auto_posting_with_no_amount_has_no_amount_node() {
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 0, "auto posting has no AMOUNT child");
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
}
#[test]
fn cost_spec_wraps_simple_per_unit_cost() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {500.00 USD}\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let cs = cost_specs(&tree);
assert_eq!(cs.len(), 1);
assert_eq!(
elements_of(&cs[0]),
tok_seq(&[L_BRACE, NUMBER, WHITESPACE, CURRENCY, R_BRACE]),
);
}
#[test]
fn cost_spec_wraps_total_double_brace() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {{5000.00 USD}}\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let cs = cost_specs(&tree);
assert_eq!(cs.len(), 1);
assert_eq!(
elements_of(&cs[0]),
tok_seq(&[L_DOUBLE_BRACE, NUMBER, WHITESPACE, CURRENCY, R_DOUBLE_BRACE]),
);
}
#[test]
fn cost_spec_wraps_per_unit_plus_total_brace_hash() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {# 5000.00 USD}\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let cs = cost_specs(&tree);
assert_eq!(cs.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&cs[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.contains(&L_BRACE_HASH));
assert!(kinds.contains(&R_BRACE));
}
#[test]
fn cost_spec_unclosed_at_eof_still_wraps_per_rule_5() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {500.00 USD";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let cs = cost_specs(&tree);
assert_eq!(cs.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&cs[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.contains(&L_BRACE));
assert!(!kinds.contains(&R_BRACE), "no close brace consumed");
assert!(kinds.contains(&CURRENCY));
}
#[test]
fn price_annotation_wraps_per_unit_with_nested_amount() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL @ 500.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let prices = price_annotations(&tree);
assert_eq!(prices.len(), 1);
assert_eq!(
elements_of(&prices[0]),
vec![
Element::Tok(AT),
Element::Tok(WHITESPACE),
Element::Node(AMOUNT),
],
);
let inner_amount = prices[0].children().find(|n| n.kind() == AMOUNT).unwrap();
assert_eq!(
elements_of(&inner_amount),
tok_seq(&[NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn price_annotation_wraps_total_at_at() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL @@ 5000.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let prices = price_annotations(&tree);
assert_eq!(prices.len(), 1);
let first_child_kind = elements_of(&prices[0]).first().copied();
assert_eq!(first_child_kind, Some(Element::Tok(AT_AT)));
let inner_amount = prices[0].children().find(|n| n.kind() == AMOUNT).unwrap();
assert_eq!(
elements_of(&inner_amount),
tok_seq(&[NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn posting_with_amount_cost_spec_and_price_annotation_all_three() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {500.00 USD} @ 510.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let posting_kids = elements_of(&ps[0]);
let amount_count = posting_kids
.iter()
.filter(|e| matches!(e, Element::Node(AMOUNT)))
.count();
let cost_count = posting_kids
.iter()
.filter(|e| matches!(e, Element::Node(COST_SPEC)))
.count();
let price_count = posting_kids
.iter()
.filter(|e| matches!(e, Element::Node(PRICE_ANNOTATION)))
.count();
assert_eq!(amount_count, 1, "one units AMOUNT");
assert_eq!(cost_count, 1, "one COST_SPEC");
assert_eq!(price_count, 1, "one PRICE_ANNOTATION");
let amount_idx = posting_kids
.iter()
.position(|e| matches!(e, Element::Node(AMOUNT)))
.unwrap();
let cost_idx = posting_kids
.iter()
.position(|e| matches!(e, Element::Node(COST_SPEC)))
.unwrap();
let price_idx = posting_kids
.iter()
.position(|e| matches!(e, Element::Node(PRICE_ANNOTATION)))
.unwrap();
assert!(amount_idx < cost_idx);
assert!(cost_idx < price_idx);
}
#[test]
fn posting_amount_and_trailing_comment_keeps_comment_outside_amount() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 100 USD ; trailing\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
let amount_comments = amts[0]
.descendants_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
assert_eq!(amount_comments, 0, "comment is not absorbed into AMOUNT");
let ps = postings(&tree);
let posting_comments = ps[0]
.children_with_tokens()
.filter(|e| e.kind() == COMMENT)
.count();
assert_eq!(posting_comments, 1, "comment is a POSTING flat child");
}
#[test]
fn posting_attached_meta_entry_after_amount_still_attaches() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {500.00 USD}\n\
\x20\x20\x20\x20note: \"posting-attached\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amount_count = ps[0].children().filter(|n| n.kind() == AMOUNT).count();
let cost_count = ps[0].children().filter(|n| n.kind() == COST_SPEC).count();
let meta_count = ps[0].children().filter(|n| n.kind() == META_ENTRY).count();
assert_eq!(amount_count, 1);
assert_eq!(cost_count, 1);
assert_eq!(meta_count, 1);
}
#[test]
fn hash_flagged_posting_with_amount_wraps_both() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20# Assets:Cash -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amount_count = ps[0].children().filter(|n| n.kind() == AMOUNT).count();
assert_eq!(amount_count, 1);
let first_four: Vec<Element> = elements_of(&ps[0]).into_iter().take(4).collect();
assert_eq!(
first_four,
vec![
Element::Tok(WHITESPACE),
Element::Tok(HASH),
Element::Tok(WHITESPACE),
Element::Tok(ACCOUNT),
],
);
}
#[test]
fn amount_with_only_negative_number_no_currency() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -100\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
assert_eq!(elements_of(&amts[0]), tok_seq(&[MINUS, NUMBER]));
}
#[test]
fn price_annotation_without_amount_still_wraps_opener_only() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL @\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let prices = price_annotations(&tree);
assert_eq!(prices.len(), 1);
assert_eq!(elements_of(&prices[0]), tok_seq(&[AT]));
}
#[test]
fn cost_spec_with_inner_label_and_date_stays_flat_internally() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {500.00 USD, \"lot1\", 2024-01-15}\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let cs = cost_specs(&tree);
assert_eq!(cs.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&cs[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.contains(&NUMBER));
assert!(kinds.contains(&CURRENCY));
assert!(kinds.contains(&STRING));
assert!(kinds.contains(&DATE));
assert!(kinds.contains(&L_BRACE));
assert!(kinds.contains(&R_BRACE));
}
#[test]
fn amount_wraps_number_and_currency_with_no_whitespace_between() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 1USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let amts = amounts(&tree);
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[NUMBER, CURRENCY]),
"AMOUNT wraps both NUMBER and adjacent CURRENCY (no WS between)",
);
}
#[test]
fn price_annotation_with_negative_amount_wraps_sign_inside_nested_amount() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL @ -5.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let prices = price_annotations(&tree);
assert_eq!(prices.len(), 1);
let inner_amount = prices[0].children().find(|n| n.kind() == AMOUNT).unwrap();
assert_eq!(
elements_of(&inner_amount),
tok_seq(&[MINUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn cost_spec_empty_braces_wraps_open_close_pair_only() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {}\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let cs = cost_specs(&tree);
assert_eq!(cs.len(), 1);
assert_eq!(elements_of(&cs[0]), tok_seq(&[L_BRACE, R_BRACE]));
}
#[test]
fn cost_spec_with_merge_star_keeps_star_inside_node() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL {*}\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let cs = cost_specs(&tree);
assert_eq!(cs.len(), 1);
assert_eq!(elements_of(&cs[0]), tok_seq(&[L_BRACE, STAR, R_BRACE]));
}
#[test]
fn price_annotation_at_eof_without_newline_still_wraps_opener_only() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL @";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let prices = price_annotations(&tree);
assert_eq!(prices.len(), 1);
assert_eq!(elements_of(&prices[0]), tok_seq(&[AT]));
}
#[test]
fn total_price_annotation_at_at_eof_without_newline_still_wraps_opener_only() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL @@";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let prices = price_annotations(&tree);
assert_eq!(prices.len(), 1);
assert_eq!(elements_of(&prices[0]), tok_seq(&[AT_AT]));
}
#[test]
fn balance_and_price_directive_header_amounts_stay_flat_not_wrapped() {
use SyntaxKind::*;
let source = "2024-06-30 balance Assets:Cash 100.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), BALANCE_DIRECTIVE);
assert_eq!(
amounts(&tree).len(),
0,
"BALANCE header NUMBER + CURRENCY are flat children of BALANCE_DIRECTIVE",
);
let price_source = "2024-01-01 price USD 1.10 EUR\n";
let price_tree = parse_structured(price_source);
assert_round_trip(price_source, &price_tree);
let price_ds = directives(&price_tree);
assert_eq!(price_ds.len(), 1);
assert_eq!(price_ds[0].kind(), PRICE_DIRECTIVE);
assert_eq!(
amounts(&price_tree).len(),
0,
"PRICE header NUMBER + CURRENCY are flat children of PRICE_DIRECTIVE",
);
}
#[test]
fn amount_wraps_arithmetic_no_spaces() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10+5 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[NUMBER, PLUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn amount_wraps_arithmetic_with_spaces_around_op() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 100 + 5 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[
NUMBER, WHITESPACE, PLUS, WHITESPACE, NUMBER, WHITESPACE, CURRENCY
]),
);
}
#[test]
fn amount_wraps_signed_arithmetic_negative_outer() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -10+5 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[MINUS, NUMBER, PLUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn amount_wraps_parenthesized_subexpression() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -(10+5) USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[
MINUS, L_PAREN, NUMBER, PLUS, NUMBER, R_PAREN, WHITESPACE, CURRENCY
]),
);
}
#[test]
fn amount_wraps_multiplication_and_division() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10*2/4 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[NUMBER, STAR, NUMBER, SLASH, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn amount_wraps_nested_parens_via_depth_tracking() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash ((1+2)) USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[
L_PAREN, L_PAREN, NUMBER, PLUS, NUMBER, R_PAREN, R_PAREN, WHITESPACE, CURRENCY
]),
);
}
#[test]
fn amount_wraps_real_corpus_arithmetic_shape() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Bancos:BB 700.00 * 0.1 BRL\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
assert_eq!(
elements_of(&amts[0]),
tok_seq(&[
NUMBER, WHITESPACE, STAR, WHITESPACE, NUMBER, WHITESPACE, CURRENCY
]),
);
}
#[test]
fn price_annotation_inner_amount_wraps_arithmetic() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash 10 HOOL @ 5+1 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
let prices: Vec<SyntaxNode> = ps[0]
.children()
.filter(|n| n.kind() == PRICE_ANNOTATION)
.collect();
assert_eq!(prices.len(), 1);
let inner_amount = prices[0].children().find(|n| n.kind() == AMOUNT).unwrap();
assert_eq!(
elements_of(&inner_amount),
tok_seq(&[NUMBER, PLUS, NUMBER, WHITESPACE, CURRENCY]),
);
}
#[test]
fn amount_with_unclosed_paren_at_newline_stops_per_rule_5() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash (10+5 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 1);
let amts: Vec<SyntaxNode> = ps[0].children().filter(|n| n.kind() == AMOUNT).collect();
assert_eq!(amts.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&amts[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.contains(&L_PAREN));
assert!(!kinds.contains(&R_PAREN));
let posting_kids = elements_of(&ps[0]);
let newline_after_amount = posting_kids
.iter()
.position(|e| matches!(e, Element::Tok(NEWLINE)));
let amount_idx = posting_kids
.iter()
.position(|e| matches!(e, Element::Node(AMOUNT)))
.unwrap();
assert!(
newline_after_amount.is_some_and(|n| n > amount_idx),
"NEWLINE terminator follows the AMOUNT, not consumed inside it",
);
}
#[test]
fn mixed_shape_sibling_postings_each_wrap_their_own_amount_or_not() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash\n\
\x20\x20Assets:Bank -5.00 USD\n\
\x20\x20Income:Misc 10 HOOL {500.00 USD} @ 510.00 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ps = postings(&tree);
assert_eq!(ps.len(), 3);
let p0_amounts = ps[0].children().filter(|n| n.kind() == AMOUNT).count();
let p0_costs = ps[0].children().filter(|n| n.kind() == COST_SPEC).count();
let p0_prices = ps[0]
.children()
.filter(|n| n.kind() == PRICE_ANNOTATION)
.count();
assert_eq!(p0_amounts, 0);
assert_eq!(p0_costs, 0);
assert_eq!(p0_prices, 0);
let p1_amounts = ps[1].children().filter(|n| n.kind() == AMOUNT).count();
let p1_costs = ps[1].children().filter(|n| n.kind() == COST_SPEC).count();
let p1_prices = ps[1]
.children()
.filter(|n| n.kind() == PRICE_ANNOTATION)
.count();
assert_eq!(p1_amounts, 1);
assert_eq!(p1_costs, 0);
assert_eq!(p1_prices, 0);
let p2_amounts = ps[2].children().filter(|n| n.kind() == AMOUNT).count();
let p2_costs = ps[2].children().filter(|n| n.kind() == COST_SPEC).count();
let p2_prices = ps[2]
.children()
.filter(|n| n.kind() == PRICE_ANNOTATION)
.count();
assert_eq!(p2_amounts, 1);
assert_eq!(p2_costs, 1);
assert_eq!(p2_prices, 1);
}
#[test]
fn commodity_with_metadata_wraps_full_multi_line_directive() {
use SyntaxKind::*;
let source = "2024-01-01 commodity HOOL\n name: \"Hooli Common shares.\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), COMMODITY_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
vec![
Element::Tok(DATE),
Element::Tok(WHITESPACE),
Element::Tok(COMMODITY_KW),
Element::Tok(WHITESPACE),
Element::Tok(CURRENCY),
Element::Tok(NEWLINE),
Element::Node(META_ENTRY),
],
);
let me = ds[0]
.children()
.find(|n| n.kind() == META_ENTRY)
.expect("directive contains a META_ENTRY child");
assert_eq!(
elements_of(&me),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING, NEWLINE]),
);
assert_eq!(elements_of(&tree), vec![Element::Node(COMMODITY_DIRECTIVE)]);
}
#[test]
fn open_with_multiple_metadata_lines_wraps_all_inside_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash USD\n\
\x20\x20description: \"main checking\"\n\
\x20\x20priority: \"high\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(elements_of(&tree), vec![Element::Node(OPEN_DIRECTIVE)]);
}
#[test]
fn directive_with_metadata_then_next_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash USD\n\
\x20\x20description: \"main\"\n\
2024-01-02 close Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(ds[1].kind(), CLOSE_DIRECTIVE);
}
#[test]
fn indented_comment_after_no_metadata_directive_leads_next_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20; documentation for the next directive\n\
2024-01-02 close Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(ds[1].kind(), CLOSE_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT, NEWLINE]),
"rule 2: indented comment after header-only directive must NOT be absorbed; \
it's inter-directive trivia leading the next directive",
);
let d2_first = elements_of(&ds[1])
.iter()
.take_while(|e| !matches!(e, Element::Tok(DATE)))
.copied()
.collect::<Vec<_>>();
assert_eq!(
d2_first,
tok_seq(&[WHITESPACE, COMMENT, NEWLINE]),
"rule 2: leading trivia of d2 must include the inter-directive comment",
);
}
#[test]
fn indented_comment_at_eof_after_no_metadata_directive_is_file_trailing() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20; trailing indented comment\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[DATE, WHITESPACE, OPEN_KW, WHITESPACE, ACCOUNT, NEWLINE]),
"directive owns ONLY its header + terminator NEWLINE",
);
assert_eq!(
elements_of(&tree),
vec![
Element::Node(OPEN_DIRECTIVE),
Element::Tok(WHITESPACE),
Element::Tok(COMMENT),
Element::Tok(NEWLINE),
],
"rule 4: indented trailing comment is file-trailing under SOURCE_FILE",
);
}
#[test]
fn indented_comment_before_first_metadata_stays_inside_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20; documentation for the next field\n\
\x20\x20description: \"main checking\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(elements_of(&tree), vec![Element::Node(OPEN_DIRECTIVE)]);
let sf_token_kinds: Vec<SyntaxKind> = elements_of(&tree)
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(
!sf_token_kinds.contains(&META_KEY),
"META_KEY orphaned to SOURCE_FILE: {sf_token_kinds:?}",
);
}
#[test]
fn indented_comment_between_metadata_lines_stays_inside_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\x20\x20k1: \"v1\"\n\
\x20\x20; doc comment for k2\n\
\x20\x20k2: \"v2\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(elements_of(&tree), vec![Element::Node(OPEN_DIRECTIVE)]);
let sf_children: Vec<SyntaxKind> = elements_of(&tree)
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(
!sf_children.contains(&META_KEY),
"META_KEY orphaned to SOURCE_FILE: {sf_children:?}",
);
}
#[test]
fn blank_line_between_metadata_lines_terminates_directive() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash USD\n\
\x20\x20description: \"main\"\n\
\n\
2024-01-02 close Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].kind(), OPEN_DIRECTIVE);
assert_eq!(ds[1].kind(), CLOSE_DIRECTIVE);
let d2_first = elements_of(&ds[1]).first().copied();
assert_eq!(d2_first, Some(Element::Tok(NEWLINE)));
}
#[test]
fn malformed_date_then_keyword_on_next_line_is_not_a_directive() {
let source = "2024-01-01\nopen Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert!(
ds.is_empty(),
"DATE alone on a line is malformed; identifier must not pretend it starts an OPEN_DIRECTIVE just because the next non-trivia token (skipping the NEWLINE) happens to be OPEN_KW",
);
}
#[test]
fn recognized_and_passthrough_can_coexist() {
use SyntaxKind::*;
let source = "option \"title\" \"My Ledger\"\n\
2024-01-01 open Assets:Cash\n\
2024-01-15 * \"Coffee\"\n\
2024-01-16 close Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 4);
assert_eq!(ds[0].kind(), OPTION_DIRECTIVE);
assert_eq!(ds[1].kind(), OPEN_DIRECTIVE);
assert_eq!(ds[2].kind(), TRANSACTION);
assert_eq!(ds[3].kind(), CLOSE_DIRECTIVE);
}
#[test]
fn option_directive() {
use SyntaxKind::*;
let source = "option \"title\" \"My Ledger\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPTION_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[OPTION_KW, WHITESPACE, STRING, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn include_directive() {
use SyntaxKind::*;
let source = "include \"shared/2024.beancount\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), INCLUDE_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[INCLUDE_KW, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn plugin_directive_without_config() {
use SyntaxKind::*;
let source = "plugin \"beancount.plugins.implicit_prices\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PLUGIN_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[PLUGIN_KW, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn plugin_directive_with_config_string() {
use SyntaxKind::*;
let source = "plugin \"my.plugin\" \"{\\\"key\\\": 42}\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PLUGIN_DIRECTIVE);
assert_eq!(
elements_of(&ds[0]),
tok_seq(&[PLUGIN_KW, WHITESPACE, STRING, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn custom_directive_with_string_values() {
use SyntaxKind::*;
let source = "2024-01-01 custom \"budget\" \"Food\" \"500 USD\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), CUSTOM_DIRECTIVE);
let kinds: Vec<SyntaxKind> = elements_of(&ds[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.contains(&DATE));
assert!(kinds.contains(&CUSTOM_KW));
assert_eq!(kinds.iter().filter(|&&k| k == STRING).count(), 3);
}
#[test]
fn custom_directive_with_mixed_value_types() {
use SyntaxKind::*;
let source = "2024-01-01 custom \"limits\" \"cap\" Assets:Cash 500 USD 2024-12-31 TRUE\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), CUSTOM_DIRECTIVE);
let amount_count = ds[0].descendants().filter(|n| n.kind() == AMOUNT).count();
assert_eq!(amount_count, 0);
}
#[test]
fn option_directive_with_metadata_wraps_multi_line() {
use SyntaxKind::*;
let source = "option \"title\" \"My Ledger\"\n\
\x20\x20doc: \"primary file\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPTION_DIRECTIVE);
let metas = ds[0]
.descendants()
.filter(|n| n.kind() == META_ENTRY)
.count();
assert_eq!(metas, 1);
}
#[test]
fn plugin_directive_terminates_at_next_top_level() {
use SyntaxKind::*;
let source = "plugin \"a\"\n\
include \"b.bean\"\n\
option \"c\" \"d\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 3);
assert_eq!(ds[0].kind(), PLUGIN_DIRECTIVE);
assert_eq!(ds[1].kind(), INCLUDE_DIRECTIVE);
assert_eq!(ds[2].kind(), OPTION_DIRECTIVE);
}
#[test]
fn custom_directive_unterminated_at_eof_still_wraps_per_rule_5() {
use SyntaxKind::*;
let source = "2024-01-01 custom \"type\" \"value\"";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), CUSTOM_DIRECTIVE);
let has_trailing_newline = elements_of(&ds[0])
.iter()
.any(|e| matches!(e, Element::Tok(NEWLINE)));
assert!(!has_trailing_newline);
}
#[test]
fn include_directive_with_metadata_wraps_multi_line() {
use SyntaxKind::*;
let source = "include \"shared/2024.beancount\"\n\
\x20\x20note: \"shared accounts\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), INCLUDE_DIRECTIVE);
let mes: Vec<SyntaxNode> = ds[0]
.descendants()
.filter(|n| n.kind() == META_ENTRY)
.collect();
assert_eq!(mes.len(), 1);
assert_eq!(
elements_of(&mes[0]),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn plugin_directive_with_metadata_wraps_multi_line() {
use SyntaxKind::*;
let source = "plugin \"my.plugin\" \"cfg\"\n\
\x20\x20tolerance: \"0.01\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PLUGIN_DIRECTIVE);
let mes: Vec<SyntaxNode> = ds[0]
.descendants()
.filter(|n| n.kind() == META_ENTRY)
.collect();
assert_eq!(mes.len(), 1);
assert_eq!(
elements_of(&mes[0]),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING, NEWLINE]),
);
}
#[test]
fn custom_directive_with_metadata_wraps_multi_line() {
use SyntaxKind::*;
let source = "2024-01-01 custom \"budget\" \"food\"\n\
\x20\x20source: \"manual\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), CUSTOM_DIRECTIVE);
let mes: Vec<SyntaxNode> = ds[0]
.descendants()
.filter(|n| n.kind() == META_ENTRY)
.collect();
assert_eq!(mes.len(), 1);
assert_eq!(
elements_of(&mes[0]),
tok_seq(&[WHITESPACE, META_KEY, WHITESPACE, STRING, NEWLINE]),
);
}
fn assert_directive_has_trailing_comment_before_newline(directive: &SyntaxNode) {
use SyntaxKind::*;
let kids = elements_of(directive);
let comment_idx = kids
.iter()
.position(|e| matches!(e, Element::Tok(COMMENT)))
.expect("directive must contain a COMMENT child");
let newline_idx = kids
.iter()
.position(|e| matches!(e, Element::Tok(NEWLINE)))
.expect("directive must contain its NEWLINE terminator");
assert!(
comment_idx < newline_idx,
"trailing same-line COMMENT must precede the NEWLINE inside the directive",
);
let comment_count = kids
.iter()
.filter(|e| matches!(e, Element::Tok(COMMENT)))
.count();
assert_eq!(comment_count, 1);
}
#[test]
fn option_directive_with_trailing_inline_comment_attaches_inside() {
use SyntaxKind::*;
let source = "option \"title\" \"My Ledger\" ; an explanation\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), OPTION_DIRECTIVE);
assert_directive_has_trailing_comment_before_newline(&ds[0]);
}
#[test]
fn include_directive_with_trailing_inline_comment_attaches_inside() {
use SyntaxKind::*;
let source = "include \"shared.beancount\" ; main shared file\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), INCLUDE_DIRECTIVE);
assert_directive_has_trailing_comment_before_newline(&ds[0]);
}
#[test]
fn plugin_directive_with_trailing_inline_comment_attaches_inside() {
use SyntaxKind::*;
let source = "plugin \"my.plugin\" ; description\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), PLUGIN_DIRECTIVE);
assert_directive_has_trailing_comment_before_newline(&ds[0]);
}
#[test]
fn custom_directive_with_trailing_inline_comment_attaches_inside() {
use SyntaxKind::*;
let source = "2024-01-01 custom \"budget\" \"food\" ; monthly cap\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].kind(), CUSTOM_DIRECTIVE);
assert_directive_has_trailing_comment_before_newline(&ds[0]);
}
#[test]
fn all_four_edge_directives_mixed_with_dated_directives() {
use SyntaxKind::*;
let source = "option \"title\" \"X\"\n\
include \"shared.bean\"\n\
plugin \"my.plugin\"\n\
2024-01-01 open Assets:Cash\n\
2024-01-15 * \"tx\"\n\
\x20\x20Assets:Cash 1 USD\n\
2024-01-20 custom \"note\" \"end of test\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 6);
assert_eq!(ds[0].kind(), OPTION_DIRECTIVE);
assert_eq!(ds[1].kind(), INCLUDE_DIRECTIVE);
assert_eq!(ds[2].kind(), PLUGIN_DIRECTIVE);
assert_eq!(ds[3].kind(), OPEN_DIRECTIVE);
assert_eq!(ds[4].kind(), TRANSACTION);
assert_eq!(ds[5].kind(), CUSTOM_DIRECTIVE);
}
fn error_nodes(node: &SyntaxNode) -> Vec<SyntaxNode> {
node.descendants()
.filter(|n| n.kind() == SyntaxKind::ERROR_NODE)
.collect()
}
#[test]
fn unknown_keyword_line_wraps_in_error_node() {
use SyntaxKind::*;
let source = "bogus \"x\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let errs = error_nodes(&tree);
assert_eq!(errs.len(), 1);
let kinds: Vec<SyntaxKind> = elements_of(&errs[0])
.iter()
.filter_map(|e| match e {
Element::Tok(k) => Some(*k),
Element::Node(_) => None,
})
.collect();
assert!(kinds.contains(&STRING));
assert!(kinds.contains(&NEWLINE));
}
#[test]
fn unrecognized_dated_keyword_wraps_in_error_node() {
let source = "2024-01-01 zzz \"data\"\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let errs = error_nodes(&tree);
assert_eq!(errs.len(), 1);
let ds = directives(&tree);
assert_eq!(ds.len(), 0, "no real directive recognized");
}
#[test]
fn error_node_coexists_with_recognized_directives() {
use SyntaxKind::*;
let source = "option \"title\" \"X\"\n\
bogus line\n\
2024-01-01 open Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let errs = error_nodes(&tree);
assert_eq!(errs.len(), 1);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].kind(), OPTION_DIRECTIVE);
assert_eq!(ds[1].kind(), OPEN_DIRECTIVE);
}
#[test]
fn error_node_unterminated_at_eof_still_wraps_per_rule_5() {
use SyntaxKind::*;
let source = "bogus content without newline";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let errs = error_nodes(&tree);
assert_eq!(errs.len(), 1);
let has_newline = elements_of(&errs[0])
.iter()
.any(|e| matches!(e, Element::Tok(NEWLINE)));
assert!(!has_newline);
}
#[test]
fn multiple_consecutive_error_lines_each_get_their_own_error_node() {
let source = "bogus one\n\
zzz two\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let errs = error_nodes(&tree);
assert_eq!(errs.len(), 2);
}
#[test]
fn error_node_leading_trivia_attaches_inside_per_rule_2() {
use SyntaxKind::*;
let source = "2024-01-01 open Assets:Cash\n\
\n\
bogus content\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let errs = error_nodes(&tree);
assert_eq!(errs.len(), 1);
let first = elements_of(&errs[0]).first().copied();
assert_eq!(first, Some(Element::Tok(NEWLINE)));
}
#[test]
fn error_node_adjacent_to_multi_line_transaction_doesnt_bleed() {
use SyntaxKind::*;
let source = "2024-01-15 * \"x\"\n\
\x20\x20Assets:Cash -5 USD\n\
\x20\x20Expenses:Food 5 USD\n\
bogus content here\n\
2024-01-16 * \"y\"\n\
\x20\x20Assets:Cash -3 USD\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
let ds = directives(&tree);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].kind(), TRANSACTION);
assert_eq!(ds[1].kind(), TRANSACTION);
let errs = error_nodes(&tree);
assert_eq!(
errs.len(),
1,
"exactly one ERROR_NODE between the two transactions"
);
let tx_inner_errors = ds[0]
.descendants()
.filter(|n| n.kind() == ERROR_NODE)
.count()
+ ds[1]
.descendants()
.filter(|n| n.kind() == ERROR_NODE)
.count();
assert_eq!(tx_inner_errors, 0);
let p0_count = ds[0].descendants().filter(|n| n.kind() == POSTING).count();
let p1_count = ds[1].descendants().filter(|n| n.kind() == POSTING).count();
assert_eq!(p0_count, 2);
assert_eq!(p1_count, 1);
}
#[test]
fn empty_source() {
let tree = parse_structured("");
assert_round_trip("", &tree);
assert_eq!(tree.kind(), SyntaxKind::SOURCE_FILE);
assert!(directives(&tree).is_empty());
}
#[test]
fn only_trivia_no_directives() {
use SyntaxKind::*;
let source = ";; only a comment\n\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
assert!(directives(&tree).is_empty());
assert_eq!(
elements_of(&tree),
vec![
Element::Tok(COMMENT),
Element::Tok(NEWLINE),
Element::Tok(NEWLINE)
],
);
}
#[test]
fn bom_under_source_file_directive_follows() {
use SyntaxKind::*;
let source = "\u{FEFF}2024-01-01 open Assets:Cash\n";
let tree = parse_structured(source);
assert_round_trip(source, &tree);
assert_eq!(
elements_of(&tree),
vec![Element::Tok(BOM), Element::Node(OPEN_DIRECTIVE)],
);
}