1use std::borrow::Borrow;
2use std::collections::HashSet;
3use std::fmt::{Display, Formatter};
4use std::sync::Arc;
5
6use nom::{
7 branch::alt,
8 bytes::complete::{tag, take_while},
9 character::complete::satisfy,
10 character::complete::{char as char_tag, space0, space1},
11 combinator::{cut, iterator, map, opt, success, value},
12 sequence::{delimited, preceded, separated_pair, terminated, tuple},
13 Parser,
14};
15
16use crate::string;
17use crate::{
18 account, account::Account, amount, amount::Amount, date, empty_line, end_of_line, metadata,
19 Date, Decimal, IResult, Span,
20};
21
22#[derive(Debug, Clone, PartialEq)]
46#[non_exhaustive]
47pub struct Transaction<D> {
48 pub flag: Option<char>,
50 pub payee: Option<String>,
52 pub narration: Option<String>,
54 pub tags: HashSet<Tag>,
56 pub links: HashSet<Link>,
58 pub postings: Vec<Posting<D>>,
60}
61
62#[derive(Debug, Clone, PartialEq)]
90#[non_exhaustive]
91pub struct Posting<D> {
92 pub flag: Option<char>,
94 pub account: Account,
96 pub amount: Option<Amount<D>>,
98 pub cost: Option<Cost<D>>,
100 pub price: Option<PostingPrice<D>>,
102 pub metadata: metadata::Map<D>,
104}
105
106#[derive(Debug, Default, Clone, PartialEq)]
110#[non_exhaustive]
111pub struct Cost<D> {
112 pub amount: Option<Amount<D>>,
114 pub date: Option<Date>,
116}
117
118#[derive(Debug, Clone, PartialEq)]
122pub enum PostingPrice<D> {
123 Unit(Amount<D>),
125 Total(Amount<D>),
127}
128
129#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
147pub struct Tag(Arc<str>);
148
149impl Tag {
150 #[must_use]
152 pub fn as_str(&self) -> &str {
153 &self.0
154 }
155}
156
157impl Display for Tag {
158 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
159 Display::fmt(&self.0, f)
160 }
161}
162
163impl AsRef<str> for Tag {
164 fn as_ref(&self) -> &str {
165 self.0.as_ref()
166 }
167}
168
169impl Borrow<str> for Tag {
170 fn borrow(&self) -> &str {
171 self.0.borrow()
172 }
173}
174
175#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
193pub struct Link(Arc<str>);
194
195impl Link {
196 #[must_use]
198 pub fn as_str(&self) -> &str {
199 &self.0
200 }
201}
202
203impl Display for Link {
204 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
205 Display::fmt(&self.0, f)
206 }
207}
208
209impl AsRef<str> for Link {
210 fn as_ref(&self) -> &str {
211 self.0.as_ref()
212 }
213}
214
215impl Borrow<str> for Link {
216 fn borrow(&self) -> &str {
217 self.0.borrow()
218 }
219}
220
221#[allow(clippy::type_complexity)]
222pub(crate) fn parse<D: Decimal>(
223 input: Span<'_>,
224) -> IResult<'_, (Transaction<D>, metadata::Map<D>)> {
225 let (input, flag) = alt((map(flag, Some), value(None, tag("txn"))))(input)?;
226 cut(do_parse(flag))(input)
227}
228
229fn flag(input: Span<'_>) -> IResult<'_, char> {
230 satisfy(|c: char| !c.is_ascii_lowercase())(input)
231}
232
233fn do_parse<D: Decimal>(
234 flag: Option<char>,
235) -> impl Fn(Span<'_>) -> IResult<'_, (Transaction<D>, metadata::Map<D>)> {
236 move |input| {
237 let (input, payee_and_narration) = opt(preceded(space1, payee_and_narration))(input)?;
238 let (input, (tags, links)) = tags_and_links(input)?;
239 let (input, ()) = end_of_line(input)?;
240 let (input, metadata) = metadata::parse(input)?;
241 let mut iter = iterator(input, alt((posting.map(Some), empty_line.map(|()| None))));
242 let postings = iter.flatten().collect();
243 let (input, ()) = iter.finish()?;
244 let (payee, narration) = match payee_and_narration {
245 Some((payee, narration)) => (payee, Some(narration)),
246 None => (None, None),
247 };
248 Ok((
249 input,
250 (
251 Transaction {
252 flag,
253 payee,
254 narration,
255 tags,
256 links,
257 postings,
258 },
259 metadata,
260 ),
261 ))
262 }
263}
264
265pub(super) enum TagOrLink {
266 Tag(Tag),
267 Link(Link),
268}
269
270pub(super) fn parse_tag(input: Span<'_>) -> IResult<'_, Tag> {
271 map(
272 preceded(
273 char_tag('#'),
274 take_while(|c: char| c.is_alphanumeric() || c == '-' || c == '_'),
275 ),
276 |s: Span<'_>| Tag((*s.fragment()).into()),
277 )(input)
278}
279
280pub(super) fn parse_link(input: Span<'_>) -> IResult<'_, Link> {
281 map(
282 preceded(
283 char_tag('^'),
284 take_while(|c: char| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
285 ),
286 |s: Span<'_>| Link((*s.fragment()).into()),
287 )(input)
288}
289
290pub(super) fn parse_tag_or_link(input: Span<'_>) -> IResult<'_, TagOrLink> {
291 alt((
292 map(parse_tag, TagOrLink::Tag),
293 map(parse_link, TagOrLink::Link),
294 ))(input)
295}
296
297fn tags_and_links(input: Span<'_>) -> IResult<'_, (HashSet<Tag>, HashSet<Link>)> {
298 let mut tags_and_links_iter = iterator(input, preceded(space0, parse_tag_or_link));
299 let (tags, links) = tags_and_links_iter.fold(
300 (HashSet::new(), HashSet::new()),
301 |(mut tags, mut links), x| {
302 match x {
303 TagOrLink::Tag(tag) => tags.insert(tag),
304 TagOrLink::Link(link) => links.insert(link),
305 };
306 (tags, links)
307 },
308 );
309 let (input, ()) = tags_and_links_iter.finish()?;
310 Ok((input, (tags, links)))
311}
312
313fn payee_and_narration(input: Span<'_>) -> IResult<'_, (Option<String>, String)> {
314 let (input, s1) = string(input)?;
315 let (input, s2) = opt(preceded(space1, string))(input)?;
316 Ok((
317 input,
318 match s2 {
319 Some(narration) => (Some(s1), narration),
320 None => (None, s1),
321 },
322 ))
323}
324
325fn posting<D: Decimal>(input: Span<'_>) -> IResult<'_, Posting<D>> {
326 let (input, _) = space1(input)?;
327 let (input, flag) = opt(terminated(flag, space1))(input)?;
328 let (input, account) = account::parse(input)?;
329 let (input, amounts) = opt(tuple((
330 preceded(space1, amount::parse),
331 opt(preceded(space1, cost)),
332 opt(preceded(
333 space1,
334 alt((
335 map(
336 preceded(tuple((char_tag('@'), space1)), amount::parse),
337 PostingPrice::Unit,
338 ),
339 map(
340 preceded(tuple((tag("@@"), space1)), amount::parse),
341 PostingPrice::Total,
342 ),
343 )),
344 )),
345 )))(input)?;
346 let (input, ()) = end_of_line(input)?;
347 let (input, metadata) = metadata::parse(input)?;
348 let (amount, cost, price) = match amounts {
349 Some((a, l, p)) => (Some(a), l, p),
350 None => (None, None, None),
351 };
352 Ok((
353 input,
354 Posting {
355 flag,
356 account,
357 amount,
358 cost,
359 price,
360 metadata,
361 },
362 ))
363}
364
365fn cost<D: Decimal>(input: Span<'_>) -> IResult<'_, Cost<D>> {
366 let (input, _) = terminated(char_tag('{'), space0)(input)?;
367 let (input, (cost, date)) = alt((
368 map(
369 separated_pair(
370 amount::parse,
371 delimited(space0, char_tag(','), space0),
372 date::parse,
373 ),
374 |(a, d)| (Some(a), Some(d)),
375 ),
376 map(
377 separated_pair(
378 date::parse,
379 delimited(space0, char_tag(','), space0),
380 amount::parse,
381 ),
382 |(d, a)| (Some(a), Some(d)),
383 ),
384 map(amount::parse, |a| (Some(a), None)),
385 map(date::parse, |d| (None, Some(d))),
386 map(success(true), |_| (None, None)),
387 ))(input)?;
388 let (input, _) = preceded(space0, char_tag('}'))(input)?;
389 Ok((input, Cost { amount: cost, date }))
390}
391
392#[cfg(test)]
393mod chumsky {
394 use std::collections::HashSet;
395
396 use crate::{ChumskyParser, Decimal, Posting, PostingPrice, Transaction};
397 use chumsky::{prelude::*, text::whitespace};
398
399 use super::{Cost, Link, Tag, TagOrLink};
400
401 fn transaction<D: Decimal + 'static>() -> impl ChumskyParser<Transaction<D>> {
402 flag()
403 .then(payee_and_narration())
404 .then(tags_and_links())
405 .then(posting().padded().repeated())
406 .map(
407 |(((flag, (payee, narration)), (tags, links)), postings)| Transaction {
408 flag,
409 payee,
410 narration,
411 tags,
412 links,
413 postings,
414 },
415 )
416 }
417
418 fn flag() -> impl ChumskyParser<Option<char>> {
419 choice((
420 just("txn").to(None),
421 just('!').map(Some),
422 just('*').map(Some),
423 ))
424 }
425
426 fn payee_and_narration() -> impl ChumskyParser<(Option<String>, Option<String>)> {
427 whitespace()
428 .ignore_then(crate::chumksy::string())
429 .then(whitespace().ignore_then(crate::chumksy::string()).or_not())
430 .or_not()
431 .map(|v| match v {
432 Some((p, Some(n))) => (Some(p), Some(n)),
433 Some((n, None)) => (None, Some(n)),
434 None => (None, None),
435 })
436 }
437
438 fn tags_and_links() -> impl ChumskyParser<(HashSet<Tag>, HashSet<Link>)> {
439 let ident = filter(|c: &char| c.is_alphanumeric())
440 .or(one_of("_-."))
441 .repeated()
442 .at_least(1)
443 .collect::<String>();
444 let tag_or_link = whitespace().ignore_then(choice((
445 just('#')
446 .ignore_then(ident.clone().map(|s| super::Tag(s.into())))
447 .map(TagOrLink::Tag),
448 just('^')
449 .ignore_then(ident.map(|s| super::Link(s.into())))
450 .map(TagOrLink::Link),
451 )));
452 empty()
453 .map(|()| (HashSet::<Tag>::new(), HashSet::<Link>::new()))
454 .then(tag_or_link.padded().repeated())
455 .foldl(
456 |(mut tags, mut links): (HashSet<Tag>, HashSet<Link>), tag_or_link| {
457 match tag_or_link {
458 TagOrLink::Tag(t) => tags.insert(t),
459 TagOrLink::Link(t) => links.insert(t),
460 };
461 (tags, links)
462 },
463 )
464 }
465
466 fn posting<D: Decimal + 'static>() -> impl ChumskyParser<Posting<D>> {
467 one_of("*!")
468 .then_ignore(whitespace())
469 .or_not()
470 .then(crate::account::chumksy::account())
471 .then_ignore(whitespace())
472 .then(crate::amount::chumsky::amount().or_not())
473 .then(whitespace().ignore_then(cost::<D>()).or_not())
474 .then(
475 choice((
476 just('@')
477 .padded()
478 .ignore_then(crate::amount::chumsky::amount())
479 .map(PostingPrice::Unit),
480 just("@@")
481 .padded()
482 .ignore_then(crate::amount::chumsky::amount())
483 .map(PostingPrice::Total),
484 ))
485 .or_not(),
486 )
487 .then(
488 crate::metadata::chumsky::map()
489 .padded()
490 .or_not()
491 .map(Option::unwrap_or_default),
492 )
493 .map(
494 |(((((flag, account), amount), cost), price), metadata)| Posting {
495 flag,
496 account,
497 amount,
498 cost,
499 price,
500 metadata,
501 },
502 )
503 }
504
505 fn cost<D: Decimal + 'static>() -> impl ChumskyParser<Cost<D>> {
506 choice((
507 crate::amount::chumsky::amount()
508 .then(
509 just(',')
510 .padded()
511 .ignore_then(crate::date::chumsky::date())
512 .or_not(),
513 )
514 .map(|(amount, date)| Cost {
515 amount: Some(amount),
516 date,
517 }),
518 crate::date::chumsky::date()
519 .then(
520 just(',')
521 .padded()
522 .ignore_then(crate::amount::chumsky::amount())
523 .or_not(),
524 )
525 .map(|(date, amount)| Cost {
526 amount,
527 date: Some(date),
528 }),
529 ))
530 .or_not()
531 .padded()
532 .delimited_by(just('{'), just('}'))
533 .map(Option::unwrap_or_default)
534 .labelled("cost")
535 }
536
537 #[cfg(test)]
538 mod tests {
539 use crate::{metadata, transaction::Tag, Amount, Date, PostingPrice, Transaction};
540
541 use super::*;
542 use rstest::rstest;
543
544 #[rstest]
545 #[case("txn", None)]
546 #[case("*", Some('*'))]
547 #[case("!", Some('!'))]
548 fn should_parse_transaction_flag(#[case] input: &str, #[case] expected: Option<char>) {
549 let trx: Transaction<i32> = transaction().then_ignore(end()).parse(input).unwrap();
550 assert_eq!(trx.flag, expected);
551 }
552
553 #[rstest]
554 #[ignore = "not implemented"]
555 fn should_parse_transaction_postings() {
556 let input = "* \"foo\" #tag\n Assets:Cash 1 CHF\n Income:A -1 CHF";
557 let trx: Transaction<i32> = transaction().then_ignore(end()).parse(input).unwrap();
558 assert_eq!(
559 trx.postings
560 .iter()
561 .map(|p| p.account.as_str())
562 .collect::<Vec<_>>(),
563 vec!["Assets:Cash", "Income:A"]
564 );
565 }
566
567 #[rstest]
568 #[case("*", None, None)]
569 #[case("* \"Hello\"", None, Some("Hello"))]
570 #[case("* \"Hello\" \"World\"", Some("Hello"), Some("World"))]
571 fn should_parse_transaction_description_and_payee(
572 #[case] input: &str,
573 #[case] expected_payee: Option<&str>,
574 #[case] expected_narration: Option<&str>,
575 ) {
576 let trx: Transaction<i32> = transaction().then_ignore(end()).parse(input).unwrap();
577 assert_eq!(trx.payee.as_deref(), expected_payee);
578 assert_eq!(trx.narration.as_deref(), expected_narration);
579 }
580
581 #[rstest]
582 #[case("* \"hello\" \"world\"", &[])]
583 #[case("* \"hello\" \"world\" #foo #hello-world", &["foo", "hello-world"])]
584 #[case("* \"hello\" \"world\" #2023_05", &["2023_05"])]
585 fn should_parse_transaction_tags(#[case] input: &str, #[case] expected: &[&str]) {
586 let expected: HashSet<Tag> = expected.iter().map(|s| Tag((*s).into())).collect();
587 let trx = transaction::<i32>()
588 .then_ignore(end())
589 .parse(input)
590 .unwrap();
591 assert_eq!(trx.tags, expected);
592 }
593
594 #[rstest]
595 #[case("*", &[])]
596 #[case("* \"hello\" \"world\" ^foo.bar ^hello-world", &["foo.bar", "hello-world"])]
597 #[case("* #2023_05 ^2023-06 #2023_07", &["2023-06"])]
598 fn should_parse_transaction_links(#[case] input: &str, #[case] expected: &[&str]) {
599 let expected: HashSet<Link> = expected.iter().map(|s| Link((*s).into())).collect();
600 let trx = transaction::<i32>()
601 .then_ignore(end())
602 .parse(input)
603 .unwrap();
604 assert_eq!(trx.links, expected);
605 }
606
607 #[rstest]
608 #[case::invalid_tag("* #")]
609 #[case::invalid_tag("* #!")]
610 fn should_not_parse_invalid_transaction(#[case] input: &str) {
611 let result: Result<Transaction<i32>, _> = transaction()
612 .then_ignore(end())
613 .then_ignore(end())
614 .parse(input);
615 assert!(result.is_err(), "{result:?}");
616 }
617
618 #[rstest]
619 fn should_parse_posting_account() {
620 let posting: Posting<i32> = posting().then_ignore(end()).parse("Assets:Cash").unwrap();
621 assert_eq!(posting.account.as_str(), "Assets:Cash");
622 }
623
624 #[rstest]
625 #[case::none("Assets:Cash", None)]
626 #[case::some("Assets:Cash 42 PLN", Some(Amount { value: 42, currency: "PLN".parse().unwrap() }))]
627 fn should_parse_posting_amount(#[case] input: &str, #[case] expected: Option<Amount<i32>>) {
628 let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
629 assert_eq!(posting.amount, expected);
630 }
631
632 #[rstest]
633 #[case::no_flag("Assets:Cash 1 CHF", None)]
634 #[case::cleared("* Assets:Cash 1 CHF", Some('*'))]
635 #[case::pending("! Assets:Cash 1 CHF", Some('!'))]
636 fn should_parse_posting_flag(#[case] input: &str, #[case] expected: Option<char>) {
637 let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
638 assert_eq!(posting.flag, expected);
639 }
640
641 #[rstest]
642 #[case::none("Assets:Cash 1 CHF", None)]
643 #[case::unit("Assets:Cash 1 CHF @ 2 EUR", Some(PostingPrice::Unit(Amount { value: 2, currency: "EUR".parse().unwrap() })))]
644 #[case::total("Assets:Cash 1 CHF @@ 2 EUR", Some(PostingPrice::Total(Amount { value: 2, currency: "EUR".parse().unwrap() })))]
645 fn should_parse_posting_price(
646 #[case] input: &str,
647 #[case] expected: Option<PostingPrice<i32>>,
648 ) {
649 let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
650 assert_eq!(posting.price, expected);
651 }
652
653 #[rstest]
654 #[case::none("Assets:Cash 1 CHF", None)]
655 #[case::empty("Assets:Cash 1 CHF {}", Some(Cost::default()))]
656 #[case::some("Assets:Cash 1 CHF {2023-03-03}", Some(Cost { date: Some(Date::new(2023,3,3)), ..Cost::default() }))]
657 #[case::some_before_price("Assets:Cash 1 CHF {2023-03-03} @ 3 PLN", Some(Cost { date: Some(Date::new(2023,3,3)), ..Cost::default() }))]
658 fn should_parse_posting_cost(#[case] input: &str, #[case] expected: Option<Cost<i32>>) {
659 let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
660 assert_eq!(posting.cost, expected);
661 }
662
663 #[rstest]
664 fn should_parse_posting_metadata() {
665 let input = "Assets:Cash 10 CHF @ 40 PLN\n hello: \"world\"";
666 let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
667 assert_eq!(
668 posting.metadata.get("hello"),
669 Some(&metadata::Value::String("world".into()))
670 );
671 }
672
673 #[rstest]
674 fn should_parse_empty_cost(#[values("{}", "{ }")] input: &str) {
675 let cost: Cost<i32> = cost().then_ignore(end()).parse(input).unwrap();
676 assert_eq!(cost.amount, None);
677 assert_eq!(cost.date, None);
678 }
679
680 #[rstest]
681 fn should_parse_cost_amount(
682 #[values("{1 EUR}", "{ 1 EUR }", "{2024-03-03, 1 EUR}")] input: &str,
683 ) {
684 let cost: Cost<i32> = cost().then_ignore(end()).parse(input).unwrap();
685 let amount = cost.amount.unwrap();
686 assert_eq!(amount.value, 1);
687 assert_eq!(amount.currency.as_str(), "EUR");
688 }
689
690 #[rstest]
691 fn should_parse_cost_date(
692 #[values("{2024-03-02}", "{ 1 EUR , 2024-03-02 }", "{ 2024-03-02, 2 EUR }")]
693 input: &str,
694 ) {
695 let cost: Cost<i32> = cost().then_ignore(end()).parse(input).unwrap();
696 assert_eq!(
697 cost.date,
698 Some(Date {
699 year: 2024,
700 month: 3,
701 day: 2,
702 })
703 );
704 }
705
706 #[rstest]
707 #[case::duplicated_date("{2023-03-03, 2023-03-04}")]
708 #[case::duplicated_amount("{1 EUR, 2 CHF}")]
709 fn should_not_parse_invalid_cost(#[case] input: &str) {
710 let result: Result<Cost<i32>, _> = cost().then_ignore(end()).parse(input);
711 assert!(result.is_err(), "{result:?}");
712 }
713 }
714}