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 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
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
39 .parse_repeated(parse_ledger_statement, character::newlines.void(), input)
40 .enumerate()
41 .map(|(i, parsed)| {
42 parsed.map(|(ctx, statement)| {
43 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
61fn 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 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 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 Separation::Immediate,
222 Separation::Immediate,
224 Separation::BlankLine,
225 Separation::Immediate,
227 Separation::BlankLine,
229 ]
230 );
231 }
232}