beancount_parser/
metadata.rs

1//! Types to represent [beancount metadata](https://beancount.github.io/docs/beancount_language_syntax.html#metadata)
2//!
3//! # Example
4//!
5//! ```
6//! # use beancount_parser::BeancountFile;
7//! use beancount_parser::metadata::Value;
8//! let input = r#"
9//! 2023-05-27 commodity CHF
10//!     title: "Swiss Franc"
11//! "#;
12//! let beancount: BeancountFile<f64> = input.parse().unwrap();
13//! let directive_metadata = &beancount.directives[0].metadata;
14//! assert_eq!(directive_metadata.get("title"), Some(&Value::String("Swiss Franc".into())));
15//! ```
16
17use std::{
18    borrow::Borrow,
19    collections::HashMap,
20    fmt::{Debug, Display, Formatter},
21    str::FromStr,
22    sync::Arc,
23};
24
25use nom::{
26    branch::alt,
27    bytes::complete::take_while,
28    character::complete::{char, satisfy, space1},
29    combinator::{all_consuming, iterator, map, recognize},
30    sequence::preceded,
31    Parser,
32};
33
34use crate::{amount, empty_line, end_of_line, string, Currency, Decimal, IResult, Span};
35
36/// Metadata map
37///
38/// See the [`metadata`](crate::metadata) module for an example
39pub type Map<D> = HashMap<Key, Value<D>>;
40
41/// Metadata key
42///
43/// See the [`metadata`](crate::metadata) module for an example
44#[derive(Debug, Clone, Eq, PartialEq, Hash)]
45pub struct Key(Arc<str>);
46
47impl Display for Key {
48    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
49        Display::fmt(&self.0, f)
50    }
51}
52
53impl AsRef<str> for Key {
54    fn as_ref(&self) -> &str {
55        self.0.as_ref()
56    }
57}
58
59impl Borrow<str> for Key {
60    fn borrow(&self) -> &str {
61        self.0.borrow()
62    }
63}
64
65impl FromStr for Key {
66    type Err = crate::Error;
67    fn from_str(s: &str) -> Result<Self, Self::Err> {
68        let span = Span::new(s);
69        match all_consuming(key)(span) {
70            Ok((_, key)) => Ok(key),
71            Err(_) => Err(crate::Error::new(s, span)),
72        }
73    }
74}
75
76/// Metadata value
77///
78/// See the [`metadata`](crate::metadata) module for an example
79#[derive(Debug, Clone, PartialEq)]
80#[non_exhaustive]
81pub enum Value<D> {
82    /// String value
83    String(String),
84    /// A number or number expression
85    Number(D),
86    /// A [`Currency`]
87    Currency(Currency),
88}
89
90pub(crate) fn parse<D: Decimal>(input: Span<'_>) -> IResult<'_, Map<D>> {
91    let mut iter = iterator(input, alt((entry.map(Some), empty_line.map(|()| None))));
92    let map: HashMap<_, _> = iter.flatten().collect();
93    let (input, ()) = iter.finish()?;
94    Ok((input, map))
95}
96
97fn entry<D: Decimal>(input: Span<'_>) -> IResult<'_, (Key, Value<D>)> {
98    let (input, _) = space1(input)?;
99    let (input, key) = key(input)?;
100    let (input, _) = char(':')(input)?;
101    let (input, _) = space1(input)?;
102    let (input, value) = alt((
103        string.map(Value::String),
104        amount::expression.map(Value::Number),
105        amount::currency.map(Value::Currency),
106    ))(input)?;
107    let (input, ()) = end_of_line(input)?;
108    Ok((input, (key, value)))
109}
110
111fn key(input: Span<'_>) -> IResult<'_, Key> {
112    map(
113        recognize(preceded(
114            satisfy(char::is_lowercase),
115            take_while(|c: char| c.is_alphanumeric() || c == '-' || c == '_'),
116        )),
117        |s: Span<'_>| Key((*s.fragment()).into()),
118    )(input)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    use rstest::rstest;
126
127    #[rstest]
128    fn key_from_str_should_parse_key() {
129        let key: Key = "foo".parse().unwrap();
130        assert_eq!(key.as_ref(), "foo");
131    }
132
133    #[rstest]
134    fn key_from_str_should_not_parse_invalid_key() {
135        let key: Result<Key, _> = "foo bar".parse();
136        assert!(key.is_err(), "{key:?}");
137    }
138}
139
140#[cfg(test)]
141pub(crate) mod chumsky {
142
143    use std::collections::HashMap;
144
145    use super::{Key, Value};
146    use crate::{ChumskyParser, Decimal};
147
148    use chumsky::prelude::*;
149
150    pub(crate) fn map<D: Decimal + 'static>() -> impl ChumskyParser<HashMap<Key, Value<D>>> {
151        entry().padded().repeated().collect().labelled("metadata")
152    }
153
154    fn entry<D: Decimal + 'static>() -> impl ChumskyParser<(Key, Value<D>)> {
155        let key = filter(|c: &char| c.is_alphanumeric())
156            .or(one_of("_-"))
157            .repeated()
158            .collect::<String>()
159            .map(|s| Key(s.into()));
160        let value = choice((
161            crate::amount::chumsky::expression::<D>().map(Value::Number),
162            crate::amount::chumsky::currency().map(Value::Currency),
163            crate::chumksy::string().map(Value::String),
164        ));
165
166        key.then_ignore(just(':').padded())
167            .then(value)
168            .labelled("metadata entry")
169    }
170
171    #[cfg(test)]
172    mod tests {
173        use super::*;
174        use rstest::rstest;
175
176        #[rstest]
177        fn should_parse_metadata_map() {
178            let input = "foo: 1\n  bar: 2\n\nbaz: 3";
179            let mut expected = HashMap::<Key, Value<i32>>::new();
180            expected.insert("foo".parse().unwrap(), Value::Number(1));
181            expected.insert("bar".parse().unwrap(), Value::Number(2));
182            expected.insert("baz".parse().unwrap(), Value::Number(3));
183            let actual = map().then_ignore(end()).parse(input).unwrap();
184            assert_eq!(actual, expected);
185        }
186
187        #[rstest]
188        #[case::num("foo: 42", "foo", Value::Number(42))]
189        #[case::expression("foo: (41 + 1)", "foo", Value::Number(42))]
190        #[case::kebab_key("foo-bar: 1", "foo-bar", Value::Number(1))]
191        #[case::snake_key("foo_bar: 1", "foo_bar", Value::Number(1))]
192        #[case::camel_case_key("fooBar: 1", "fooBar", Value::Number(1))]
193        #[case::currency("currency: CHF", "currency", Value::Currency("CHF".parse().unwrap()))]
194        #[case::string("hello: \"world\"", "hello", Value::String("world".into()))]
195        fn should_parse_valid_metadata_entry(
196            #[case] input: &str,
197            #[case] expected_key: Key,
198            #[case] expected_value: Value<i32>,
199        ) {
200            let (key, value): (Key, Value<i32>) = entry().then_ignore(end()).parse(input).unwrap();
201            assert_eq!(key, expected_key);
202            assert_eq!(value, expected_value);
203        }
204
205        #[rstest]
206        #[case::space_in_key("hello world: 1")]
207        fn should_not_parse_invalid_metadata(#[case] input: &str) {
208            let result: Result<(Key, Value<i32>), _> = entry().then_ignore(end()).parse(input);
209            assert!(result.is_err(), "{result:?}");
210        }
211    }
212}