1#![cfg_attr(docsrs, feature(doc_auto_cfg))]
2
3use std::{collections::HashSet, fs::File, io::Read, path::PathBuf, str::FromStr};
45
46use nom::{
47 branch::alt,
48 bytes::complete::{tag, take_while},
49 character::complete::{char, line_ending, not_line_ending, space0, space1},
50 combinator::{all_consuming, cut, eof, iterator, map, not, opt, value},
51 sequence::{delimited, preceded, terminated, tuple},
52 Finish, Parser,
53};
54use nom_locate::position;
55
56use crate::iterator::Iter;
57pub use crate::{
58 account::{Account, Balance, Close, Open, Pad},
59 amount::{Amount, Currency, Decimal, Price},
60 date::Date,
61 error::{ConversionError, Error, ReadFileError},
62 event::Event,
63 transaction::{Cost, Link, Posting, PostingPrice, Tag, Transaction},
64};
65
66#[deprecated(note = "use `metadata::Value` instead", since = "1.0.0-beta.3")]
67#[doc(hidden)]
68pub type MetadataValue<D> = metadata::Value<D>;
69
70mod account;
71mod amount;
72mod date;
73mod error;
74mod event;
75mod iterator;
76pub mod metadata;
77mod transaction;
78
79pub fn parse<D: Decimal>(input: &str) -> Result<BeancountFile<D>, Error> {
89 input.parse()
90}
91
92pub fn parse_iter<'a, D: Decimal + 'a>(
102 input: &'a str,
103) -> impl Iterator<Item = Result<Entry<D>, Error>> + 'a {
104 Iter::new(input, iterator(Span::new(input), entry::<D>))
105}
106
107impl<D: Decimal> FromStr for BeancountFile<D> {
108 type Err = Error;
109 fn from_str(input: &str) -> Result<Self, Self::Err> {
110 parse_iter(input).collect()
111 }
112}
113
114pub fn read_files<D: Decimal, F: FnMut(Entry<D>)>(
123 files: impl IntoIterator<Item = PathBuf>,
124 mut on_entry: F,
125) -> Result<(), ReadFileError> {
126 let mut loaded: HashSet<PathBuf> = HashSet::new();
127 let mut pending: Vec<PathBuf> = files
128 .into_iter()
129 .map(|p| p.canonicalize())
130 .collect::<Result<_, _>>()?;
131 let mut buffer = String::new();
132 while let Some(path) = pending.pop() {
133 if loaded.contains(&path) {
134 continue;
135 }
136 loaded.insert(path.clone());
137 buffer.clear();
138 File::open(&path)?.read_to_string(&mut buffer)?;
139 for result in parse_iter::<D>(&buffer) {
140 let entry = result?;
141 match entry {
142 Entry::Include(include) => {
143 let path = if include.is_relative() {
144 let Some(parent) = path.parent() else {
145 unreachable!("there must be a parent if the file was valid")
146 };
147 parent.join(include)
148 } else {
149 include
150 };
151 let path = path.canonicalize()?;
152 if !loaded.contains(&path) {
153 pending.push(path);
154 }
155 }
156 entry => on_entry(entry),
157 }
158 }
159 }
160 Ok(())
161}
162
163#[derive(Debug, Clone)]
169#[non_exhaustive]
170pub struct BeancountFile<D> {
171 pub options: Vec<BeanOption>,
175 pub includes: Vec<PathBuf>,
179 pub directives: Vec<Directive<D>>,
181}
182
183impl<D> Default for BeancountFile<D> {
184 fn default() -> Self {
185 Self {
186 options: Vec::new(),
187 includes: Vec::new(),
188 directives: Vec::new(),
189 }
190 }
191}
192
193impl<D> BeancountFile<D> {
194 #[must_use]
217 pub fn option(&self, key: &str) -> Option<&str> {
218 self.options
219 .iter()
220 .find(|opt| opt.name == key)
221 .map(|opt| &opt.value[..])
222 }
223}
224
225impl<D> Extend<Entry<D>> for BeancountFile<D> {
226 fn extend<T: IntoIterator<Item = Entry<D>>>(&mut self, iter: T) {
227 for entry in iter {
228 match entry {
229 Entry::Directive(d) => self.directives.push(d),
230 Entry::Option(o) => self.options.push(o),
231 Entry::Include(p) => self.includes.push(p),
232 }
233 }
234 }
235}
236
237impl<D> FromIterator<Entry<D>> for BeancountFile<D> {
238 fn from_iter<T: IntoIterator<Item = Entry<D>>>(iter: T) -> Self {
239 let mut file = BeancountFile::default();
240 file.extend(iter);
241 file
242 }
243}
244
245#[derive(Debug, Clone, PartialEq)]
271#[non_exhaustive]
272pub struct Directive<D> {
273 pub date: Date,
275 pub content: DirectiveContent<D>,
277 pub metadata: metadata::Map<D>,
281 pub line_number: u32,
283}
284
285impl<D: Decimal> FromStr for Directive<D> {
286 type Err = Error;
287 fn from_str(s: &str) -> Result<Self, Self::Err> {
288 match all_consuming(directive)(Span::new(s)).finish() {
289 Ok((_, d)) => Ok(d),
290 Err(err) => Err(Error::new(s, err.input)),
291 }
292 }
293}
294
295#[allow(missing_docs)]
297#[derive(Debug, Clone, PartialEq)]
298#[non_exhaustive]
299pub enum DirectiveContent<D> {
300 Transaction(Transaction<D>),
301 Price(Price<D>),
302 Balance(Balance<D>),
303 Open(Open),
304 Close(Close),
305 Pad(Pad),
306 Commodity(Currency),
307 Event(Event),
308}
309
310type Span<'a> = nom_locate::LocatedSpan<&'a str>;
311type IResult<'a, O> = nom::IResult<Span<'a>, O>;
312
313#[allow(missing_docs)]
317#[non_exhaustive]
318#[derive(Debug, Clone)]
319pub enum Entry<D> {
320 Directive(Directive<D>),
321 Option(BeanOption),
322 Include(PathBuf),
323}
324
325enum RawEntry<D> {
326 Directive(Directive<D>),
327 Option(BeanOption),
328 Include(PathBuf),
329 PushTag(Tag),
330 PopTag(Tag),
331 Comment,
332}
333
334#[derive(Debug, Clone)]
338#[non_exhaustive]
339pub struct BeanOption {
340 pub name: String,
342 pub value: String,
344}
345
346fn entry<D: Decimal>(input: Span<'_>) -> IResult<'_, RawEntry<D>> {
347 alt((
348 directive.map(RawEntry::Directive),
349 option.map(|(name, value)| RawEntry::Option(BeanOption { name, value })),
350 include.map(|p| RawEntry::Include(p)),
351 tag_stack_operation,
352 line.map(|()| RawEntry::Comment),
353 ))(input)
354}
355
356fn directive<D: Decimal>(input: Span<'_>) -> IResult<'_, Directive<D>> {
357 let (input, position) = position(input)?;
358 let (input, date) = date::parse(input)?;
359 let (input, _) = cut(space1)(input)?;
360 let (input, (content, metadata)) = alt((
361 map(transaction::parse, |(t, m)| {
362 (DirectiveContent::Transaction(t), m)
363 }),
364 tuple((
365 terminated(
366 alt((
367 map(
368 preceded(tag("price"), cut(preceded(space1, amount::price))),
369 DirectiveContent::Price,
370 ),
371 map(
372 preceded(tag("balance"), cut(preceded(space1, account::balance))),
373 DirectiveContent::Balance,
374 ),
375 map(
376 preceded(tag("open"), cut(preceded(space1, account::open))),
377 DirectiveContent::Open,
378 ),
379 map(
380 preceded(tag("close"), cut(preceded(space1, account::close))),
381 DirectiveContent::Close,
382 ),
383 map(
384 preceded(tag("pad"), cut(preceded(space1, account::pad))),
385 DirectiveContent::Pad,
386 ),
387 map(
388 preceded(tag("commodity"), cut(preceded(space1, amount::currency))),
389 DirectiveContent::Commodity,
390 ),
391 map(
392 preceded(tag("event"), cut(preceded(space1, event::parse))),
393 DirectiveContent::Event,
394 ),
395 )),
396 end_of_line,
397 ),
398 metadata::parse,
399 )),
400 ))(input)?;
401 Ok((
402 input,
403 Directive {
404 date,
405 content,
406 metadata,
407 line_number: position.location_line(),
408 },
409 ))
410}
411
412fn option(input: Span<'_>) -> IResult<'_, (String, String)> {
413 let (input, _) = tag("option")(input)?;
414 let (input, key) = preceded(space1, string)(input)?;
415 let (input, value) = preceded(space1, string)(input)?;
416 let (input, ()) = end_of_line(input)?;
417 Ok((input, (key, value)))
418}
419
420fn include(input: Span<'_>) -> IResult<'_, PathBuf> {
421 let (input, _) = tag("include")(input)?;
422 let (input, path) = cut(delimited(space1, string, end_of_line))(input)?;
423 Ok((input, path.into()))
424}
425
426fn tag_stack_operation<D>(input: Span<'_>) -> IResult<'_, RawEntry<D>> {
427 alt((
428 preceded(tuple((tag("pushtag"), space1)), transaction::parse_tag).map(RawEntry::PushTag),
429 preceded(tuple((tag("poptag"), space1)), transaction::parse_tag).map(RawEntry::PopTag),
430 ))(input)
431}
432
433fn end_of_line(input: Span<'_>) -> IResult<'_, ()> {
434 let (input, _) = space0(input)?;
435 let (input, _) = opt(comment)(input)?;
436 let (input, _) = alt((line_ending, eof))(input)?;
437 Ok((input, ()))
438}
439
440fn comment(input: Span<'_>) -> IResult<'_, ()> {
441 let (input, _) = char(';')(input)?;
442 let (input, _) = not_line_ending(input)?;
443 Ok((input, ()))
444}
445
446fn line(input: Span<'_>) -> IResult<'_, ()> {
447 let (input, _) = not_line_ending(input)?;
448 let (input, _) = line_ending(input)?;
449 Ok((input, ()))
450}
451
452fn empty_line(input: Span<'_>) -> IResult<'_, ()> {
453 let (input, ()) = not(eof)(input)?;
454 end_of_line(input)
455}
456
457fn string(input: Span<'_>) -> IResult<'_, String> {
458 let (input, _) = char('"')(input)?;
459 let mut string = String::new();
460 let take_data = take_while(|c: char| c != '"' && c != '\\');
461 let (mut input, mut part) = take_data(input)?;
462 while !part.fragment().is_empty() {
463 string.push_str(part.fragment());
464 let (new_input, escaped) =
465 opt(alt((value('"', tag("\\\"")), value('\\', tag("\\\\")))))(input)?;
466 let Some(escaped) = escaped else { break };
467 string.push(escaped);
468 let (new_input, new_part) = take_data(new_input)?;
469 input = new_input;
470 part = new_part;
471 }
472 let (input, _) = char('"')(input)?;
473 Ok((input, string))
474}
475
476#[cfg(test)]
477type ChumskyError = chumsky::error::Simple<char>;
478
479#[cfg(test)]
480trait ChumskyParser<O>: chumsky::Parser<char, O, Error = ChumskyError> {}
481
482#[cfg(test)]
483impl<O, P: chumsky::Parser<char, O, Error = ChumskyError>> ChumskyParser<O> for P {}
484
485#[cfg(test)]
486mod chumksy {
487 use chumsky::prelude::*;
488
489 use crate::ChumskyParser;
490
491 pub(crate) fn string() -> impl ChumskyParser<String> {
492 choice((just("\\\"").to('"'), just("\\\\").to('\\'), just('"').not()))
493 .repeated()
494 .delimited_by(just('"'), just('"'))
495 .collect()
496 .labelled("string")
497 }
498
499 #[cfg(test)]
500 mod tests {
501 use super::*;
502 use rstest::rstest;
503
504 #[rstest]
505 #[case::empty("\"\"", "")]
506 #[case::normal("\"hello\"", "hello")]
507 #[case::escaped_quote("\"hello \\\"world\\\"\"", "hello \"world\"")]
508 #[case::escaped_backslash("\"hello\\\\world\"", "hello\\world")]
509 fn should_parse_valid_string(#[case] input: &str, #[case] expected: &str) {
510 let string: String = string().then_ignore(end()).parse(input).unwrap();
511 assert_eq!(string, expected);
512 }
513
514 #[rstest]
515 #[case::nothing("")]
516 #[case::not_quoted("hello")]
517 #[case::not_closed("\"hello")]
518 #[case::not_closed_escaped("\"hello\\\"")]
519 fn should_not_parse_invalid_string(#[case] input: &str) {
520 let result: Result<String, _> = string().then_ignore(end()).parse(input);
521 assert!(result.is_err(), "{result:?}");
522 }
523 }
524}