askit_macros/
lib.rs

1#![recursion_limit = "256"]
2//! Procedural macros for agent-stream-kit.
3//!
4//! Provides an attribute to declare agent metadata alongside the agent type and
5//! generate the registration boilerplate.
6
7use proc_macro::TokenStream;
8use proc_macro2::Span;
9use quote::{format_ident, quote};
10use syn::{
11    Expr, ItemStruct, Meta, MetaList, Type, parse_macro_input, parse_quote, punctuated::Punctuated,
12    spanned::Spanned, token::Comma,
13};
14
15#[proc_macro_attribute]
16pub fn askit(attr: TokenStream, item: TokenStream) -> TokenStream {
17    askit_agent(attr, item)
18}
19
20/// Declare agent metadata and generate `agent_definition` / `register` helpers.
21///
22/// Example:
23/// ```rust,ignore
24/// #[askit_agent(
25///     title = "Add Int",
26///     category = "Utils",
27///     inputs = ["int"],
28///     outputs = ["int"],
29///     integer_config(
30///         name = "n",
31///         default = 1,
32///     )
33/// )]
34/// struct AdderAgent { /* ... */ }
35/// ```
36#[proc_macro_attribute]
37pub fn askit_agent(attr: TokenStream, item: TokenStream) -> TokenStream {
38    let args = parse_macro_input!(attr with Punctuated<Meta, Comma>::parse_terminated);
39    let item_struct = parse_macro_input!(item as ItemStruct);
40
41    match expand_askit_agent(args, item_struct) {
42        Ok(tokens) => tokens.into(),
43        Err(err) => err.into_compile_error().into(),
44    }
45}
46
47struct AgentArgs {
48    kind: Option<Expr>,
49    name: Option<Expr>,
50    title: Option<Expr>,
51    description: Option<Expr>,
52    category: Option<Expr>,
53    inputs: Vec<Expr>,
54    outputs: Vec<Expr>,
55    configs: Vec<ConfigSpec>,
56    global_configs: Vec<ConfigSpec>,
57}
58
59#[derive(Default)]
60struct CommonConfig {
61    name: Option<Expr>,
62    default: Option<Expr>,
63    title: Option<Expr>,
64    description: Option<Expr>,
65    hide_title: bool,
66    readonly: bool,
67}
68
69struct CustomConfig {
70    name: Expr,
71    default: Expr,
72    type_: Expr,
73    title: Option<Expr>,
74    description: Option<Expr>,
75    hide_title: bool,
76    readonly: bool,
77}
78
79enum ConfigSpec {
80    Unit(CommonConfig),
81    Boolean(CommonConfig),
82    Integer(CommonConfig),
83    Number(CommonConfig),
84    String(CommonConfig),
85    Text(CommonConfig),
86    Object(CommonConfig),
87    Custom(CustomConfig),
88}
89
90fn expand_askit_agent(
91    args: Punctuated<Meta, Comma>,
92    item: ItemStruct,
93) -> syn::Result<proc_macro2::TokenStream> {
94    let has_data_field = item.fields.iter().any(|f| match (&f.ident, &f.ty) {
95        (Some(ident), Type::Path(tp)) if ident == "data" => tp
96            .path
97            .segments
98            .last()
99            .map(|seg| seg.ident == "AgentData")
100            .unwrap_or(false),
101        _ => false,
102    });
103
104    if !has_data_field {
105        return Err(syn::Error::new(
106            item.span(),
107            "#[askit_agent] expects the struct to have a `data: AgentData` field",
108        ));
109    }
110
111    let mut parsed = AgentArgs {
112        kind: None,
113        name: None,
114        title: None,
115        description: None,
116        category: None,
117        inputs: Vec::new(),
118        outputs: Vec::new(),
119        configs: Vec::new(),
120        global_configs: Vec::new(),
121    };
122
123    for meta in args {
124        match meta {
125            Meta::NameValue(nv) if nv.path.is_ident("kind") => {
126                parsed.kind = Some(nv.value);
127            }
128            Meta::NameValue(nv) if nv.path.is_ident("name") => {
129                parsed.name = Some(nv.value);
130            }
131            Meta::NameValue(nv) if nv.path.is_ident("title") => {
132                parsed.title = Some(nv.value);
133            }
134            Meta::NameValue(nv) if nv.path.is_ident("description") => {
135                parsed.description = Some(nv.value);
136            }
137            Meta::NameValue(nv) if nv.path.is_ident("category") => {
138                parsed.category = Some(nv.value);
139            }
140            Meta::NameValue(nv) if nv.path.is_ident("inputs") => {
141                parsed.inputs = parse_expr_array(nv.value)?;
142            }
143            Meta::NameValue(nv) if nv.path.is_ident("outputs") => {
144                parsed.outputs = parse_expr_array(nv.value)?;
145            }
146            Meta::List(ml) if ml.path.is_ident("inputs") => {
147                parsed.inputs = collect_exprs(ml)?;
148            }
149            Meta::List(ml) if ml.path.is_ident("outputs") => {
150                parsed.outputs = collect_exprs(ml)?;
151            }
152            Meta::List(ml) if ml.path.is_ident("string_config") => {
153                parsed
154                    .configs
155                    .push(ConfigSpec::String(parse_common_config(ml)?));
156            }
157            Meta::List(ml) if ml.path.is_ident("text_config") => {
158                parsed
159                    .configs
160                    .push(ConfigSpec::Text(parse_common_config(ml)?));
161            }
162            Meta::List(ml) if ml.path.is_ident("boolean_config") => {
163                parsed
164                    .configs
165                    .push(ConfigSpec::Boolean(parse_common_config(ml)?));
166            }
167            Meta::List(ml) if ml.path.is_ident("integer_config") => {
168                parsed
169                    .configs
170                    .push(ConfigSpec::Integer(parse_common_config(ml)?));
171            }
172            Meta::List(ml) if ml.path.is_ident("number_config") => {
173                parsed
174                    .configs
175                    .push(ConfigSpec::Number(parse_common_config(ml)?));
176            }
177            Meta::List(ml) if ml.path.is_ident("object_config") => {
178                parsed
179                    .configs
180                    .push(ConfigSpec::Object(parse_common_config(ml)?));
181            }
182            Meta::List(ml) if ml.path.is_ident("custom_config") => {
183                parsed
184                    .configs
185                    .push(ConfigSpec::Custom(parse_custom_config(ml)?));
186            }
187            Meta::List(ml) if ml.path.is_ident("unit_config") => {
188                parsed
189                    .configs
190                    .push(ConfigSpec::Unit(parse_common_config(ml)?));
191            }
192            Meta::List(ml) if ml.path.is_ident("string_global_config") => {
193                parsed
194                    .global_configs
195                    .push(ConfigSpec::String(parse_common_config(ml)?));
196            }
197            Meta::List(ml) if ml.path.is_ident("text_global_config") => {
198                parsed
199                    .global_configs
200                    .push(ConfigSpec::Text(parse_common_config(ml)?));
201            }
202            Meta::List(ml) if ml.path.is_ident("boolean_global_config") => {
203                parsed
204                    .global_configs
205                    .push(ConfigSpec::Boolean(parse_common_config(ml)?));
206            }
207            Meta::List(ml) if ml.path.is_ident("integer_global_config") => {
208                parsed
209                    .global_configs
210                    .push(ConfigSpec::Integer(parse_common_config(ml)?));
211            }
212            Meta::List(ml) if ml.path.is_ident("number_global_config") => {
213                parsed
214                    .global_configs
215                    .push(ConfigSpec::Number(parse_common_config(ml)?));
216            }
217            Meta::List(ml) if ml.path.is_ident("object_global_config") => {
218                parsed
219                    .global_configs
220                    .push(ConfigSpec::Object(parse_common_config(ml)?));
221            }
222            Meta::List(ml) if ml.path.is_ident("custom_global_config") => {
223                parsed
224                    .global_configs
225                    .push(ConfigSpec::Custom(parse_custom_config(ml)?));
226            }
227            Meta::List(ml) if ml.path.is_ident("unit_global_config") => {
228                parsed
229                    .global_configs
230                    .push(ConfigSpec::Unit(parse_common_config(ml)?));
231            }
232            other => {
233                return Err(syn::Error::new_spanned(
234                    other,
235                    "unsupported askit_agent argument",
236                ));
237            }
238        }
239    }
240
241    let ident = &item.ident;
242    let generics = item.generics.clone();
243    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
244    let data_impl = quote! {
245        impl #impl_generics ::agent_stream_kit::HasAgentData for #ident #ty_generics #where_clause {
246            fn data(&self) -> &::agent_stream_kit::AgentData {
247                &self.data
248            }
249
250            fn mut_data(&mut self) -> &mut ::agent_stream_kit::AgentData {
251                &mut self.data
252            }
253        }
254    };
255
256    let kind = parsed.kind.unwrap_or_else(|| parse_quote! { "Agent" });
257    let name_tokens = parsed.name.map(|n| quote! { #n }).unwrap_or_else(|| {
258        quote! { concat!(module_path!(), "::", stringify!(#ident)) }
259    });
260
261    let title = parsed
262        .title
263        .ok_or_else(|| syn::Error::new(Span::call_site(), "askit_agent: missing `title`"))?;
264    let category = parsed
265        .category
266        .ok_or_else(|| syn::Error::new(Span::call_site(), "askit_agent: missing `category`"))?;
267    let title = quote! { .title(#title) };
268    let description = parsed.description.map(|d| quote! { .description(#d) });
269    let category = quote! { .category(#category) };
270
271    let inputs = if parsed.inputs.is_empty() {
272        quote! {}
273    } else {
274        let values = parsed.inputs;
275        quote! { .inputs(vec![#(#values),*]) }
276    };
277
278    let outputs = if parsed.outputs.is_empty() {
279        quote! {}
280    } else {
281        let values = parsed.outputs;
282        quote! { .outputs(vec![#(#values),*]) }
283    };
284
285    let config_calls = parsed
286        .configs
287        .into_iter()
288        .map(|cfg| match cfg {
289            ConfigSpec::Unit(c) => {
290                let name = c.name.ok_or_else(|| {
291                    syn::Error::new(Span::call_site(), "unit_config missing `name`")
292                })?;
293                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
294                let description = c
295                    .description
296                    .map(|d| quote! { let entry = entry.description(#d); });
297                let hide_title = if c.hide_title {
298                    quote! { let entry = entry.hide_title(); }
299                } else {
300                    quote! {}
301                };
302                let readonly = if c.readonly {
303                    quote! { let entry = entry.readonly(); }
304                } else {
305                    quote! {}
306                };
307                Ok(quote! {
308                    .unit_config_with(#name, |entry| {
309                        let entry = entry;
310                        #title
311                        #description
312                        #hide_title
313                        #readonly
314                        entry
315                    })
316                })
317            }
318            ConfigSpec::Boolean(c) => {
319                let name = c.name.ok_or_else(|| {
320                    syn::Error::new(Span::call_site(), "boolean_config missing `name`")
321                })?;
322                let default = c.default.unwrap_or_else(|| parse_quote! { false });
323                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
324                let description = c
325                    .description
326                    .map(|d| quote! { let entry = entry.description(#d); });
327                let hide_title = if c.hide_title {
328                    quote! { let entry = entry.hide_title(); }
329                } else {
330                    quote! {}
331                };
332                let readonly = if c.readonly {
333                    quote! { let entry = entry.readonly(); }
334                } else {
335                    quote! {}
336                };
337                Ok(quote! {
338                    .boolean_config_with(#name, #default, |entry| {
339                        let entry = entry;
340                        #title
341                        #description
342                        #hide_title
343                        #readonly
344                        entry
345                    })
346                })
347            }
348            ConfigSpec::Integer(c) => {
349                let name = c.name.ok_or_else(|| {
350                    syn::Error::new(Span::call_site(), "integer_config missing `name`")
351                })?;
352                let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
353                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
354                let description = c
355                    .description
356                    .map(|d| quote! { let entry = entry.description(#d); });
357                let hide_title = if c.hide_title {
358                    quote! { let entry = entry.hide_title(); }
359                } else {
360                    quote! {}
361                };
362                let readonly = if c.readonly {
363                    quote! { let entry = entry.readonly(); }
364                } else {
365                    quote! {}
366                };
367                Ok(quote! {
368                    .integer_config_with(#name, #default, |entry| {
369                        let entry = entry;
370                        #title
371                        #description
372                        #hide_title
373                        #readonly
374                        entry
375                    })
376                })
377            }
378            ConfigSpec::Number(c) => {
379                let name = c.name.ok_or_else(|| {
380                    syn::Error::new(Span::call_site(), "number_config missing `name`")
381                })?;
382                let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
383                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
384                let description = c
385                    .description
386                    .map(|d| quote! { let entry = entry.description(#d); });
387                let hide_title = if c.hide_title {
388                    quote! { let entry = entry.hide_title(); }
389                } else {
390                    quote! {}
391                };
392                let readonly = if c.readonly {
393                    quote! { let entry = entry.readonly(); }
394                } else {
395                    quote! {}
396                };
397                Ok(quote! {
398                    .number_config_with(#name, #default, |entry| {
399                        let entry = entry;
400                        #title
401                        #description
402                        #hide_title
403                        #readonly
404                        entry
405                    })
406                })
407            }
408            ConfigSpec::String(c) => {
409                let name = c.name.ok_or_else(|| {
410                    syn::Error::new(Span::call_site(), "string_config missing `name`")
411                })?;
412                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
413                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
414                let description = c
415                    .description
416                    .map(|d| quote! { let entry = entry.description(#d); });
417                let hide_title = if c.hide_title {
418                    quote! { let entry = entry.hide_title(); }
419                } else {
420                    quote! {}
421                };
422                let readonly = if c.readonly {
423                    quote! { let entry = entry.readonly(); }
424                } else {
425                    quote! {}
426                };
427                Ok(quote! {
428                    .string_config_with(#name, #default, |entry| {
429                        let entry = entry;
430                        #title
431                        #description
432                        #hide_title
433                        #readonly
434                        entry
435                    })
436                })
437            }
438            ConfigSpec::Text(c) => {
439                let name = c.name.ok_or_else(|| {
440                    syn::Error::new(Span::call_site(), "text_config missing `name`")
441                })?;
442                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
443                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
444                let description = c
445                    .description
446                    .map(|d| quote! { let entry = entry.description(#d); });
447                let hide_title = if c.hide_title {
448                    quote! { let entry = entry.hide_title(); }
449                } else {
450                    quote! {}
451                };
452                let readonly = if c.readonly {
453                    quote! { let entry = entry.readonly(); }
454                } else {
455                    quote! {}
456                };
457                Ok(quote! {
458                    .text_config_with(#name, #default, |entry| {
459                        let entry = entry;
460                        #title
461                        #description
462                        #hide_title
463                        #readonly
464                        entry
465                    })
466                })
467            }
468            ConfigSpec::Object(c) => {
469                let name = c.name.ok_or_else(|| {
470                    syn::Error::new(Span::call_site(), "object_config missing `name`")
471                })?;
472                let default = c.default.unwrap_or_else(|| {
473                    parse_quote! { ::agent_stream_kit::AgentValue::object_default() }
474                });
475                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
476                let description = c
477                    .description
478                    .map(|d| quote! { let entry = entry.description(#d); });
479                let hide_title = if c.hide_title {
480                    quote! { let entry = entry.hide_title(); }
481                } else {
482                    quote! {}
483                };
484                let readonly = if c.readonly {
485                    quote! { let entry = entry.readonly(); }
486                } else {
487                    quote! {}
488                };
489                Ok(quote! {
490                    .object_config_with(#name, #default, |entry| {
491                        let entry = entry;
492                        #title
493                        #description
494                        #hide_title
495                        #readonly
496                        entry
497                    })
498                })
499            }
500            ConfigSpec::Custom(c) => custom_config_call("custom_config_with", c),
501        })
502        .collect::<syn::Result<Vec<_>>>()?;
503
504    let global_config_calls = parsed
505        .global_configs
506        .into_iter()
507        .map(|cfg| match cfg {
508            ConfigSpec::Unit(c) => {
509                let name = c.name.ok_or_else(|| {
510                    syn::Error::new(Span::call_site(), "unit_global_config missing `name`")
511                })?;
512                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
513                let description = c
514                    .description
515                    .map(|d| quote! { let entry = entry.description(#d); });
516                let hide_title = if c.hide_title {
517                    quote! { let entry = entry.hide_title(); }
518                } else {
519                    quote! {}
520                };
521                let readonly = if c.readonly {
522                    quote! { let entry = entry.readonly(); }
523                } else {
524                    quote! {}
525                };
526                Ok(quote! {
527                    .unit_global_config_with(#name, |entry| {
528                        let entry = entry;
529                        #title
530                        #description
531                        #hide_title
532                        #readonly
533                        entry
534                    })
535                })
536            }
537            ConfigSpec::Boolean(c) => {
538                let name = c.name.ok_or_else(|| {
539                    syn::Error::new(Span::call_site(), "boolean_global_config missing `name`")
540                })?;
541                let default = c.default.unwrap_or_else(|| parse_quote! { false });
542                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
543                let description = c
544                    .description
545                    .map(|d| quote! { let entry = entry.description(#d); });
546                let hide_title = if c.hide_title {
547                    quote! { let entry = entry.hide_title(); }
548                } else {
549                    quote! {}
550                };
551                let readonly = if c.readonly {
552                    quote! { let entry = entry.readonly(); }
553                } else {
554                    quote! {}
555                };
556                Ok(quote! {
557                    .boolean_global_config_with(#name, #default, |entry| {
558                        let entry = entry;
559                        #title
560                        #description
561                        #hide_title
562                        #readonly
563                        entry
564                    })
565                })
566            }
567            ConfigSpec::Integer(c) => {
568                let name = c.name.ok_or_else(|| {
569                    syn::Error::new(Span::call_site(), "integer_global_config missing `name`")
570                })?;
571                let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
572                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
573                let description = c
574                    .description
575                    .map(|d| quote! { let entry = entry.description(#d); });
576                let hide_title = if c.hide_title {
577                    quote! { let entry = entry.hide_title(); }
578                } else {
579                    quote! {}
580                };
581                let readonly = if c.readonly {
582                    quote! { let entry = entry.readonly(); }
583                } else {
584                    quote! {}
585                };
586                Ok(quote! {
587                    .integer_global_config_with(#name, #default, |entry| {
588                        let entry = entry;
589                        #title
590                        #description
591                        #hide_title
592                        #readonly
593                        entry
594                    })
595                })
596            }
597            ConfigSpec::Number(c) => {
598                let name = c.name.ok_or_else(|| {
599                    syn::Error::new(Span::call_site(), "number_global_config missing `name`")
600                })?;
601                let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
602                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
603                let description = c
604                    .description
605                    .map(|d| quote! { let entry = entry.description(#d); });
606                let hide_title = if c.hide_title {
607                    quote! { let entry = entry.hide_title(); }
608                } else {
609                    quote! {}
610                };
611                let readonly = if c.readonly {
612                    quote! { let entry = entry.readonly(); }
613                } else {
614                    quote! {}
615                };
616                Ok(quote! {
617                    .number_global_config_with(#name, #default, |entry| {
618                        let entry = entry;
619                        #title
620                        #description
621                        #hide_title
622                        #readonly
623                        entry
624                    })
625                })
626            }
627            ConfigSpec::String(c) => {
628                let name = c.name.ok_or_else(|| {
629                    syn::Error::new(Span::call_site(), "string_global_config missing `name`")
630                })?;
631                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
632                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
633                let description = c
634                    .description
635                    .map(|d| quote! { let entry = entry.description(#d); });
636                let hide_title = if c.hide_title {
637                    quote! { let entry = entry.hide_title(); }
638                } else {
639                    quote! {}
640                };
641                let readonly = if c.readonly {
642                    quote! { let entry = entry.readonly(); }
643                } else {
644                    quote! {}
645                };
646                Ok(quote! {
647                    .string_global_config_with(#name, #default, |entry| {
648                        let entry = entry;
649                        #title
650                        #description
651                        #hide_title
652                        #readonly
653                        entry
654                    })
655                })
656            }
657            ConfigSpec::Text(c) => {
658                let name = c.name.ok_or_else(|| {
659                    syn::Error::new(Span::call_site(), "text_global_config missing `name`")
660                })?;
661                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
662                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
663                let description = c
664                    .description
665                    .map(|d| quote! { let entry = entry.description(#d); });
666                let hide_title = if c.hide_title {
667                    quote! { let entry = entry.hide_title(); }
668                } else {
669                    quote! {}
670                };
671                let readonly = if c.readonly {
672                    quote! { let entry = entry.readonly(); }
673                } else {
674                    quote! {}
675                };
676                Ok(quote! {
677                    .text_global_config_with(#name, #default, |entry| {
678                        let entry = entry;
679                        #title
680                        #description
681                        #hide_title
682                        #readonly
683                        entry
684                    })
685                })
686            }
687            ConfigSpec::Object(c) => {
688                let name = c.name.ok_or_else(|| {
689                    syn::Error::new(Span::call_site(), "object_global_config missing `name`")
690                })?;
691                let default = c.default.unwrap_or_else(|| {
692                    parse_quote! { ::agent_stream_kit::AgentValue::object_default() }
693                });
694                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
695                let description = c
696                    .description
697                    .map(|d| quote! { let entry = entry.description(#d); });
698                let hide_title = if c.hide_title {
699                    quote! { let entry = entry.hide_title(); }
700                } else {
701                    quote! {}
702                };
703                let readonly = if c.readonly {
704                    quote! { let entry = entry.readonly(); }
705                } else {
706                    quote! {}
707                };
708                Ok(quote! {
709                    .object_global_config_with(#name, #default, |entry| {
710                        let entry = entry;
711                        #title
712                        #description
713                        #hide_title
714                        #readonly
715                        entry
716                    })
717                })
718            }
719            ConfigSpec::Custom(c) => custom_config_call("custom_global_config_with", c),
720        })
721        .collect::<syn::Result<Vec<_>>>()?;
722
723    let definition_builder = quote! {
724        ::agent_stream_kit::AgentDefinition::new(
725            #kind,
726            #name_tokens,
727            Some(::agent_stream_kit::new_agent_boxed::<#ident>),
728        )
729        #title
730        #description
731        #category
732        #inputs
733        #outputs
734        #(#config_calls)*
735        #(#global_config_calls)*
736    };
737
738    let expanded = quote! {
739        #item
740
741        #data_impl
742
743        impl #impl_generics #ident #ty_generics #where_clause {
744            pub const DEF_NAME: &'static str = #name_tokens;
745
746            pub fn def_name() -> &'static str { Self::DEF_NAME }
747
748            pub fn agent_definition() -> ::agent_stream_kit::AgentDefinition {
749                #definition_builder
750            }
751
752            pub fn register(askit: &::agent_stream_kit::ASKit) {
753                askit.register_agent_definiton(Self::agent_definition());
754            }
755        }
756
757        ::agent_stream_kit::inventory::submit! {
758            ::agent_stream_kit::AgentRegistration {
759                build: || #definition_builder,
760            }
761        }
762    };
763
764    Ok(expanded)
765}
766
767fn parse_name_type_title_description(
768    meta: &Meta,
769    name: &mut Option<Expr>,
770    type_: &mut Option<Expr>,
771    title: &mut Option<Expr>,
772    description: &mut Option<Expr>,
773) -> bool {
774    match meta {
775        Meta::NameValue(nv) if nv.path.is_ident("name") => {
776            *name = Some(nv.value.clone());
777            true
778        }
779        Meta::NameValue(nv) if nv.path.is_ident("type") => {
780            *type_ = Some(nv.value.clone());
781            true
782        }
783        Meta::NameValue(nv) if nv.path.is_ident("type_") => {
784            *type_ = Some(nv.value.clone());
785            true
786        }
787        Meta::NameValue(nv) if nv.path.is_ident("title") => {
788            *title = Some(nv.value.clone());
789            true
790        }
791        Meta::NameValue(nv) if nv.path.is_ident("description") => {
792            *description = Some(nv.value.clone());
793            true
794        }
795        _ => false,
796    }
797}
798
799fn parse_custom_config(list: MetaList) -> syn::Result<CustomConfig> {
800    let mut name = None;
801    let mut default = None;
802    let mut type_ = None;
803    let mut title = None;
804    let mut description = None;
805    let mut hide_title = false;
806    let mut readonly = false;
807    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
808
809    for meta in nested {
810        if parse_name_type_title_description(
811            &meta,
812            &mut name,
813            &mut type_,
814            &mut title,
815            &mut description,
816        ) {
817            continue;
818        }
819
820        match meta {
821            Meta::NameValue(nv) if nv.path.is_ident("default") => {
822                default = Some(nv.value.clone());
823            }
824            Meta::Path(p) if p.is_ident("hide_title") => {
825                hide_title = true;
826            }
827            Meta::Path(p) if p.is_ident("readonly") => {
828                readonly = true;
829            }
830            other => {
831                return Err(syn::Error::new_spanned(
832                    other,
833                    "custom_config supports name, default, type/type_, title, description, hide_title, readonly",
834                ));
835            }
836        }
837    }
838
839    let name = name.ok_or_else(|| syn::Error::new(list.span(), "config missing `name`"))?;
840    let default =
841        default.ok_or_else(|| syn::Error::new(list.span(), "config missing `default`"))?;
842    let type_ = type_.ok_or_else(|| syn::Error::new(list.span(), "config missing `type`"))?;
843
844    Ok(CustomConfig {
845        name,
846        default,
847        type_,
848        title,
849        description,
850        hide_title,
851        readonly,
852    })
853}
854
855fn collect_exprs(list: MetaList) -> syn::Result<Vec<Expr>> {
856    let values = list.parse_args_with(Punctuated::<Expr, Comma>::parse_terminated)?;
857    Ok(values.into_iter().collect())
858}
859
860fn parse_expr_array(expr: Expr) -> syn::Result<Vec<Expr>> {
861    if let Expr::Array(arr) = expr {
862        Ok(arr.elems.into_iter().collect())
863    } else {
864        Err(syn::Error::new_spanned(
865            expr,
866            "inputs/outputs expect array expressions",
867        ))
868    }
869}
870
871fn parse_common_config(list: MetaList) -> syn::Result<CommonConfig> {
872    let mut cfg = CommonConfig::default();
873    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
874
875    for meta in nested {
876        match meta {
877            Meta::NameValue(nv) if nv.path.is_ident("name") => {
878                cfg.name = Some(nv.value.clone());
879            }
880            Meta::NameValue(nv) if nv.path.is_ident("default") => {
881                cfg.default = Some(nv.value.clone());
882            }
883            Meta::NameValue(nv) if nv.path.is_ident("title") => {
884                cfg.title = Some(nv.value.clone());
885            }
886            Meta::NameValue(nv) if nv.path.is_ident("description") => {
887                cfg.description = Some(nv.value.clone());
888            }
889            Meta::Path(p) if p.is_ident("hide_title") => {
890                cfg.hide_title = true;
891            }
892            Meta::Path(p) if p.is_ident("readonly") => {
893                cfg.readonly = true;
894            }
895            other => {
896                return Err(syn::Error::new_spanned(
897                    other,
898                    "config supports name, default, title, description, hide_title, readonly",
899                ));
900            }
901        }
902    }
903
904    if cfg.name.is_none() {
905        return Err(syn::Error::new(list.span(), "config missing `name`"));
906    }
907    Ok(cfg)
908}
909
910fn custom_config_call(method: &str, cfg: CustomConfig) -> syn::Result<proc_macro2::TokenStream> {
911    let CustomConfig {
912        name,
913        default,
914        type_,
915        title,
916        description,
917        hide_title,
918        readonly,
919    } = cfg;
920    let title = title.map(|t| quote! { let entry = entry.title(#t); });
921    let description = description.map(|d| quote! { let entry = entry.description(#d); });
922    let hide_title = if hide_title {
923        quote! { let entry = entry.hide_title(); }
924    } else {
925        quote! {}
926    };
927    let readonly = if readonly {
928        quote! { let entry = entry.readonly(); }
929    } else {
930        quote! {}
931    };
932    let method_ident = format_ident!("{}", method);
933
934    Ok(quote! {
935        .#method_ident(#name, #default, #type_, |entry| {
936            let entry = entry;
937            #title
938            #description
939            #hide_title
940            #readonly
941            entry
942        })
943    })
944}