Add a templating engine for accounts

korrat
Oct 25, 2022, 4:08 PM
W3MWSSJ7LUAUUAQJH47BNNGLM2O6CEMJ2MA5PY72EAA6GIW67ANAC

Dependencies

  • [2] R7S2CWF7 Add type for account segments
  • [*] YDK6X6PP add a library of important types for beancount
  • [*] 2JBFREZG enable additional warnings

Change contents

  • file addition: tests (d--r------)
    [4.27]
  • file addition: snapshots (d--r------)
    [0.17]
  • file addition: account_template__parsing_only_base_account_works.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    assertion_line: 30
    expression: template
    ---
    Template {
    base: Acc {
    name: "Assets:Banking",
    },
    segments: [],
    }
  • file addition: account_template__parsing_fails_for_invalid_selectors.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    expression: template
    ---
    Parsing {
    inner: [
    SelectorSegment {
    source: SelectorError {
    selector: "institution",
    },
    span: SourceSpan {
    offset: SourceOffset(
    15,
    ),
    length: SourceOffset(
    13,
    ),
    },
    },
    SelectorSegment {
    source: SelectorError {
    selector: "account",
    },
    span: SourceSpan {
    offset: SourceOffset(
    29,
    ),
    length: SourceOffset(
    9,
    ),
    },
    },
    SelectorSegment {
    source: SelectorError {
    selector: "position",
    },
    span: SourceSpan {
    offset: SourceOffset(
    46,
    ),
    length: SourceOffset(
    10,
    ),
    },
    },
    ],
    template: "Assets:Banking:{institution}:{account}:Stocks:{position}",
    }
  • file addition: account_template__parsing_fails_for_invalid_literals.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    expression: template
    ---
    Parsing {
    inner: [
    LiteralSegment {
    source: SegmentError {
    segment: "Main Account",
    backtrace: Backtrace(
    (),
    ),
    },
    span: SourceSpan {
    offset: SourceOffset(
    29,
    ),
    length: SourceOffset(
    12,
    ),
    },
    },
    LiteralSegment {
    source: SegmentError {
    segment: "stocks",
    backtrace: Backtrace(
    (),
    ),
    },
    span: SourceSpan {
    offset: SourceOffset(
    42,
    ),
    length: SourceOffset(
    6,
    ),
    },
    },
    ],
    template: "Assets:Banking:{institution}:Main Account:stocks:{position}",
    }
  • file addition: account_template__parsing_fails_for_invalid_base_account.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    expression: template
    ---
    Parsing {
    inner: [
    BaseAccount {
    source: AccountError {
    name: "Assets:Banking:Bank of America",
    backtrace: Backtrace(
    (),
    ),
    },
    span: SourceSpan {
    offset: SourceOffset(
    0,
    ),
    length: SourceOffset(
    30,
    ),
    },
    },
    ],
    template: "Assets:Banking:Bank of America:{account}",
    }
  • file addition: account_template__parsing_empty_template_fails.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    assertion_line: 7
    expression: template
    ---
    EmptyTemplate
  • file addition: account_template__parsing_base_account_and_trailing_selector_works.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    expression: template
    ---
    Template {
    base: Acc {
    name: "Assets:Banking",
    },
    segments: [
    Selector(
    AnySelector(
    "institution",
    ),
    ),
    ],
    }
  • file addition: account_template__parsing_base_account_and_multiple_trailing_selectors_works.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    expression: template
    ---
    Template {
    base: Acc {
    name: "Assets:Banking",
    },
    segments: [
    Selector(
    AnySelector(
    "institution",
    ),
    ),
    Selector(
    AnySelector(
    "account",
    ),
    ),
    Selector(
    AnySelector(
    "position",
    ),
    ),
    ],
    }
  • file addition: account_template__parsing_base_account_and_mixed_trailing_selectors_and_literals_works.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    expression: template
    ---
    Template {
    base: Acc {
    name: "Assets:Banking",
    },
    segments: [
    Selector(
    AnySelector(
    "institution",
    ),
    ),
    Selector(
    AnySelector(
    "account",
    ),
    ),
    Literal(
    Seg {
    inner: "Stocks",
    },
    ),
    Selector(
    AnySelector(
    "position",
    ),
    ),
    ],
    }
  • file addition: account_template__multiple_parsing_errors_are_reported_in_bulk.snap (---r------)
    [0.40]
    ---
    source: common/beancount-types/tests/account_template.rs
    expression: template
    ---
    Parsing {
    inner: [
    BaseAccount {
    source: AccountError {
    name: "Assets:Banking:Bank of America",
    backtrace: Backtrace(
    (),
    ),
    },
    span: SourceSpan {
    offset: SourceOffset(
    0,
    ),
    length: SourceOffset(
    30,
    ),
    },
    },
    LiteralSegment {
    source: SegmentError {
    segment: "cash",
    backtrace: Backtrace(
    (),
    ),
    },
    span: SourceSpan {
    offset: SourceOffset(
    41,
    ),
    length: SourceOffset(
    4,
    ),
    },
    },
    LiteralSegment {
    source: SegmentError {
    segment: "{po",
    backtrace: Backtrace(
    (),
    ),
    },
    span: SourceSpan {
    offset: SourceOffset(
    46,
    ),
    length: SourceOffset(
    3,
    ),
    },
    },
    ],
    template: "Assets:Banking:Bank of America:{account}:cash:{po",
    }
  • file addition: account_template.rs (---r------)
    [0.17]
    use beancount_types::AccountTemplate;
    use miette::Diagnostic;
    use snafu::Snafu;
    #[derive(Clone, Copy, Debug)]
    struct AnySelector<'t>(&'t str);
    impl<'t> TryFrom<&'t str> for AnySelector<'t> {
    type Error = NeverSelectorError;
    fn try_from(value: &'t str) -> Result<Self, Self::Error> {
    Ok(Self(value.try_into().unwrap()))
    }
    }
    #[derive(Debug, Diagnostic, Snafu)]
    enum NeverSelectorError {}
    #[derive(Clone, Copy, Debug)]
    struct NoSelector();
    impl<'t> TryFrom<&'t str> for NoSelector {
    type Error = SelectorError<'t>;
    fn try_from(selector: &'t str) -> Result<Self, Self::Error> {
    Err(SelectorError { selector })
    }
    }
    #[derive(Debug, Diagnostic, Snafu)]
    struct SelectorError<'t> {
    selector: &'t str,
    }
    #[test]
    fn parsing_empty_template_fails() {
    let template = AccountTemplate::<AnySelector>::parse("").unwrap_err();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn parsing_only_base_account_works() {
    let template = AccountTemplate::<AnySelector>::parse("Assets:Banking").unwrap();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn parsing_base_account_and_trailing_selector_works() {
    let template = AccountTemplate::<AnySelector>::parse("Assets:Banking:{institution}").unwrap();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn parsing_base_account_and_multiple_trailing_selectors_works() {
    let template =
    AccountTemplate::<AnySelector>::parse("Assets:Banking:{institution}:{account}:{position}")
    .unwrap();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn parsing_base_account_and_mixed_trailing_selectors_and_literals_works() {
    let template = AccountTemplate::<AnySelector>::parse(
    "Assets:Banking:{institution}:{account}:Stocks:{position}",
    )
    .unwrap();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn parsing_fails_for_invalid_literals() {
    let template = AccountTemplate::<AnySelector>::parse(
    "Assets:Banking:{institution}:Main Account:stocks:{position}",
    )
    .unwrap_err();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn parsing_fails_for_invalid_selectors() {
    let template = AccountTemplate::<NoSelector>::parse(
    "Assets:Banking:{institution}:{account}:Stocks:{position}",
    )
    .unwrap_err();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn parsing_fails_for_invalid_base_account() {
    let template =
    AccountTemplate::<AnySelector>::parse("Assets:Banking:Bank of America:{account}")
    .unwrap_err();
    insta::assert_debug_snapshot!(template);
    }
    #[test]
    fn multiple_parsing_errors_are_reported_in_bulk() {
    let template =
    AccountTemplate::<AnySelector>::parse("Assets:Banking:Bank of America:{account}:cash:{po")
    .unwrap_err();
    insta::assert_debug_snapshot!(template);
    }
  • edit in common/beancount-types/src/lib.rs at line 2
    [2.20]
    [5.4030]
    pub use crate::account::template::Template as AccountTemplate;
  • edit in common/beancount-types/src/account.rs at line 22
    [4.11550]
    [4.11760]
    pub(crate) mod template;
  • file addition: account (d--r------)
    [4.44]
  • file addition: template.rs (---r------)
    [0.9743]
    use core::fmt::Debug;
    use core::ops::Index;
    use miette::Diagnostic;
    use miette::SourceSpan;
    use snafu::ResultExt as _;
    use snafu::Snafu;
    use crate::account::AccountError;
    use crate::account::SegmentError;
    use crate::Acc;
    use crate::Account;
    use crate::Seg;
    #[derive(Debug, Diagnostic, Snafu)]
    pub enum Error<'t, E>
    where
    E: 'static + Diagnostic,
    {
    #[snafu(display("empty templates are not supported"))]
    EmptyTemplate {},
    #[snafu(display("errors while parsing template"))]
    Parsing {
    #[related]
    inner: Vec<ParsingError<E>>,
    #[source_code]
    template: &'t str,
    },
    }
    #[derive(Debug, Diagnostic, Snafu)]
    pub enum ParsingError<E>
    where
    E: 'static + Diagnostic,
    {
    #[snafu(display("could not parse base account"))]
    BaseAccount {
    source: AccountError,
    #[label]
    span: SourceSpan,
    },
    #[snafu(display("could not parse literal segment"))]
    LiteralSegment {
    #[diagnostic_source]
    source: SegmentError,
    #[label]
    span: SourceSpan,
    },
    #[snafu(display("could not parse selector segment"))]
    SelectorSegment {
    source: E,
    #[label]
    span: SourceSpan,
    },
    }
    #[derive(Debug)]
    pub struct Template<'t, Selector> {
    base: &'t Acc,
    segments: Vec<Segment<'t, Selector>>,
    }
    impl<'t, Selector> Template<'t, Selector>
    where
    Selector: TryFrom<&'t str>,
    Selector::Error: Diagnostic,
    {
    pub fn parse(template: &'t str) -> Result<Self, Error<'t, Selector::Error>> {
    snafu::ensure!(!template.is_empty(), EmptyTemplateSnafu {});
    let (base, segments) = template.find(":{").map_or((template, ""), |mid| {
    (&template[..mid], &template[mid + 1..])
    });
    let initial_offset = base.len();
    let base = base.try_into().context(BaseAccountSnafu {
    span: 0..base.len(),
    });
    let segments = parse_trailing_segments(segments, initial_offset);
    let errors = match (base, segments) {
    (Ok(base), Ok(segments)) => return Ok(Self { base, segments }),
    (Ok(_), Err(errors)) => errors,
    (Err(error), Ok(_)) => vec![error],
    (Err(error), Err(mut errors)) => {
    errors.insert(0, error);
    errors
    }
    };
    ParsingSnafu {
    inner: errors,
    template,
    }
    .fail()
    }
    }
    impl<'t, Selector> Template<'t, Selector> {
    pub fn render<'c, Context>(&'t self, context: &'c Context) -> Account
    where
    Context: Index<&'t Selector, Output = Seg>,
    Selector: 't,
    {
    let fields = self.segments.iter().map(|field| match field {
    Segment::Literal(seg) => *seg,
    Segment::Selector(selector) => context.index(selector),
    });
    fields.fold(self.base.to_owned(), |account, segment| {
    account.join(&segment)
    })
    }
    }
    impl<'t, Selector> TryFrom<&'t str> for Template<'t, Selector>
    where
    Selector: TryFrom<&'t str>,
    Selector::Error: 'static + Diagnostic,
    {
    type Error = Error<'t, Selector::Error>;
    fn try_from(template: &'t str) -> Result<Self, Self::Error> {
    Self::parse(template)
    }
    }
    #[derive(Clone, Debug)]
    pub(crate) enum Segment<'t, Selector> {
    Literal(&'t Seg),
    Selector(Selector),
    }
    impl<'t, Selector> Segment<'t, Selector>
    where
    Selector: TryFrom<&'t str>,
    Selector::Error: 'static + Diagnostic,
    {
    fn parse(segment: &'t str, span: SourceSpan) -> Result<Self, ParsingError<Selector::Error>> {
    if segment.starts_with('{') && segment.ends_with('}') {
    Ok(Self::Selector(
    segment[1..segment.len() - 1]
    .trim()
    .try_into()
    .context(SelectorSegmentSnafu { span })?,
    ))
    } else {
    <&Seg>::try_from(segment)
    .map(Self::from)
    .context(LiteralSegmentSnafu { span })
    }
    }
    }
    impl<'t, Selector> From<&'t Seg> for Segment<'t, Selector> {
    fn from(seg: &'t Seg) -> Self {
    Self::Literal(seg)
    }
    }
    fn parse_trailing_segments<'t, Selector>(
    segments: &'t str,
    initial_offset: usize,
    ) -> Result<Vec<Segment<Selector>>, Vec<ParsingError<Selector::Error>>>
    where
    Selector: TryFrom<&'t str>,
    Selector::Error: Diagnostic,
    {
    if segments.is_empty() {
    return Ok(vec![]);
    }
    let segments = segments.split(':').scan(initial_offset, {
    |offset, segment| {
    let start = *offset + 1;
    *offset += segment.len() + 1;
    Some(Segment::parse(segment, (start..*offset).into()))
    }
    });
    let (length, _) = segments.size_hint();
    segments.fold(Ok(Vec::with_capacity(length)), |result, segment| {
    match (result, segment) {
    (Ok(mut segments), Ok(segment)) => {
    segments.push(segment);
    Ok(segments)
    }
    (Ok(_), Err(error)) => Err(vec![error]),
    (Err(errors), Ok(_)) => Err(errors),
    (Err(mut errors), Err(error)) => {
    errors.push(error);
    Err(errors)
    }
    }
    })
    }