Skip to main content

codama_attributes/codama_directives/
display_directive.rs

1use crate::{
2    utils::{FromMeta, SetOnce},
3    Attribute, CodamaAttribute, CodamaDirective,
4};
5use codama_errors::CodamaError;
6use codama_nodes::{DisplaySkip, NumberDisplayNode};
7use codama_syn_helpers::{extensions::*, Meta};
8
9#[derive(Debug, Default, PartialEq, Clone)]
10pub struct DisplayDirective {
11    pub intent: Option<String>,
12    pub interpolated_intent: Option<String>,
13    pub label: Option<String>,
14    pub skip: Option<DisplaySkip>,
15    pub flatten: Option<bool>,
16    pub flatten_prefix: Option<String>,
17    pub number_display: Option<NumberDisplayNode>,
18}
19
20impl DisplayDirective {
21    pub fn parse(meta: &Meta) -> syn::Result<Self> {
22        let pl = meta.assert_directive("display")?.as_path_list()?;
23        let mut intent: SetOnce<String> = SetOnce::new("intent");
24        let mut interpolated_intent: SetOnce<String> = SetOnce::new("interpolated_intent");
25        let mut label: SetOnce<String> = SetOnce::new("label");
26        let mut skip: SetOnce<DisplaySkip> = SetOnce::new("skip");
27        let mut flatten: SetOnce<bool> = SetOnce::new("flatten");
28        let mut flatten_prefix: SetOnce<String> = SetOnce::new("flatten_prefix");
29        let mut number_display: SetOnce<NumberDisplayNode> = SetOnce::new("number display");
30
31        pl.each(|ref meta| match meta.path_str().as_str() {
32            "intent" => intent.set(meta.as_value()?.as_expr()?.as_string()?, meta),
33            "interpolated_intent" => {
34                interpolated_intent.set(meta.as_value()?.as_expr()?.as_string()?, meta)
35            }
36            "label" => label.set(meta.as_value()?.as_expr()?.as_string()?, meta),
37            "skip" => skip.set(DisplaySkip::from_meta(meta.as_value()?)?, meta),
38            "flatten" => flatten.set(bool::from_meta(meta)?, meta),
39            "flatten_prefix" => flatten_prefix.set(meta.as_value()?.as_expr()?.as_string()?, meta),
40            "amount" | "date_time" | "duration" | "injected" => {
41                number_display.set(NumberDisplayNode::from_meta(meta)?, meta)
42            }
43            "string" => Err(meta.error("string display is not supported yet")),
44            "skip_inner_data" => Err(meta.error("enum variant display is not supported yet")),
45            _ => Err(meta.error("unrecognized display attribute")),
46        })?;
47
48        let display = Self {
49            intent: intent.option(),
50            interpolated_intent: interpolated_intent.option(),
51            label: label.option(),
52            skip: skip.option(),
53            flatten: flatten.option(),
54            flatten_prefix: flatten_prefix.option(),
55            number_display: number_display.option(),
56        };
57        if display == Self::default() {
58            return Err(meta.error("display requires at least one attribute"));
59        }
60        Ok(display)
61    }
62}
63
64impl<'a> TryFrom<&'a CodamaAttribute<'a>> for &'a DisplayDirective {
65    type Error = CodamaError;
66
67    fn try_from(attribute: &'a CodamaAttribute) -> Result<Self, Self::Error> {
68        match attribute.directive.as_ref() {
69            CodamaDirective::Display(ref display) => Ok(display),
70            _ => Err(CodamaError::InvalidCodamaDirective {
71                expected: "display".to_string(),
72                actual: attribute.directive.name().to_string(),
73            }),
74        }
75    }
76}
77
78impl<'a> TryFrom<&'a Attribute<'a>> for &'a DisplayDirective {
79    type Error = CodamaError;
80
81    fn try_from(attribute: &'a Attribute) -> Result<Self, Self::Error> {
82        <&CodamaAttribute>::try_from(attribute)?.try_into()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use codama_nodes::{
90        AmountNumberDisplayNode, DateTimeNumberDisplayNode, InstructionAccountDisplayNode,
91        NumberValueNode, StringValueNode,
92    };
93
94    fn parse_display(tokens: proc_macro2::TokenStream) -> syn::Result<DisplayDirective> {
95        let meta: Meta = syn::parse2(tokens)?;
96        DisplayDirective::parse(&meta)
97    }
98
99    #[test]
100    fn parses_instruction_display() {
101        let display = parse_display(quote::quote! {
102            display(
103                intent = "Transfer tokens",
104                interpolated_intent = "Transfer ${data.amount} to ${accounts.destination}"
105            )
106        })
107        .unwrap();
108        assert_eq!(
109            display,
110            DisplayDirective {
111                intent: Some("Transfer tokens".to_string()),
112                interpolated_intent: Some(
113                    "Transfer ${data.amount} to ${accounts.destination}".to_string()
114                ),
115                ..Default::default()
116            }
117        );
118    }
119
120    #[test]
121    fn parses_struct_field_display() {
122        let display = parse_display(quote::quote! {
123            display(
124                label = "Authorities",
125                skip = when_injected,
126                flatten,
127                flatten_prefix = "Authority "
128            )
129        })
130        .unwrap();
131        assert_eq!(
132            display,
133            DisplayDirective {
134                label: Some("Authorities".to_string()),
135                skip: Some(DisplaySkip::WhenInjected),
136                flatten: Some(true),
137                flatten_prefix: Some("Authority ".to_string()),
138                ..Default::default()
139            }
140        );
141    }
142
143    #[test]
144    fn parses_amount_display() {
145        let display = parse_display(quote::quote! {
146            display(amount(decimals = 9, unit = "SOL"))
147        })
148        .unwrap();
149        assert_eq!(
150            display.number_display,
151            Some(NumberDisplayNode::Amount(AmountNumberDisplayNode {
152                decimals: Box::new(Some(NumberValueNode::new(9u64).into())),
153                unit: Box::new(Some(StringValueNode::new("SOL").into())),
154            }))
155        );
156    }
157
158    #[test]
159    fn parses_empty_amount_display() {
160        let display = parse_display(quote::quote! { display(amount) }).unwrap();
161        assert_eq!(
162            display.number_display,
163            Some(NumberDisplayNode::Amount(Default::default()))
164        );
165    }
166
167    #[test]
168    fn parses_partial_amount_displays() {
169        let decimals = parse_display(quote::quote! {
170            display(amount(decimals = 6))
171        })
172        .unwrap();
173        assert_eq!(
174            decimals.number_display,
175            Some(NumberDisplayNode::Amount(AmountNumberDisplayNode {
176                decimals: Box::new(Some(NumberValueNode::new(6u64).into())),
177                unit: Box::new(None),
178            }))
179        );
180
181        let unit = parse_display(quote::quote! {
182            display(amount(unit = "bytes"))
183        })
184        .unwrap();
185        assert_eq!(
186            unit.number_display,
187            Some(NumberDisplayNode::Amount(AmountNumberDisplayNode {
188                decimals: Box::new(None),
189                unit: Box::new(Some(StringValueNode::new("bytes").into())),
190            }))
191        );
192    }
193
194    #[test]
195    fn parses_date_time_display() {
196        let display = parse_display(quote::quote! {
197            display(date_time(ticks_per_second = 1_000))
198        })
199        .unwrap();
200        assert_eq!(
201            display.number_display,
202            Some(NumberDisplayNode::DateTime(DateTimeNumberDisplayNode {
203                ticks_per_second: Some(1_000),
204            }))
205        );
206    }
207
208    #[test]
209    fn parses_empty_date_time_display() {
210        let display = parse_display(quote::quote! { display(date_time) }).unwrap();
211        assert_eq!(
212            display.number_display,
213            Some(NumberDisplayNode::DateTime(Default::default()))
214        );
215    }
216
217    #[test]
218    fn parses_instruction_account_display() {
219        let meta: Meta = syn::parse_quote! { display(label = "Payer", skip = always) };
220        assert_eq!(
221            InstructionAccountDisplayNode::from_meta(&meta).unwrap(),
222            InstructionAccountDisplayNode {
223                label: Some("Payer".to_string()),
224                skip: Some(DisplaySkip::Always),
225            }
226        );
227    }
228
229    #[test]
230    fn parses_flatten_prefix_without_flatten() {
231        let display = parse_display(quote::quote! {
232            display(flatten_prefix = "Authority ")
233        })
234        .unwrap();
235        assert_eq!(display.flatten, None);
236        assert_eq!(display.flatten_prefix, Some("Authority ".to_string()));
237    }
238
239    #[test]
240    fn parses_explicit_false_flatten() {
241        let display = parse_display(quote::quote! { display(flatten = false) }).unwrap();
242        assert_eq!(display.flatten, Some(false));
243    }
244
245    #[test]
246    fn rejects_multiple_number_displays() {
247        let error = parse_display(quote::quote! {
248            display(amount, date_time)
249        })
250        .unwrap_err();
251        assert_eq!(error.to_string(), "number display is already set");
252    }
253
254    #[test]
255    fn rejects_unsupported_display_nodes() {
256        for (tokens, expected) in [
257            (
258                quote::quote! { display(duration) },
259                "duration display is not supported yet",
260            ),
261            (
262                quote::quote! { display(string) },
263                "string display is not supported yet",
264            ),
265            (
266                quote::quote! { display(skip_inner_data = true) },
267                "enum variant display is not supported yet",
268            ),
269        ] {
270            assert_eq!(parse_display(tokens).unwrap_err().to_string(), expected);
271        }
272    }
273
274    #[test]
275    fn rejects_injected_amount_values() {
276        let error = parse_display(quote::quote! {
277            display(amount(decimals = injected("mint_decimals")))
278        })
279        .unwrap_err();
280        assert_eq!(
281            error.to_string(),
282            "injected display values are not supported yet"
283        );
284    }
285
286    #[test]
287    fn rejects_positional_attributes() {
288        for tokens in [
289            quote::quote! { display("Transfer tokens") },
290            quote::quote! { display(amount(9, "SOL")) },
291            quote::quote! { display(date_time(1_000)) },
292        ] {
293            assert!(parse_display(tokens).is_err());
294        }
295    }
296
297    #[test]
298    fn rejects_invalid_skip() {
299        let error = parse_display(quote::quote! { display(skip = sometimes) }).unwrap_err();
300        assert_eq!(
301            error.to_string(),
302            "expected always, never, or when_injected"
303        );
304    }
305
306    #[test]
307    fn rejects_empty_display() {
308        let error = parse_display(quote::quote! { display() }).unwrap_err();
309        assert_eq!(error.to_string(), "display requires at least one attribute");
310    }
311}