codama-attributes 0.13.1

Codama attributes definitions and parsers
Documentation
use crate::{
    utils::{FromMeta, SetOnce},
    Attribute, CodamaAttribute, CodamaDirective,
};
use codama_errors::CodamaError;
use codama_nodes::{DisplaySkip, NumberDisplayNode};
use codama_syn_helpers::{extensions::*, Meta};

#[derive(Debug, Default, PartialEq, Clone)]
pub struct DisplayDirective {
    pub intent: Option<String>,
    pub interpolated_intent: Option<String>,
    pub label: Option<String>,
    pub skip: Option<DisplaySkip>,
    pub flatten: Option<bool>,
    pub flatten_prefix: Option<String>,
    pub number_display: Option<NumberDisplayNode>,
}

impl DisplayDirective {
    pub fn parse(meta: &Meta) -> syn::Result<Self> {
        let pl = meta.assert_directive("display")?.as_path_list()?;
        let mut intent: SetOnce<String> = SetOnce::new("intent");
        let mut interpolated_intent: SetOnce<String> = SetOnce::new("interpolated_intent");
        let mut label: SetOnce<String> = SetOnce::new("label");
        let mut skip: SetOnce<DisplaySkip> = SetOnce::new("skip");
        let mut flatten: SetOnce<bool> = SetOnce::new("flatten");
        let mut flatten_prefix: SetOnce<String> = SetOnce::new("flatten_prefix");
        let mut number_display: SetOnce<NumberDisplayNode> = SetOnce::new("number display");

        pl.each(|ref meta| match meta.path_str().as_str() {
            "intent" => intent.set(meta.as_value()?.as_expr()?.as_string()?, meta),
            "interpolated_intent" => {
                interpolated_intent.set(meta.as_value()?.as_expr()?.as_string()?, meta)
            }
            "label" => label.set(meta.as_value()?.as_expr()?.as_string()?, meta),
            "skip" => skip.set(DisplaySkip::from_meta(meta.as_value()?)?, meta),
            "flatten" => flatten.set(bool::from_meta(meta)?, meta),
            "flatten_prefix" => flatten_prefix.set(meta.as_value()?.as_expr()?.as_string()?, meta),
            "amount" | "date_time" | "duration" | "injected" => {
                number_display.set(NumberDisplayNode::from_meta(meta)?, meta)
            }
            "string" => Err(meta.error("string display is not supported yet")),
            "skip_inner_data" => Err(meta.error("enum variant display is not supported yet")),
            _ => Err(meta.error("unrecognized display attribute")),
        })?;

        let display = Self {
            intent: intent.option(),
            interpolated_intent: interpolated_intent.option(),
            label: label.option(),
            skip: skip.option(),
            flatten: flatten.option(),
            flatten_prefix: flatten_prefix.option(),
            number_display: number_display.option(),
        };
        if display == Self::default() {
            return Err(meta.error("display requires at least one attribute"));
        }
        Ok(display)
    }
}

impl<'a> TryFrom<&'a CodamaAttribute<'a>> for &'a DisplayDirective {
    type Error = CodamaError;

    fn try_from(attribute: &'a CodamaAttribute) -> Result<Self, Self::Error> {
        match attribute.directive.as_ref() {
            CodamaDirective::Display(ref display) => Ok(display),
            _ => Err(CodamaError::InvalidCodamaDirective {
                expected: "display".to_string(),
                actual: attribute.directive.name().to_string(),
            }),
        }
    }
}

impl<'a> TryFrom<&'a Attribute<'a>> for &'a DisplayDirective {
    type Error = CodamaError;

    fn try_from(attribute: &'a Attribute) -> Result<Self, Self::Error> {
        <&CodamaAttribute>::try_from(attribute)?.try_into()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use codama_nodes::{
        AmountNumberDisplayNode, DateTimeNumberDisplayNode, InstructionAccountDisplayNode,
        NumberValueNode, StringValueNode,
    };

    fn parse_display(tokens: proc_macro2::TokenStream) -> syn::Result<DisplayDirective> {
        let meta: Meta = syn::parse2(tokens)?;
        DisplayDirective::parse(&meta)
    }

    #[test]
    fn parses_instruction_display() {
        let display = parse_display(quote::quote! {
            display(
                intent = "Transfer tokens",
                interpolated_intent = "Transfer ${data.amount} to ${accounts.destination}"
            )
        })
        .unwrap();
        assert_eq!(
            display,
            DisplayDirective {
                intent: Some("Transfer tokens".to_string()),
                interpolated_intent: Some(
                    "Transfer ${data.amount} to ${accounts.destination}".to_string()
                ),
                ..Default::default()
            }
        );
    }

    #[test]
    fn parses_struct_field_display() {
        let display = parse_display(quote::quote! {
            display(
                label = "Authorities",
                skip = when_injected,
                flatten,
                flatten_prefix = "Authority "
            )
        })
        .unwrap();
        assert_eq!(
            display,
            DisplayDirective {
                label: Some("Authorities".to_string()),
                skip: Some(DisplaySkip::WhenInjected),
                flatten: Some(true),
                flatten_prefix: Some("Authority ".to_string()),
                ..Default::default()
            }
        );
    }

    #[test]
    fn parses_amount_display() {
        let display = parse_display(quote::quote! {
            display(amount(decimals = 9, unit = "SOL"))
        })
        .unwrap();
        assert_eq!(
            display.number_display,
            Some(NumberDisplayNode::Amount(AmountNumberDisplayNode {
                decimals: Box::new(Some(NumberValueNode::new(9u64).into())),
                unit: Box::new(Some(StringValueNode::new("SOL").into())),
            }))
        );
    }

    #[test]
    fn parses_empty_amount_display() {
        let display = parse_display(quote::quote! { display(amount) }).unwrap();
        assert_eq!(
            display.number_display,
            Some(NumberDisplayNode::Amount(Default::default()))
        );
    }

    #[test]
    fn parses_partial_amount_displays() {
        let decimals = parse_display(quote::quote! {
            display(amount(decimals = 6))
        })
        .unwrap();
        assert_eq!(
            decimals.number_display,
            Some(NumberDisplayNode::Amount(AmountNumberDisplayNode {
                decimals: Box::new(Some(NumberValueNode::new(6u64).into())),
                unit: Box::new(None),
            }))
        );

        let unit = parse_display(quote::quote! {
            display(amount(unit = "bytes"))
        })
        .unwrap();
        assert_eq!(
            unit.number_display,
            Some(NumberDisplayNode::Amount(AmountNumberDisplayNode {
                decimals: Box::new(None),
                unit: Box::new(Some(StringValueNode::new("bytes").into())),
            }))
        );
    }

    #[test]
    fn parses_date_time_display() {
        let display = parse_display(quote::quote! {
            display(date_time(ticks_per_second = 1_000))
        })
        .unwrap();
        assert_eq!(
            display.number_display,
            Some(NumberDisplayNode::DateTime(DateTimeNumberDisplayNode {
                ticks_per_second: Some(1_000),
            }))
        );
    }

    #[test]
    fn parses_empty_date_time_display() {
        let display = parse_display(quote::quote! { display(date_time) }).unwrap();
        assert_eq!(
            display.number_display,
            Some(NumberDisplayNode::DateTime(Default::default()))
        );
    }

    #[test]
    fn parses_instruction_account_display() {
        let meta: Meta = syn::parse_quote! { display(label = "Payer", skip = always) };
        assert_eq!(
            InstructionAccountDisplayNode::from_meta(&meta).unwrap(),
            InstructionAccountDisplayNode {
                label: Some("Payer".to_string()),
                skip: Some(DisplaySkip::Always),
            }
        );
    }

    #[test]
    fn parses_flatten_prefix_without_flatten() {
        let display = parse_display(quote::quote! {
            display(flatten_prefix = "Authority ")
        })
        .unwrap();
        assert_eq!(display.flatten, None);
        assert_eq!(display.flatten_prefix, Some("Authority ".to_string()));
    }

    #[test]
    fn parses_explicit_false_flatten() {
        let display = parse_display(quote::quote! { display(flatten = false) }).unwrap();
        assert_eq!(display.flatten, Some(false));
    }

    #[test]
    fn rejects_multiple_number_displays() {
        let error = parse_display(quote::quote! {
            display(amount, date_time)
        })
        .unwrap_err();
        assert_eq!(error.to_string(), "number display is already set");
    }

    #[test]
    fn rejects_unsupported_display_nodes() {
        for (tokens, expected) in [
            (
                quote::quote! { display(duration) },
                "duration display is not supported yet",
            ),
            (
                quote::quote! { display(string) },
                "string display is not supported yet",
            ),
            (
                quote::quote! { display(skip_inner_data = true) },
                "enum variant display is not supported yet",
            ),
        ] {
            assert_eq!(parse_display(tokens).unwrap_err().to_string(), expected);
        }
    }

    #[test]
    fn rejects_injected_amount_values() {
        let error = parse_display(quote::quote! {
            display(amount(decimals = injected("mint_decimals")))
        })
        .unwrap_err();
        assert_eq!(
            error.to_string(),
            "injected display values are not supported yet"
        );
    }

    #[test]
    fn rejects_positional_attributes() {
        for tokens in [
            quote::quote! { display("Transfer tokens") },
            quote::quote! { display(amount(9, "SOL")) },
            quote::quote! { display(date_time(1_000)) },
        ] {
            assert!(parse_display(tokens).is_err());
        }
    }

    #[test]
    fn rejects_invalid_skip() {
        let error = parse_display(quote::quote! { display(skip = sometimes) }).unwrap_err();
        assert_eq!(
            error.to_string(),
            "expected always, never, or when_injected"
        );
    }

    #[test]
    fn rejects_empty_display() {
        let error = parse_display(quote::quote! { display() }).unwrap_err();
        assert_eq!(error.to_string(), "display requires at least one attribute");
    }
}