Skip to main content

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    ModalResult, Parser,
24    combinator::{alt, cut_err, dispatch, fail, peek, preceded, trace},
25    error::StrContext,
26    stream::{Stream, StreamIsPartial},
27    token::{any, literal},
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
39        .parse_repeated(parse_ledger_statement, character::newlines.void(), input)
40        .enumerate()
41        .map(|(i, parsed)| {
42            parsed.map(|(ctx, statement)| {
43                // The first entry never gets a blank line before it, regardless
44                // of the leading newlines the file may have.
45                let separation = if i > 0 && ctx.has_blank_line_before() {
46                    syntax::Separation::BlankLine
47                } else {
48                    syntax::Separation::Immediate
49                };
50                (
51                    ctx,
52                    syntax::LedgerEntry {
53                        separation,
54                        statement,
55                    },
56                )
57            })
58        })
59}
60
61/// Parses given `input` into [syntax::LedgerStatement].
62fn parse_ledger_statement<'i, I, Deco>(
63    input: &mut I,
64) -> ModalResult<syntax::LedgerStatement<'i, Deco>>
65where
66    I: Stream<Token = char, Slice = &'i str>
67        + StreamIsPartial
68        + winnow::stream::Compare<&'static str>
69        + winnow::stream::FindSlice<(char, char)>
70        + winnow::stream::Location
71        + Clone,
72    Deco: Decoration + 'static,
73{
74    trace(
75        "parse_ledger_statement",
76        dispatch! {peek(any);
77            'a' => alt((
78                preceded(
79                    peek(literal("account")),
80                    cut_err(directive::account_declaration.map(syntax::LedgerStatement::Account)),
81                ),
82                preceded(
83                    peek(literal("apply")),
84                    cut_err(directive::apply_tag.map(syntax::LedgerStatement::ApplyTag)),
85                ),
86            )),
87            'c' => directive::commodity_declaration.map(syntax::LedgerStatement::Commodity),
88            'e' => directive::end_apply_tag.map(|_| syntax::LedgerStatement::EndApplyTag),
89            'i' => directive::include.map(syntax::LedgerStatement::Include),
90            c if directive::is_comment_prefix(c) => {
91                directive::top_comment.map(syntax::LedgerStatement::Comment)
92            },
93            c if c.is_ascii_digit() => transaction::transaction.map(syntax::LedgerStatement::Txn),
94            _ => fail.context(StrContext::Label("no matching syntax")),
95        },
96    )
97    .parse_next(input)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    use chrono::NaiveDate;
105    use indoc::indoc;
106    use pretty_assertions::assert_eq;
107
108    use syntax::plain::LedgerEntry;
109    use syntax::{LedgerStatement, Separation};
110
111    fn parse_ledger_into(input: &'_ str) -> Vec<(ParsedContext<'_>, LedgerEntry<'_>)> {
112        let r: Result<Vec<(ParsedContext, LedgerEntry)>, ParseError> =
113            parse_ledger(&ParseOptions::default(), input).collect();
114        match r {
115            Ok(x) => x,
116            Err(e) => panic!("failed to parse:\n{}", e),
117        }
118    }
119
120    /// Returns the [`Separation`] of every parsed entry.
121    fn parse_ledger_separations(input: &str) -> Vec<Separation> {
122        parse_ledger_into(input)
123            .iter()
124            .map(|(_, entry)| entry.separation)
125            .collect()
126    }
127
128    #[test]
129    fn parse_ledger_skips_empty_lines() {
130        let input = "\n\n2022/01/23\n";
131        assert_eq!(input.chars().next(), Some('\n'));
132        assert_eq!(
133            parse_ledger_into(input),
134            vec![(
135                ParsedContext {
136                    initial: input,
137                    span: 2..13,
138                    separator: 0..2,
139                },
140                LedgerEntry {
141                    // Leading newlines must not turn into a leading blank line.
142                    separation: Separation::Immediate,
143                    statement: LedgerStatement::Txn(syntax::Transaction::new(
144                        NaiveDate::from_ymd_opt(2022, 1, 23).unwrap(),
145                        ""
146                    )),
147                }
148            )]
149        );
150    }
151
152    #[test]
153    fn parse_ledger_two_contiguous_transactions() {
154        let input = indoc! {"
155            2024/4/10 Migros
156                Expenses:Grocery
157            2024/4/20 Coop
158                Expenses:Grocery
159        "};
160
161        assert_eq!(
162            parse_ledger_into(input),
163            vec![
164                (
165                    ParsedContext {
166                        initial: input,
167                        span: 0..38,
168                        separator: 0..0,
169                    },
170                    LedgerEntry {
171                        separation: Separation::Immediate,
172                        statement: LedgerStatement::Txn(syntax::Transaction {
173                            posts: vec![syntax::Posting::new_untracked("Expenses:Grocery")],
174                            ..syntax::Transaction::new(
175                                NaiveDate::from_ymd_opt(2024, 4, 10).unwrap(),
176                                "Migros",
177                            )
178                        }),
179                    }
180                ),
181                (
182                    ParsedContext {
183                        initial: input,
184                        span: 38..74,
185                        separator: 38..38,
186                    },
187                    LedgerEntry {
188                        separation: Separation::Immediate,
189                        statement: LedgerStatement::Txn(syntax::Transaction {
190                            posts: vec![syntax::Posting::new_untracked("Expenses:Grocery")],
191                            ..syntax::Transaction::new(
192                                NaiveDate::from_ymd_opt(2024, 4, 20).unwrap(),
193                                "Coop"
194                            )
195                        }),
196                    }
197                )
198            ]
199        )
200    }
201
202    #[test]
203    fn parse_ledger_tracks_separation() {
204        let input = indoc! {"
205            ; comment right above the transaction
206            2024/4/10 Migros
207                Expenses:Grocery
208
209            include other.ledger
210            include another.ledger
211
212
213
214            end apply tag
215        "};
216
217        assert_eq!(
218            parse_ledger_separations(input),
219            vec![
220                // Leading comment, first entry of the file.
221                Separation::Immediate,
222                // Transaction glued to the comment above it.
223                Separation::Immediate,
224                Separation::BlankLine,
225                // Consecutive include directives stay grouped.
226                Separation::Immediate,
227                // Several blank lines are still just one separation.
228                Separation::BlankLine,
229            ]
230        );
231    }
232}