okane_core/
parse.rs

1//! Defines parser for the Ledger format.
2//! Currently only the parser for the entire file format is provieded as public.
3
4mod 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
32/// Parses Ledger `str` containing a list of [`syntax::LedgerEntry`] into iterator.
33/// See [`ParseOptions`] to control its behavior.
34pub 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
41/// Parses given `input` into [syntax::LedgerEntry].
42fn 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}