config_it_macros/
lib.rs

1use std::borrow::Cow;
2
3use proc_macro::TokenStream as LangTokenStream;
4use proc_macro2::{Span, TokenStream};
5use proc_macro_error::{emit_error, proc_macro_error};
6use quote::{quote, quote_spanned};
7use syn::{
8    punctuated::Punctuated, spanned::Spanned, Attribute, Expr, ExprLit, Ident, Lit, LitStr, Meta,
9    Token,
10};
11
12/// # Usage
13///
14/// ```ignore
15/// #[derive(config_it::Template, Clone)]
16/// struct MyConfig {
17///     /// Documentation comments are retrieved and used as descriptions in the editor.
18///     #[config(default = 154)]
19///     pub any_number: i32,
20///
21///     #[non_config_default_expr = r#"1.try_into().unwrap()"#]
22///     pub non_config_number: std::num::NonZeroUsize,
23///
24///     pub non_config_boolean: bool,
25/// }
26///
27/// let storage = config_it::create_storage();
28/// let mut config = storage.find_or_create::<MyConfig>(["my", "config", "path"]).unwrap();
29///
30/// // Initial update always reports dirty.
31/// assert_eq!(config.update(), true);
32/// assert_eq!(config.consume_update(&config.any_number), true);
33/// assert_eq!(config.consume_update(&config.non_config_number), true);
34/// assert_eq!(config.consume_update(&config.non_config_boolean), true);
35/// ```
36///
37/// # Attributes
38///
39/// Attributes are encapsulated within `#[config(...)]` or `#[config_it(...)]`.
40///
41/// - `alias = "<name>"`: Assign an alias to the field.
42/// - `default = <expr>` or `default_expr = "<expr>"`: Define a default value for the field.
43/// - `admin | admin_write | admin_read`: Restrict access to the field for non-admin users.
44///
45/// - `min = <expr>`, `max = <expr>`, `one_of = [<expr>...]`: Apply constraints to the field.
46/// - `validate_with = "<function_name>"`: Specify a validation function for the field with the
47///   signature `fn(&mut T) -> Result<Validation, impl Into<Cow<'static, str>>>`.
48///
49/// - `readonly | writeonly`: Designate an element as read-only or write-only.
50/// - `secret`: Flag an element as confidential (e.g., passwords). The value will be archived as a
51///   encrypted base64 string, which is becomes readable when imported back by storage.
52///
53/// - `env = "<literal>"` or `env_once = "<literal>"`: Set the default value from an environment
54///   variable.
55/// - `transient | no_export | no_import`: Prevent field export/import.
56/// - `editor = <ident>`: Define an editor hint for the field. See
57///   [`config_it::shared::meta::MetadataEditorHint`](https://docs.rs/config-it/latest/config_it/shared/meta/enum.MetadataEditorHint.html)
58///   - e.g. Specify expression as `editor = ColorRgba255`, `editor = Code("rust".into())`, etc.
59/// - `hidden` or `hidden_non_admin`: Make a field invisible in the editor or only to non-admin
60///   users, respectively.
61///
62/// # Interacting with non-config-it Types
63///
64/// For non-configuration types that lack a `Default` trait, the `#[non_config_default_expr =
65/// "<expr>"]` attribute can be used to specify default values.
66
67#[proc_macro_error]
68#[proc_macro_derive(Template, attributes(config_it, config, non_config_default_expr))]
69pub fn derive_collect_fn(item: LangTokenStream) -> LangTokenStream {
70    let tokens = TokenStream::from(item);
71    let Ok(syn::ItemStruct { attrs: _, ident, fields, .. }) =
72        syn::parse2::<syn::ItemStruct>(tokens)
73    else {
74        proc_macro_error::abort_call_site!("expected struct")
75    };
76    let syn::Fields::Named(fields) = fields else {
77        proc_macro_error::abort_call_site!("Non-named fields are not allowed")
78    };
79
80    let mut gen = GenContext::default();
81    let this_crate = this_crate_name();
82
83    visit_fields(
84        &mut gen,
85        GenInputCommon { this_crate: &this_crate, struct_ident: &ident },
86        fields,
87    );
88
89    let GenContext {
90        // <br>
91        fn_props,
92        fn_prop_at_offset,
93        fn_default_config,
94        fn_elem_at_mut,
95        fn_global_constants,
96        ..
97    } = gen;
98    let n_props = fn_props.len();
99
100    quote!(
101        #[allow(unused_parens)]
102        #[allow(unused_imports)]
103        #[allow(unused_braces)]
104        #[allow(clippy::useless_conversion)]
105        #[allow(clippy::redundant_closure)]
106        #[allow(clippy::clone_on_copy)]
107        const _: () = {
108            #( #fn_global_constants )*
109
110            impl #this_crate::Template for #ident {
111                type LocalPropContextArray = #this_crate::config::group::LocalPropContextArrayImpl<#n_props>;
112
113                fn props__() -> &'static [#this_crate::config::entity::PropertyInfo] {
114                    static PROPS: ::std::sync::OnceLock<[#this_crate::config::entity::PropertyInfo; #n_props]> = ::std::sync::OnceLock::new();
115                    PROPS.get_or_init(|| [#(#fn_props)*] )
116                }
117
118                fn prop_at_offset__(offset: usize) -> Option<&'static #this_crate::config::entity::PropertyInfo> {
119                    let index = match offset { #(#fn_prop_at_offset)* _ => None::<usize> };
120                    index.map(|x| &Self::props__()[x])
121                }
122
123                fn template_name() -> (&'static str, &'static str) {
124                    (module_path!(), stringify!(#ident))
125                }
126
127                fn default_config() -> Self {
128                    Self {
129                        #(#fn_default_config)*
130                    }
131                }
132
133                fn elem_at_mut__(&mut self, index: usize) -> &mut dyn std::any::Any {
134                    use ::std::any::Any;
135
136                    match index {
137                        #(#fn_elem_at_mut)*
138                        _ => panic!("Invalid index {}", index),
139                    }
140                }
141            }
142        };
143    )
144    .into()
145}
146
147fn visit_fields(
148    GenContext {
149        fn_props,
150        fn_prop_at_offset,
151        fn_default_config,
152        fn_elem_at_mut,
153        fn_global_constants,
154    }: &mut GenContext,
155    GenInputCommon { this_crate, struct_ident }: GenInputCommon,
156    syn::FieldsNamed { named: fields, .. }: syn::FieldsNamed,
157) {
158    let n_field = fields.len();
159    fn_prop_at_offset.reserve(n_field);
160    fn_default_config.reserve(n_field);
161    fn_props.reserve(n_field);
162    fn_elem_at_mut.reserve(n_field);
163
164    let mut doc_string = Vec::new();
165    let mut field_index = 0usize;
166
167    for field in fields.into_iter() {
168        let field_span = field.ident.span();
169        let field_ty = field.ty;
170        let field_ident = field.ident.expect("This is struct with named fields");
171
172        /* -------------------------------------------------------------------------------------- */
173        /*                                    ATTRIBUTE PARSING                                   */
174        /* -------------------------------------------------------------------------------------- */
175
176        let mut field_type = FieldType::Plain;
177        doc_string.clear();
178
179        for Attribute { meta, .. } in field.attrs {
180            if meta.path().is_ident("doc") {
181                /* ----------------------------- Doc String Parsing ----------------------------- */
182                let Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit_str), .. }) =
183                    &meta.require_name_value().unwrap().value
184                else {
185                    proc_macro_error::abort!(meta, "Expected string literal")
186                };
187
188                doc_string.push(lit_str.value());
189            } else if ["config", "config_it"].into_iter().any(|x| meta.path().is_ident(x)) {
190                /* ------------------------------ Config Attributes ----------------------------- */
191                if !matches!(&field_type, FieldType::Plain) {
192                    emit_error!(meta, "Duplicate config attribute");
193                    continue;
194                }
195
196                field_type = FieldType::Property(match meta {
197                    Meta::Path(_) => Default::default(),
198                    Meta::List(list) => {
199                        if let Some(x) = from_meta_list(list) {
200                            x
201                        } else {
202                            continue;
203                        }
204                    }
205                    Meta::NameValue(_) => {
206                        emit_error!(meta, "Unexpected value. Expected `#[config(...)]`");
207                        continue;
208                    }
209                });
210            } else if meta.path().is_ident("non_config_default_expr") {
211                /* --------------------------------- Non-config --------------------------------- */
212                let span = meta.path().span();
213                let Meta::NameValue(expr) = meta else {
214                    emit_error!(meta, "Expected expression");
215                    continue;
216                };
217
218                let Some(expr) = expr_take_lit_str(expr.value) else {
219                    emit_error!(span, "Expected string literal");
220                    continue;
221                };
222
223                let Ok(expr) = expr.parse::<Expr>() else {
224                    emit_error!(span, "Expected valid expression");
225                    continue;
226                };
227
228                field_type = FieldType::PlainWithDefaultExpr(expr);
229            } else {
230                // Safely ignore unknown attributes
231            }
232        }
233
234        /* -------------------------------------------------------------------------------------- */
235        /*                                   FUNCTION GENERATION                                  */
236        /* -------------------------------------------------------------------------------------- */
237        let prop = match field_type {
238            FieldType::Plain => {
239                fn_default_config
240                    .push(quote_spanned!(field_span => #field_ident: Default::default(),));
241                continue;
242            }
243            FieldType::PlainWithDefaultExpr(expr) => {
244                fn_default_config.push(quote!(#field_ident: #expr,));
245                continue;
246            }
247            FieldType::Property(x) => x,
248        };
249
250        /* --------------------------------- Default Generation --------------------------------- */
251        let default_expr = match prop.default {
252            Some(FieldPropertyDefault::Expr(expr)) => {
253                quote!(<#field_ty>::try_from(#expr).unwrap())
254            }
255
256            Some(FieldPropertyDefault::ExprStr(lit)) => {
257                let Ok(expr) = lit.parse::<Expr>() else {
258                    emit_error!(lit.span(), "Expected valid expression");
259                    continue;
260                };
261
262                quote!(#expr)
263            }
264
265            None => {
266                quote_spanned!(field_span => Default::default())
267            }
268        };
269
270        let default_expr = if let Some((once, env)) = prop.env.clone() {
271            let env_var = env.value();
272            if once {
273                quote_spanned!(env.span() => {
274                    static ENV: ::std::sync::OnceLock<Option<#field_ty>> = ::std::sync::OnceLock::new();
275                    ENV .get_or_init(|| std::env::var(#env_var).ok().and_then(|x| x.parse().ok()))
276                        .clone().unwrap_or_else(|| #default_expr)
277                })
278            } else {
279                quote_spanned!(env.span() =>
280                    std::env::var(#env_var).ok().and_then(|x| x.parse().ok()).unwrap_or_else(|| #default_expr)
281                )
282            }
283        } else {
284            default_expr
285        };
286
287        let default_fn_ident = format!("__fn_default_{}", field_ident);
288        let default_fn_ident = Ident::new(&default_fn_ident, field_ident.span());
289        let field_ident_upper = field_ident.to_string().to_uppercase();
290        let const_offset_ident =
291            Ident::new(&format!("__COFST_{field_ident_upper}"), Span::call_site());
292
293        fn_global_constants.push(quote_spanned!(field_span =>
294            fn #default_fn_ident() -> #field_ty {
295                #default_expr
296            }
297
298            const #const_offset_ident: usize = #this_crate::offset_of!(#struct_ident, #field_ident);
299        ));
300
301        fn_default_config.push(quote_spanned!(field_span => #field_ident: #default_fn_ident(),));
302
303        /* --------------------------------- Metadata Generation -------------------------------- */
304        {
305            let FieldProperty {
306                rename,
307                admin,
308                admin_write,
309                admin_read,
310                min,
311                max,
312                one_of,
313                transient,
314                no_export,
315                no_import,
316                editor,
317                hidden,
318                secret,
319                readonly,
320                writeonly,
321                env,
322                validate_with,
323                ..
324            } = prop;
325
326            let flags = [
327                readonly.then(|| quote!(MetaFlag::READONLY)),
328                writeonly.then(|| quote!(MetaFlag::WRITEONLY)),
329                hidden.then(|| quote!(MetaFlag::HIDDEN)),
330                secret.then(|| quote!(MetaFlag::SECRET)),
331                admin.then(|| quote!(MetaFlag::ADMIN)),
332                admin_write.then(|| quote!(MetaFlag::ADMIN_WRITE)),
333                admin_read.then(|| quote!(MetaFlag::ADMIN_READ)),
334                transient.then(|| quote!(MetaFlag::TRANSIENT)),
335                no_export.then(|| quote!(MetaFlag::NO_EXPORT)),
336                no_import.then(|| quote!(MetaFlag::NO_IMPORT)),
337            ]
338            .into_iter()
339            .flatten();
340
341            let varname = field_ident.to_string();
342            let name = rename
343                .map(|x| Cow::Owned(x.value()))
344                .unwrap_or_else(|| Cow::Borrowed(varname.as_str()));
345            let doc_string = doc_string.join("\n");
346            let none = quote!(None);
347            let env = env
348                .as_ref()
349                .map(|x| x.1.value())
350                .map(|env| quote!(Some(#env)))
351                .unwrap_or_else(|| none.clone());
352            let editor_hint = editor
353                .map(|x| {
354                    let x = quote_spanned!(x.span() =>
355                        MetadataEditorHint::#x
356                    );
357                    quote!(Some(#this_crate::shared::meta::#x))
358                })
359                .unwrap_or_else(|| none.clone());
360
361            let schema = cfg!(feature = "jsonschema").then(|| {
362                quote! {
363                    __default_ref_ptr::<#field_ty>().get_schema()
364                }
365            });
366            let validation_function = {
367                let fn_min = min.map(|x| {
368                    quote!(
369                        if *mref < #x {
370                            editted = true;
371                            *mref = #x;
372                        }
373                    )
374                });
375                let fn_max = max.map(|x| {
376                    quote!(
377                        if *mref > #x {
378                            editted = true;
379                            *mref = #x;
380                        }
381                    )
382                });
383                let fn_one_of = one_of.map(|x| {
384                    quote!(
385                        if #x.into_iter().all(|x| x != *mref) {
386                            return Err("Value is not one of the allowed values".into());
387                        }
388                    )
389                });
390                let fn_user = validate_with.map(|x| {
391                    let Ok(ident) = x.parse::<syn::ExprPath>() else {
392                        emit_error!(x, "Expected valid identifier");
393                        return none.clone();
394                    };
395                    quote!(
396                        match #ident(mref) {
397                            Ok(__entity::Validation::Valid) => {}
398                            Ok(__entity::Validation::Modified) => { editted = true }
399                            Err(e) => return Err(e),
400                        }
401                    )
402                });
403
404                quote! {
405                    let mut editted = false;
406
407                    #fn_min
408                    #fn_max
409                    #fn_one_of
410                    #fn_user
411
412                    if !editted {
413                        Ok(__entity::Validation::Valid)
414                    } else {
415                        Ok(__entity::Validation::Modified)
416                    }
417                }
418            };
419
420            fn_props.push(quote_spanned! { field_span =>
421                {
422                    use #this_crate::config as __config;
423                    use #this_crate::shared as __shared;
424                    use __config::entity as __entity;
425                    use __config::__lookup::*;
426                    use __shared::meta as __meta;
427
428                    __entity::PropertyInfo::new(
429                        /* type_id:*/ std::any::TypeId::of::<#field_ty>(),
430                        /* index:*/ #field_index,
431                        /* metadata:*/ __meta::Metadata::__macro_new(
432                            #name,
433                            #varname,
434                            stringify!(#field_ty),
435                            {
436                                use __meta::MetaFlag;
437                                #(#flags |)* MetaFlag::empty()
438                            },
439                            #editor_hint,
440                            #doc_string,
441                            #env,
442                            #schema // Comma is included
443                        ),
444                        /* vtable:*/ Box::leak(Box::new(__entity::MetadataVTableImpl {
445                            impl_copy: #this_crate::impls!(#field_ty: Copy),
446                            fn_default: #default_fn_ident,
447                            fn_validate: {
448                                fn __validate(mref: &mut #field_ty) -> __entity::ValidationResult {
449                                    let _ = mref; // Allow unused instance
450                                    #validation_function
451                                }
452
453                                __validate
454                            },
455                        }))
456                    )
457                },
458            });
459        }
460
461        /* ------------------------------ Index Access Genenration ------------------------------ */
462        fn_prop_at_offset.push(quote!(#const_offset_ident => Some(#field_index),));
463        fn_elem_at_mut.push(quote!(#field_index => &mut self.#field_ident as &mut dyn Any,));
464
465        /* -------------------------------- Field Index Increment ------------------------------- */
466        field_index += 1;
467    }
468}
469
470fn from_meta_list(meta_list: syn::MetaList) -> Option<FieldProperty> {
471    let mut r = FieldProperty::default();
472    let span = meta_list.span();
473    let Ok(parsed) =
474        meta_list.parse_args_with(<Punctuated<syn::Meta, Token![,]>>::parse_terminated)
475    else {
476        emit_error!(span, "Expected valid list of arguments");
477        return None;
478    };
479
480    for arg in parsed {
481        let is_ = |x: &str| arg.path().is_ident(x);
482        match arg {
483            Meta::Path(_) => {
484                if is_("admin") {
485                    r.admin = true
486                } else if is_("admin_write") {
487                    r.admin_write = true
488                } else if is_("admin_read") {
489                    r.admin_read = true
490                } else if is_("transient") {
491                    r.transient = true
492                } else if is_("no_export") {
493                    r.no_export = true
494                } else if is_("no_import") {
495                    r.no_import = true
496                } else if is_("secret") {
497                    r.secret = true
498                } else if is_("readonly") {
499                    r.readonly = true
500                } else if is_("writeonly") {
501                    r.writeonly = true
502                } else if is_("hidden") {
503                    r.hidden = true
504                } else {
505                    emit_error!(arg, "Unknown attribute")
506                }
507            }
508            Meta::List(_) => {
509                emit_error!(arg, "Unexpected list")
510            }
511            Meta::NameValue(syn::MetaNameValue { value, path, .. }) => {
512                let is_ = |x: &str| path.is_ident(x);
513                if is_("default") {
514                    r.default = Some(FieldPropertyDefault::Expr(value));
515                } else if is_("default_expr") {
516                    r.default = expr_take_lit_str(value).map(FieldPropertyDefault::ExprStr);
517                } else if is_("alias") || is_("rename") {
518                    r.rename = expr_take_lit_str(value);
519                } else if is_("min") {
520                    r.min = Some(value);
521                } else if is_("max") {
522                    r.max = Some(value);
523                } else if is_("one_of") {
524                    let Expr::Array(one_of) = value else {
525                        emit_error!(value, "Expected array literal");
526                        continue;
527                    };
528
529                    r.one_of = Some(one_of);
530                } else if is_("validate_with") {
531                    r.validate_with = expr_take_lit_str(value);
532                } else if is_("env_once") {
533                    r.env = expr_take_lit_str(value).map(|x| (true, x));
534                } else if is_("env") {
535                    r.env = expr_take_lit_str(value).map(|x| (false, x));
536                } else if is_("editor") {
537                    r.editor = Some(value);
538                } else {
539                    emit_error!(path.span(), "Unknown attribute")
540                }
541            }
542        }
543    }
544
545    Some(r)
546}
547
548enum FieldType {
549    Plain,
550    PlainWithDefaultExpr(Expr),
551    Property(FieldProperty),
552}
553
554fn expr_take_lit_str(expr: Expr) -> Option<LitStr> {
555    if let Expr::Lit(ExprLit { lit: Lit::Str(lit), .. }) = expr {
556        Some(lit)
557    } else {
558        emit_error!(expr, "Expected string literal");
559        None
560    }
561}
562
563enum FieldPropertyDefault {
564    Expr(Expr),
565    ExprStr(LitStr),
566}
567
568#[derive(Default)]
569struct FieldProperty {
570    rename: Option<syn::LitStr>,
571    default: Option<FieldPropertyDefault>,
572    admin: bool,
573    admin_write: bool,
574    admin_read: bool,
575    secret: bool,
576    readonly: bool,
577    writeonly: bool,
578    min: Option<syn::Expr>,
579    max: Option<syn::Expr>,
580    one_of: Option<syn::ExprArray>,
581    env: Option<(bool, syn::LitStr)>, // (IsOnce, EnvKey)
582    validate_with: Option<syn::LitStr>,
583    transient: bool,
584    no_export: bool,
585    no_import: bool,
586    editor: Option<syn::Expr>,
587    hidden: bool,
588}
589
590fn this_crate_name() -> TokenStream {
591    use proc_macro_crate::*;
592
593    match crate_name("config-it") {
594        Ok(FoundCrate::Itself) => quote!(::config_it),
595        Ok(FoundCrate::Name(name)) => {
596            let ident = Ident::new(&name, Span::call_site());
597            quote!(::#ident)
598        }
599
600        Err(_) => {
601            // HACK: We may handle the re-exported crate that was aliased as 'config_it'
602            quote!(config_it)
603        }
604    }
605}
606
607struct GenInputCommon<'a> {
608    this_crate: &'a TokenStream,
609    struct_ident: &'a Ident,
610}
611
612#[derive(Default)]
613struct GenContext {
614    fn_props: Vec<TokenStream>,
615    fn_prop_at_offset: Vec<TokenStream>,
616    fn_global_constants: Vec<TokenStream>,
617    fn_default_config: Vec<TokenStream>,
618    fn_elem_at_mut: Vec<TokenStream>,
619}