1mod adaptor;
5mod character;
6mod combinator;
7mod directive;
8mod error;
9mod expr;
10mod metadata;
11mod posting;
12pub(crate) mod price;
13mod primitive;
14pub(crate) mod transaction;
15
16#[cfg(test)]
17pub(crate) mod testing;
18
19pub use adaptor::{ParseOptions, ParsedContext, ParsedSpan};
20pub use error::ParseError;
21
22use winnow::{
23 combinator::{alt, cut_err, dispatch, fail, peek, preceded, trace},
24 error::StrContext,
25 stream::{Stream, StreamIsPartial},
26 token::{any, literal},
27 ModalResult, Parser,
28};
29
30use crate::syntax::{self, decoration::Decoration};
31
32pub fn parse_ledger<'i, Deco: 'i + Decoration>(
35 options: &ParseOptions,
36 input: &'i str,
37) -> impl Iterator<Item = Result<(ParsedContext<'i>, syntax::LedgerEntry<'i, Deco>), ParseError>> {
38 options.parse_repeated(parse_ledger_entry, character::newlines.void(), input)
39}
40
41fn parse_ledger_entry<'i, I, Deco>(input: &mut I) -> ModalResult<syntax::LedgerEntry<'i, Deco>>
43where
44 I: Stream<Token = char, Slice = &'i str>
45 + StreamIsPartial
46 + winnow::stream::Compare<&'static str>
47 + winnow::stream::FindSlice<(char, char)>
48 + winnow::stream::Location
49 + Clone,
50 Deco: Decoration + 'static,
51{
52 trace(
53 "parse_ledger_entry",
54 dispatch! {peek(any);
55 'a' => alt((
56 preceded(
57 peek(literal("account")),
58 cut_err(directive::account_declaration.map(syntax::LedgerEntry::Account)),
59 ),
60 preceded(
61 peek(literal("apply")),
62 cut_err(directive::apply_tag.map(syntax::LedgerEntry::ApplyTag)),
63 ),
64 )),
65 'c' => directive::commodity_declaration.map(syntax::LedgerEntry::Commodity),
66 'e' => directive::end_apply_tag.map(|_| syntax::LedgerEntry::EndApplyTag),
67 'i' => directive::include.map(syntax::LedgerEntry::Include),
68 c if directive::is_comment_prefix(c) => {
69 directive::top_comment.map(syntax::LedgerEntry::Comment)
70 },
71 c if c.is_ascii_digit() => transaction::transaction.map(syntax::LedgerEntry::Txn),
72 _ => fail.context(StrContext::Label("no matching syntax")),
73 },
74 )
75 .parse_next(input)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 use chrono::NaiveDate;
83 use indoc::indoc;
84 use pretty_assertions::assert_eq;
85
86 use syntax::plain::LedgerEntry;
87
88 fn parse_ledger_into(input: &'_ str) -> Vec<(ParsedContext<'_>, LedgerEntry<'_>)> {
89 let r: Result<Vec<(ParsedContext, LedgerEntry)>, ParseError> =
90 parse_ledger(&ParseOptions::default(), input).collect();
91 match r {
92 Ok(x) => x,
93 Err(e) => panic!("failed to parse:\n{}", e),
94 }
95 }
96
97 #[test]
98 fn parse_ledger_skips_empty_lines() {
99 let input = "\n\n2022/01/23\n";
100 assert_eq!(input.chars().next(), Some('\n'));
101 assert_eq!(
102 parse_ledger_into(input),
103 vec![(
104 ParsedContext {
105 initial: input,
106 span: 2..13
107 },
108 syntax::LedgerEntry::Txn(syntax::Transaction::new(
109 NaiveDate::from_ymd_opt(2022, 1, 23).unwrap(),
110 ""
111 ))
112 )]
113 );
114 }
115
116 #[test]
117 fn parse_ledger_two_contiguous_transactions() {
118 let input = indoc! {"
119 2024/4/10 Migros
120 Expenses:Grocery
121 2024/4/20 Coop
122 Expenses:Grocery
123 "};
124
125 assert_eq!(
126 parse_ledger_into(input),
127 vec![
128 (
129 ParsedContext {
130 initial: input,
131 span: 0..38
132 },
133 syntax::LedgerEntry::Txn(syntax::Transaction {
134 posts: vec![syntax::Posting::new_untracked("Expenses:Grocery")],
135 ..syntax::Transaction::new(
136 NaiveDate::from_ymd_opt(2024, 4, 10).unwrap(),
137 "Migros",
138 )
139 })
140 ),
141 (
142 ParsedContext {
143 initial: input,
144 span: 38..74
145 },
146 syntax::LedgerEntry::Txn(syntax::Transaction {
147 posts: vec![syntax::Posting::new_untracked("Expenses:Grocery")],
148 ..syntax::Transaction::new(
149 NaiveDate::from_ymd_opt(2024, 4, 20).unwrap(),
150 "Coop"
151 )
152 })
153 )
154 ]
155 )
156 }
157}