Skip to main content

plushie_core_macros/
lib.rs

1//! Derive macros for Plushie widget development.
2//!
3//! - [`PlushieEnum`]: Make an enum usable as a widget property type.
4//!   Variants become snake_case strings on the wire.
5//!
6//! - [`WidgetEvent`]: Declare widget events as an enum for typed
7//!   emission via `EventResult::emit_event()`.
8//!
9//! - [`WidgetCommand`]: Declare widget commands as an enum for typed
10//!   construction via `Command::widget()`.
11//!
12//! - [`WidgetProps`]: Define your widget's properties as a struct
13//!   and get typed extraction from the widget tree.
14//!
15//! - [`PlushieWidget`]: Generate `type_names` and `fresh_for_session`
16//!   for simple stateless widgets.
17//!
18//! - [`widget!`]: Function-like macro for declaring a custom widget in a
19//!   single invocation. Generates the struct, builder, `From<_> for
20//!   TreeNode` conversion, and a build-time metadata const.
21
22use proc_macro::TokenStream;
23use quote::{format_ident, quote};
24use syn::{Data, DeriveInput, Fields, Lit, parse_macro_input};
25
26// ---------------------------------------------------------------------------
27// PlushieEnum derive
28// ---------------------------------------------------------------------------
29
30/// Make an enum usable as a widget property type.
31///
32/// Variants become snake_case strings on the wire. For example,
33/// `WordOrGlyph` becomes `"word_or_glyph"`.
34///
35/// # Example
36///
37/// ```ignore
38/// #[derive(Debug, Clone, Copy, PartialEq, Eq, PlushieEnum)]
39/// #[plushie_type(name = "direction")]
40/// pub enum Direction {
41///     Horizontal,
42///     Vertical,
43///     Both,
44/// }
45///
46/// // Now usable as a widget property type:
47/// // field :direction, Direction
48/// // builder: .direction(Direction::Horizontal)
49/// ```
50///
51/// # Custom wire names
52///
53/// If a variant's wire name doesn't match its snake_case form,
54/// override it with `#[plushie(wire = "custom_name")]`. Add
55/// alternative names the decoder should accept with
56/// `#[plushie(aliases = ["old_name"])]`.
57#[proc_macro_derive(PlushieEnum, attributes(plushie_type, plushie))]
58pub fn derive_plushie_enum(input: TokenStream) -> TokenStream {
59    let input = parse_macro_input!(input as DeriveInput);
60
61    match derive_enum_impl(&input) {
62        Ok(tokens) => tokens.into(),
63        Err(err) => err.to_compile_error().into(),
64    }
65}
66
67/// Per-variant metadata extracted from attributes.
68struct VariantMeta {
69    ident: syn::Ident,
70    wire_name: String,
71    aliases: Vec<String>,
72}
73
74fn derive_enum_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
75    let type_name = extract_plushie_type_name(input)?;
76
77    let variants = match &input.data {
78        Data::Enum(data) => &data.variants,
79        _ => {
80            return Err(syn::Error::new_spanned(
81                &input.ident,
82                "PlushieEnum can only be derived for enums",
83            ));
84        }
85    };
86
87    // Reject variants with fields (tuple or struct variants).
88    for v in variants {
89        if !matches!(v.fields, Fields::Unit) {
90            return Err(syn::Error::new_spanned(
91                v,
92                "PlushieEnum requires all variants to be unit variants (no fields)",
93            ));
94        }
95    }
96
97    let metas: Vec<VariantMeta> = variants
98        .iter()
99        .map(extract_variant_meta)
100        .collect::<syn::Result<_>>()?;
101
102    let enum_name = &input.ident;
103
104    // wire_decode match arms: canonical name + aliases
105    let decode_arms = metas.iter().map(|m| {
106        let ident = &m.ident;
107        let wire = &m.wire_name;
108        let alias_pats = m.aliases.iter().map(|a| quote! { | #a });
109        quote! {
110            #wire #(#alias_pats)* => ::core::option::Option::Some(Self::#ident)
111        }
112    });
113
114    // wire_encode match arms
115    let encode_arms = metas.iter().map(|m| {
116        let ident = &m.ident;
117        let wire = &m.wire_name;
118        quote! {
119            Self::#ident => #wire
120        }
121    });
122
123    // extract match arms (same as decode but for &str from Props)
124    let extract_arms = metas.iter().map(|m| {
125        let ident = &m.ident;
126        let wire = &m.wire_name;
127        let alias_pats = m.aliases.iter().map(|a| quote! { | #a });
128        quote! {
129            #wire #(#alias_pats)* => ::core::option::Option::Some(Self::#ident)
130        }
131    });
132
133    Ok(quote! {
134        impl ::plushie_core::types::PlushieType for #enum_name {
135            fn wire_decode(value: &::serde_json::Value) -> ::core::option::Option<Self> {
136                match value.as_str()? {
137                    #(#decode_arms,)*
138                    _ => ::core::option::Option::None,
139                }
140            }
141
142            fn wire_encode(&self) -> ::plushie_core::protocol::PropValue {
143                ::plushie_core::protocol::PropValue::Str(
144                    match self {
145                        #(#encode_arms,)*
146                    }
147                    .into(),
148                )
149            }
150
151            fn extract(
152                props: &::plushie_core::protocol::Props,
153                key: &str,
154            ) -> ::core::option::Option<Self> {
155                match props.get_str(key)? {
156                    #(#extract_arms,)*
157                    _ => ::core::option::Option::None,
158                }
159            }
160
161            fn type_name() -> &'static str {
162                #type_name
163            }
164        }
165    })
166}
167
168fn extract_plushie_type_name(input: &DeriveInput) -> syn::Result<String> {
169    for attr in &input.attrs {
170        if attr.path().is_ident("plushie_type") {
171            let mut name = None;
172            attr.parse_nested_meta(|meta| {
173                if meta.path.is_ident("name") {
174                    let value = meta.value()?;
175                    let lit: Lit = value.parse()?;
176                    if let Lit::Str(s) = lit {
177                        name = Some(s.value());
178                        Ok(())
179                    } else {
180                        Err(meta.error("expected string literal for plushie_type name"))
181                    }
182                } else {
183                    Err(meta.error("unknown plushie_type attribute, expected `name`"))
184                }
185            })?;
186            return name.ok_or_else(|| {
187                syn::Error::new_spanned(attr, "plushie_type attribute requires name = \"...\"")
188            });
189        }
190    }
191    Err(syn::Error::new_spanned(
192        &input.ident,
193        "PlushieEnum requires #[plushie_type(name = \"...\")] attribute",
194    ))
195}
196
197fn extract_variant_meta(variant: &syn::Variant) -> syn::Result<VariantMeta> {
198    let ident = variant.ident.clone();
199    let mut wire_name: Option<String> = None;
200    let mut aliases: Vec<String> = Vec::new();
201
202    for attr in &variant.attrs {
203        if attr.path().is_ident("plushie") {
204            attr.parse_nested_meta(|meta| {
205                if meta.path.is_ident("wire") {
206                    let value = meta.value()?;
207                    let lit: Lit = value.parse()?;
208                    if let Lit::Str(s) = lit {
209                        wire_name = Some(s.value());
210                        Ok(())
211                    } else {
212                        Err(meta.error("expected string literal for wire name"))
213                    }
214                } else if meta.path.is_ident("aliases") {
215                    let value = meta.value()?;
216                    let array: syn::ExprArray = value.parse()?;
217                    for elem in &array.elems {
218                        if let syn::Expr::Lit(syn::ExprLit {
219                            lit: Lit::Str(s), ..
220                        }) = elem
221                        {
222                            aliases.push(s.value());
223                        } else {
224                            return Err(syn::Error::new_spanned(
225                                elem,
226                                "expected string literal in aliases array",
227                            ));
228                        }
229                    }
230                    Ok(())
231                } else {
232                    Err(meta.error("unknown plushie attribute, expected `wire` or `aliases`"))
233                }
234            })?;
235        }
236    }
237
238    let wire_name = wire_name.unwrap_or_else(|| pascal_to_snake(&ident.to_string()));
239
240    Ok(VariantMeta {
241        ident,
242        wire_name,
243        aliases,
244    })
245}
246
247/// Convert PascalCase to snake_case.
248///
249/// Keeps acronym runs together, respects existing underscores, and
250/// splits numeric suffixes before the next capitalized word.
251fn pascal_to_snake(s: &str) -> String {
252    let mut result = String::with_capacity(s.len() + 4);
253    let chars: Vec<char> = s.chars().collect();
254
255    for (i, &c) in chars.iter().enumerate() {
256        if c == '_' {
257            if !result.ends_with('_') && !result.is_empty() {
258                result.push('_');
259            }
260            continue;
261        }
262
263        if i > 0 && should_insert_snake_boundary(chars[i - 1], c, chars.get(i + 1).copied()) {
264            result.push('_');
265        }
266
267        if c.is_uppercase() {
268            result.extend(c.to_lowercase());
269        } else {
270            result.push(c);
271        }
272    }
273
274    if result.ends_with('_') {
275        result.pop();
276    }
277
278    result
279}
280
281fn should_insert_snake_boundary(prev: char, current: char, next: Option<char>) -> bool {
282    if prev == '_' || current == '_' {
283        return false;
284    }
285
286    let lower_to_upper = prev.is_lowercase() && current.is_uppercase();
287    let acronym_to_word =
288        prev.is_uppercase() && current.is_uppercase() && next.is_some_and(char::is_lowercase);
289    let lower_to_digit =
290        prev.is_lowercase() && current.is_ascii_digit() && next.is_some_and(char::is_uppercase);
291    let digit_to_word =
292        prev.is_ascii_digit() && current.is_uppercase() && next.is_some_and(char::is_lowercase);
293
294    lower_to_upper || acronym_to_word || lower_to_digit || digit_to_word
295}
296
297// ---------------------------------------------------------------------------
298// WidgetEvent derive
299// ---------------------------------------------------------------------------
300
301/// Typed event declarations for composite widgets.
302///
303/// Generates a `WidgetEventEncode` implementation that converts
304/// each variant to a `(family, PropValue)` pair for wire transport.
305/// Variant names become snake_case family strings.
306///
307/// # Variant forms
308///
309/// - **Unit**: `Cleared` produces `("cleared", PropValue::Null)`
310/// - **Single-field tuple**: `Select(u64)` produces `("select", PropValue::U64(v))`
311/// - **Named fields**: `Change { x: f32, y: f32 }` produces
312///   `("change", PropValue::Object({x: ..., y: ...}))`
313///
314/// Field types must implement `PlushieType` (all primitives do).
315///
316/// # Example
317///
318/// ```ignore
319/// #[derive(WidgetEvent)]
320/// enum StarRatingEvent {
321///     /// User selected a rating.
322///     Select(u64),
323///     /// Hover state changed.
324///     HoverChanged(bool),
325///     /// Selection was cleared.
326///     Cleared,
327/// }
328///
329/// // In handle_event:
330/// EventResult::emit_event(StarRatingEvent::Select(5))
331/// ```
332#[proc_macro_derive(WidgetEvent)]
333pub fn derive_widget_event(input: TokenStream) -> TokenStream {
334    let input = parse_macro_input!(input as DeriveInput);
335
336    match derive_widget_event_impl(&input) {
337        Ok(tokens) => tokens.into(),
338        Err(err) => err.to_compile_error().into(),
339    }
340}
341
342fn derive_widget_event_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
343    let enum_name = &input.ident;
344
345    let variants = match &input.data {
346        Data::Enum(data) => &data.variants,
347        _ => {
348            return Err(syn::Error::new_spanned(
349                enum_name,
350                "WidgetEvent can only be derived for enums",
351            ));
352        }
353    };
354
355    // Reject multi-field tuple variants (ambiguous encoding).
356    for v in variants {
357        if let Fields::Unnamed(fields) = &v.fields
358            && fields.unnamed.len() > 1
359        {
360            return Err(syn::Error::new_spanned(
361                v,
362                "WidgetEvent tuple variants must have exactly one field; \
363                 use named fields for multiple values",
364            ));
365        }
366    }
367
368    let match_arms = variants.iter().map(|v| {
369        let ident = &v.ident;
370        let family = pascal_to_snake(&ident.to_string());
371
372        match &v.fields {
373            Fields::Unit => {
374                quote! {
375                    Self::#ident => (#family, ::plushie_core::protocol::PropValue::Null)
376                }
377            }
378            Fields::Unnamed(_) => {
379                // Single-field tuple variant: encode via PlushieType::wire_encode.
380                quote! {
381                    Self::#ident(v) => (
382                        #family,
383                        ::plushie_core::types::PlushieType::wire_encode(v),
384                    )
385                }
386            }
387            Fields::Named(fields) => {
388                let field_names: Vec<_> = fields
389                    .named
390                    .iter()
391                    .map(|f| f.ident.as_ref().unwrap())
392                    .collect();
393                let field_keys: Vec<_> = field_names.iter().map(|n| n.to_string()).collect();
394                let inserts = field_names
395                    .iter()
396                    .zip(field_keys.iter())
397                    .map(|(name, key)| {
398                        quote! {
399                            map.insert(
400                                #key,
401                                ::plushie_core::types::PlushieType::wire_encode(#name),
402                            );
403                        }
404                    });
405
406                quote! {
407                    Self::#ident { #(#field_names),* } => {
408                        let mut map = ::plushie_core::protocol::PropMap::new();
409                        #(#inserts)*
410                        (#family, ::plushie_core::protocol::PropValue::Object(map))
411                    }
412                }
413            }
414        }
415    });
416
417    let spec_arms = generate_spec_arms(variants, "EventSpec", "WidgetEvent")?;
418
419    Ok(quote! {
420        impl ::plushie_core::types::WidgetEventEncode for #enum_name {
421            fn to_wire(&self) -> (&'static str, ::plushie_core::protocol::PropValue) {
422                match self {
423                    #(#match_arms,)*
424                }
425            }
426        }
427
428        impl #enum_name {
429            /// Return specs for all event variants.
430            pub fn event_specs() -> Vec<::plushie_core::spec::EventSpec> {
431                vec![#(#spec_arms,)*]
432            }
433        }
434    })
435}
436
437// ---------------------------------------------------------------------------
438// WidgetCommand derive
439// ---------------------------------------------------------------------------
440
441/// Typed command declarations for widget operations.
442///
443/// Generates a `WidgetCommandEncode` implementation that converts
444/// each variant to an `(op, PropValue)` pair for wire transport.
445/// Variant names become snake_case operation strings.
446///
447/// Uses the same variant forms as [`WidgetEvent`]:
448///
449/// - **Unit**: `Reset` produces `("reset", PropValue::Null)`
450/// - **Single-field tuple**: `SetValue(f32)` produces `("set_value", PropValue::F64(v))`
451/// - **Named fields**: `SetRange { min: f32, max: f32 }` produces
452///   `("set_range", PropValue::Object({min: ..., max: ...}))`
453///
454/// # Example
455///
456/// ```ignore
457/// #[derive(WidgetCommand)]
458/// enum GaugeCommand {
459///     /// Set gauge to a value immediately.
460///     SetValue(f32),
461///     /// Reset gauge to zero.
462///     Reset,
463///     /// Set the value range.
464///     SetRange { min: f32, max: f32 },
465/// }
466///
467/// // Usage:
468/// Command::widget("temp", GaugeCommand::SetValue(72.0))
469/// ```
470#[proc_macro_derive(WidgetCommand)]
471pub fn derive_widget_command(input: TokenStream) -> TokenStream {
472    let input = parse_macro_input!(input as DeriveInput);
473
474    match derive_widget_command_impl(&input) {
475        Ok(tokens) => tokens.into(),
476        Err(err) => err.to_compile_error().into(),
477    }
478}
479
480fn derive_widget_command_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
481    let enum_name = &input.ident;
482
483    let variants = match &input.data {
484        Data::Enum(data) => &data.variants,
485        _ => {
486            return Err(syn::Error::new_spanned(
487                enum_name,
488                "WidgetCommand can only be derived for enums",
489            ));
490        }
491    };
492
493    // Reject multi-field tuple variants (ambiguous encoding).
494    for v in variants {
495        if let Fields::Unnamed(fields) = &v.fields
496            && fields.unnamed.len() > 1
497        {
498            return Err(syn::Error::new_spanned(
499                v,
500                "WidgetCommand tuple variants must have exactly one field; \
501                 use named fields for multiple values",
502            ));
503        }
504    }
505
506    // Generate to_wire() match arms (same logic as WidgetEvent)
507    let match_arms = variants.iter().map(|v| {
508        let ident = &v.ident;
509        let op = pascal_to_snake(&ident.to_string());
510
511        match &v.fields {
512            Fields::Unit => {
513                quote! {
514                    Self::#ident => (#op, ::plushie_core::protocol::PropValue::Null)
515                }
516            }
517            Fields::Unnamed(_) => {
518                quote! {
519                    Self::#ident(v) => (
520                        #op,
521                        ::plushie_core::types::PlushieType::wire_encode(v),
522                    )
523                }
524            }
525            Fields::Named(fields) => {
526                let field_names: Vec<_> = fields
527                    .named
528                    .iter()
529                    .map(|f| f.ident.as_ref().unwrap())
530                    .collect();
531                let field_keys: Vec<_> = field_names.iter().map(|n| n.to_string()).collect();
532                let inserts = field_names
533                    .iter()
534                    .zip(field_keys.iter())
535                    .map(|(name, key)| {
536                        quote! {
537                            map.insert(
538                                #key,
539                                ::plushie_core::types::PlushieType::wire_encode(#name),
540                            );
541                        }
542                    });
543
544                quote! {
545                    Self::#ident { #(#field_names),* } => {
546                        let mut map = ::plushie_core::protocol::PropMap::new();
547                        #(#inserts)*
548                        (#op, ::plushie_core::protocol::PropValue::Object(map))
549                    }
550                }
551            }
552        }
553    });
554
555    let spec_arms = generate_spec_arms(variants, "CommandSpec", "WidgetCommand")?;
556
557    Ok(quote! {
558        impl ::plushie_core::spec::WidgetCommandEncode for #enum_name {
559            fn to_wire(&self) -> (&'static str, ::plushie_core::protocol::PropValue) {
560                match self {
561                    #(#match_arms,)*
562                }
563            }
564
565            fn command_specs() -> Vec<::plushie_core::spec::CommandSpec> {
566                vec![#(#spec_arms,)*]
567            }
568        }
569    })
570}
571
572// ---------------------------------------------------------------------------
573// Shared spec generation for WidgetEvent and WidgetCommand
574// ---------------------------------------------------------------------------
575
576/// Generate spec constructor expressions for each enum variant.
577///
578/// `spec_type` is either "EventSpec" or "CommandSpec".
579/// Both use "family" as the name field.
580fn generate_spec_arms<'a>(
581    variants: impl IntoIterator<Item = &'a syn::Variant>,
582    spec_type: &str,
583    derive_name: &str,
584) -> syn::Result<Vec<proc_macro2::TokenStream>> {
585    let spec_ident = format_ident!("{}", spec_type);
586    let name_field = format_ident!("family");
587
588    variants
589        .into_iter()
590        .map(|v| {
591            let name = pascal_to_snake(&v.ident.to_string());
592
593            let payload = match &v.fields {
594                Fields::Unit => {
595                    quote! { ::plushie_core::spec::PayloadSpec::None }
596                }
597                Fields::Unnamed(fields) => {
598                    let ty = &fields.unnamed.first().unwrap().ty;
599                    let vt = rust_type_to_value_type(ty, derive_name)?;
600                    quote! { ::plushie_core::spec::PayloadSpec::Value(#vt) }
601                }
602                Fields::Named(fields) => {
603                    let field_specs: Vec<_> = fields
604                        .named
605                        .iter()
606                        .map(|f| {
607                            let fname = f.ident.as_ref().unwrap().to_string();
608                            let vt = rust_type_to_value_type(&f.ty, derive_name)?;
609                            Ok(quote! { (#fname.to_string(), #vt) })
610                        })
611                        .collect::<syn::Result<_>>()?;
612                    let required: Vec<_> = fields
613                        .named
614                        .iter()
615                        .map(|f| f.ident.as_ref().unwrap().to_string())
616                        .collect();
617                    quote! {
618                        ::plushie_core::spec::PayloadSpec::Fields {
619                            fields: vec![#(#field_specs),*],
620                            required: vec![#(#required.to_string()),*],
621                        }
622                    }
623                }
624            };
625
626            Ok(quote! {
627                ::plushie_core::spec::#spec_ident {
628                    #name_field: #name.to_string(),
629                    payload: #payload,
630                }
631            })
632        })
633        .collect()
634}
635
636/// Map a Rust type to a ValueType for spec generation.
637fn rust_type_to_value_type(
638    ty: &syn::Type,
639    derive_name: &str,
640) -> syn::Result<proc_macro2::TokenStream> {
641    if path_matches(ty, &["f32"]) || path_matches(ty, &["f64"]) {
642        return Ok(quote! { ::plushie_core::spec::ValueType::Float });
643    }
644    if path_matches(ty, &["i32"])
645        || path_matches(ty, &["i64"])
646        || path_matches(ty, &["u32"])
647        || path_matches(ty, &["u64"])
648    {
649        return Ok(quote! { ::plushie_core::spec::ValueType::Integer });
650    }
651    if path_matches(ty, &["bool"]) {
652        return Ok(quote! { ::plushie_core::spec::ValueType::Bool });
653    }
654    if path_matches(ty, &["String"])
655        || path_matches(ty, &["std", "string", "String"])
656        || path_matches(ty, &["alloc", "string", "String"])
657    {
658        return Ok(quote! { ::plushie_core::spec::ValueType::String });
659    }
660    if path_matches(ty, &["PropValue"])
661        || path_matches(ty, &["plushie_core", "protocol", "PropValue"])
662    {
663        return Ok(quote! { ::plushie_core::spec::ValueType::Any });
664    }
665
666    Err(syn::Error::new_spanned(
667        ty,
668        format!(
669            "unsupported {derive_name} payload type `{}`; supported payload types are f32, f64, i32, i64, u32, u64, bool, String, std::string::String, alloc::string::String, and plushie_core::protocol::PropValue",
670            quote!(#ty)
671        ),
672    ))
673}
674
675fn path_matches(ty: &syn::Type, expected: &[&str]) -> bool {
676    let syn::Type::Path(type_path) = ty else {
677        return false;
678    };
679    if type_path.qself.is_some() {
680        return false;
681    }
682
683    let mut segments = type_path.path.segments.iter();
684    for expected_ident in expected {
685        let Some(segment) = segments.next() else {
686            return false;
687        };
688        if segment.ident != expected_ident {
689            return false;
690        }
691        if !matches!(segment.arguments, syn::PathArguments::None) {
692            return false;
693        }
694    }
695    segments.next().is_none()
696}
697
698// ---------------------------------------------------------------------------
699// WidgetProps derive
700// ---------------------------------------------------------------------------
701
702/// Define your widget's properties and get typed extraction.
703///
704/// Declare your widget's fields as a struct. The derive creates
705/// a `{Name}Props` struct with a `from_node()` method that reads
706/// each property from the widget tree with the correct type.
707///
708/// Document fields with `///` comments; they carry over to the
709/// generated Props struct.
710///
711/// # Example
712///
713/// ```ignore
714/// #[derive(WidgetProps)]
715/// #[widget(name = "gauge")]
716/// struct Gauge {
717///     /// Current gauge value (0.0 to 1.0).
718///     value: f32,
719///     /// Label displayed below the gauge.
720///     label: String,
721/// }
722///
723/// // In your widget's render method:
724/// let props = GaugeProps::from_node(node);
725/// let value = props.value.unwrap_or(0.0);
726/// let label = props.label.unwrap_or_default();
727/// ```
728#[proc_macro_derive(WidgetProps, attributes(widget, field, widget_props))]
729pub fn derive_plushie_widget(input: TokenStream) -> TokenStream {
730    let input = parse_macro_input!(input as DeriveInput);
731
732    match derive_widget_impl(&input) {
733        Ok(tokens) => tokens.into(),
734        Err(err) => err.to_compile_error().into(),
735    }
736}
737
738fn derive_widget_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
739    let widget_name = extract_widget_name(input)?;
740    let is_container = has_widget_props_container_attr(input);
741
742    let fields = match &input.data {
743        Data::Struct(data) => match &data.fields {
744            Fields::Named(fields) => &fields.named,
745            _ => {
746                return Err(syn::Error::new_spanned(
747                    &input.ident,
748                    "WidgetProps requires named fields",
749                ));
750            }
751        },
752        _ => {
753            return Err(syn::Error::new_spanned(
754                &input.ident,
755                "WidgetProps can only be derived for structs",
756            ));
757        }
758    };
759
760    let struct_name = &input.ident;
761    let props_name = format_ident!("{}Props", struct_name);
762
763    // Generate props struct fields (all Option<T>).
764    let prop_fields = fields.iter().map(|f| {
765        let name = &f.ident;
766        let ty = &f.ty;
767        let docs = f
768            .attrs
769            .iter()
770            .filter(|a| a.path().is_ident("doc"))
771            .collect::<Vec<_>>();
772        quote! {
773            #(#docs)*
774            pub #name: Option<#ty>
775        }
776    });
777
778    // Generate from_node field extractions.
779    let extractions = fields.iter().map(|f| {
780        let name = &f.ident;
781        let ty = &f.ty;
782        let key = name.as_ref().unwrap().to_string();
783        quote! {
784            #name: <#ty as ::plushie_core::types::PlushieType>::extract(p, #key)
785        }
786    });
787
788    let field_names: Vec<_> = fields.iter().map(|f| &f.ident).collect();
789
790    // Generate Debug impl field formatting.
791    let debug_fields = field_names.iter().map(|name| {
792        let name_str = name.as_ref().unwrap().to_string();
793        quote! {
794            .field(#name_str, &self.#name)
795        }
796    });
797
798    // Build a field summary for the doc comment
799    let field_list: String = fields
800        .iter()
801        .map(|f| {
802            let name = f.ident.as_ref().unwrap().to_string();
803            let ty = &f.ty;
804            let ty_str = quote!(#ty).to_string();
805            // Extract the first doc line if present
806            let doc = f
807                .attrs
808                .iter()
809                .filter(|a| a.path().is_ident("doc"))
810                .filter_map(|a| {
811                    if let syn::Meta::NameValue(nv) = &a.meta
812                        && let syn::Expr::Lit(lit) = &nv.value
813                        && let syn::Lit::Str(s) = &lit.lit
814                    {
815                        return Some(s.value().trim().to_string());
816                    }
817                    None
818                })
819                .next();
820            match doc {
821                Some(d) => format!("- **`{}`** (`{}`): {}", name, ty_str, d),
822                None => format!("- **`{}`** (`{}`)", name, ty_str),
823            }
824        })
825        .collect::<Vec<_>>()
826        .join("\n");
827
828    let props_doc = format!(
829        "Typed properties for the `{}` widget.\n\n## Fields\n\n{}",
830        widget_name, field_list
831    );
832    let from_node_doc = format!("Extract properties from a `{}` tree node.", widget_name);
833    let type_name_doc = format!("The widget type name: `\"{}\"`.", widget_name);
834
835    // Second set of extraction tokens for the FromNode impl (quote
836    // iterators are consumed on first use).
837    let extractions_for_trait = fields.iter().map(|f| {
838        let name = &f.ident;
839        let ty = &f.ty;
840        let key = name.as_ref().unwrap().to_string();
841        quote! {
842            #name: <#ty as ::plushie_core::types::PlushieType>::extract(p, #key)
843        }
844    });
845
846    // -- Builder generation --
847
848    let builder_name = format_ident!("{}Builder", struct_name);
849
850    let builder_setters = fields.iter().map(|f| {
851        let name = f.ident.as_ref().unwrap();
852        let ty = &f.ty;
853        let key = name.to_string();
854        let doc = f
855            .attrs
856            .iter()
857            .filter(|a| a.path().is_ident("doc"))
858            .filter_map(|a| {
859                if let syn::Meta::NameValue(nv) = &a.meta
860                    && let syn::Expr::Lit(lit) = &nv.value
861                    && let syn::Lit::Str(s) = &lit.lit
862                {
863                    return Some(s.value().trim().to_string());
864                }
865                None
866            })
867            .next();
868        let setter_doc = match doc {
869            Some(d) => d,
870            None => format!("The `{}` property.", key),
871        };
872
873        quote! {
874            #[doc = #setter_doc]
875            pub fn #name(mut self, v: #ty) -> Self {
876                self.0.props.insert(
877                    #key,
878                    ::plushie_core::types::PlushieType::wire_encode(&v),
879                );
880                self
881            }
882        }
883    });
884
885    let builder_doc = format!(
886        "Builder for the `{}` widget.\n\n\
887         ## Properties\n\n{}",
888        widget_name, field_list
889    );
890    let builder_new_doc = format!(
891        "Create a new `{}` widget builder with the given ID.",
892        widget_name
893    );
894    let builder_fn_doc = format!(
895        "Create a `{}` widget builder with the given ID.",
896        widget_name
897    );
898
899    let container_methods = if is_container {
900        quote! {
901            /// Append a child node to this container widget.
902            pub fn child(mut self, child: ::plushie_core::protocol::TreeNode) -> Self {
903                self.0.children.push(child);
904                self
905            }
906
907            /// Replace all children with the given list.
908            pub fn children(mut self, children: ::std::vec::Vec<::plushie_core::protocol::TreeNode>) -> Self {
909                self.0.children = children;
910                self
911            }
912        }
913    } else {
914        quote! {}
915    };
916
917    Ok(quote! {
918        #[doc = #props_doc]
919        pub struct #props_name {
920            #(#prop_fields,)*
921        }
922
923        impl #props_name {
924            #[doc = #from_node_doc]
925            pub fn from_node(node: &::plushie_core::protocol::TreeNode) -> Self {
926                let p = &node.props;
927                Self {
928                    #(#extractions,)*
929                }
930            }
931        }
932
933        impl ::plushie_core::types::FromNode for #props_name {
934            fn from_node(node: &::plushie_core::protocol::TreeNode) -> Self {
935                let p = &node.props;
936                Self {
937                    #(#extractions_for_trait,)*
938                }
939            }
940        }
941
942        impl ::core::fmt::Debug for #props_name {
943            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
944                f.debug_struct(stringify!(#props_name))
945                    #(#debug_fields)*
946                    .finish()
947            }
948        }
949
950        impl #struct_name {
951            #[doc = #type_name_doc]
952            pub fn type_name() -> &'static str {
953                #widget_name
954            }
955
956            #[doc = #builder_fn_doc]
957            pub fn builder(id: &str) -> #builder_name {
958                #builder_name::new(id)
959            }
960        }
961
962        #[doc = #builder_doc]
963        pub struct #builder_name(pub ::plushie_core::WidgetBuilder);
964
965        impl #builder_name {
966            #[doc = #builder_new_doc]
967            pub fn new(id: &str) -> Self {
968                Self(::plushie_core::WidgetBuilder::new(#widget_name, id))
969            }
970
971            #(#builder_setters)*
972
973            /// Set a property by key (untyped fallback).
974            pub fn prop(mut self, key: &str, value: impl Into<::plushie_core::protocol::PropValue>) -> Self {
975                self.0.props.insert(key, value.into());
976                self
977            }
978
979            #container_methods
980        }
981    })
982}
983
984// ---------------------------------------------------------------------------
985// PlushieWidget derive
986// ---------------------------------------------------------------------------
987
988/// Generate `type_names` and `fresh_for_session` for a
989/// [`PlushieWidget`] impl, and re-declare the impl block with those
990/// methods injected.
991///
992/// Works on unit structs and structs that implement [`Default`] (the
993/// derive uses `Self::default()` when the type is not a unit struct).
994/// Requires `#[plushie_widget(type_name = "...")]`.
995///
996/// The derive produces a `PlushieWidget<R>` impl for each renderer
997/// where the type also implements `PlushieWidgetRender<R>`. A plain
998/// `impl PlushieWidgetRender` targets the default iced renderer;
999/// use `impl<R: PlushieRenderer> PlushieWidgetRender<R>` only when
1000/// the widget needs to stay renderer-generic.
1001///
1002/// # Example
1003///
1004/// ```ignore
1005/// use plushie_widget_sdk::prelude::*;
1006///
1007/// #[derive(PlushieWidget)]
1008/// #[plushie_widget(type_name = "my_gauge")]
1009/// struct MyGauge;
1010///
1011/// impl PlushieWidgetRender for MyGauge {
1012///     fn render<'a>(
1013///         &'a self,
1014///         node: &'a TreeNode,
1015///         ctx: &RenderCtx<'a>,
1016///     ) -> PlushieElement<'a> {
1017///         todo!()
1018///     }
1019/// }
1020/// ```
1021///
1022/// Stateful widgets that cannot be reached via `Default` should
1023/// implement `PlushieWidget` manually so the "return a truly fresh
1024/// instance" contract stays explicit.
1025#[proc_macro_derive(PlushieWidget, attributes(plushie_widget))]
1026pub fn derive_plushie_widget_trait(input: TokenStream) -> TokenStream {
1027    let input = parse_macro_input!(input as DeriveInput);
1028
1029    match derive_plushie_widget_trait_impl(&input) {
1030        Ok(tokens) => tokens.into(),
1031        Err(err) => err.to_compile_error().into(),
1032    }
1033}
1034
1035fn derive_plushie_widget_trait_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
1036    let type_name = extract_plushie_widget_type_name(input)?;
1037    let struct_name = &input.ident;
1038
1039    let is_unit = matches!(
1040        &input.data,
1041        Data::Struct(data) if matches!(&data.fields, Fields::Unit)
1042    );
1043    let fresh_expr = if is_unit {
1044        quote! { ::std::boxed::Box::new(Self) }
1045    } else {
1046        quote! { ::std::boxed::Box::new(<Self as ::core::default::Default>::default()) }
1047    };
1048
1049    Ok(quote! {
1050        impl<__R: ::plushie_widget_sdk::PlushieRenderer>
1051            ::plushie_widget_sdk::registry::PlushieWidget<__R> for #struct_name
1052        where
1053            Self: ::plushie_widget_sdk::registry::PlushieWidgetRender<__R>,
1054        {
1055            fn type_names(&self) -> &[&str] {
1056                &[#type_name]
1057            }
1058
1059            fn render<'a>(
1060                &'a self,
1061                node: &'a ::plushie_widget_sdk::protocol::TreeNode,
1062                ctx: &::plushie_widget_sdk::render_ctx::RenderCtx<'a, __R>,
1063            ) -> ::plushie_widget_sdk::PlushieElement<'a, __R> {
1064                <Self as ::plushie_widget_sdk::registry::PlushieWidgetRender<__R>>::render(
1065                    self, node, ctx,
1066                )
1067            }
1068
1069            fn fresh_for_session(&self)
1070                -> ::std::boxed::Box<dyn ::plushie_widget_sdk::registry::PlushieWidget<__R>>
1071            {
1072                #fresh_expr
1073            }
1074        }
1075    })
1076}
1077
1078fn extract_plushie_widget_type_name(input: &DeriveInput) -> syn::Result<String> {
1079    for attr in &input.attrs {
1080        if attr.path().is_ident("plushie_widget") {
1081            let mut name = None;
1082            attr.parse_nested_meta(|meta| {
1083                if meta.path.is_ident("type_name") {
1084                    let value = meta.value()?;
1085                    let lit: Lit = value.parse()?;
1086                    if let Lit::Str(s) = lit {
1087                        name = Some(s.value());
1088                        Ok(())
1089                    } else {
1090                        Err(meta.error("expected string literal for type_name"))
1091                    }
1092                } else {
1093                    Err(meta.error("unknown plushie_widget attribute, expected `type_name`"))
1094                }
1095            })?;
1096            return name.ok_or_else(|| {
1097                syn::Error::new_spanned(
1098                    attr,
1099                    "plushie_widget attribute requires type_name = \"...\"",
1100                )
1101            });
1102        }
1103    }
1104    Err(syn::Error::new_spanned(
1105        &input.ident,
1106        "PlushieWidget derive requires #[plushie_widget(type_name = \"...\")] attribute",
1107    ))
1108}
1109
1110fn has_widget_props_container_attr(input: &DeriveInput) -> bool {
1111    for attr in &input.attrs {
1112        if attr.path().is_ident("widget_props") {
1113            let mut is_container = false;
1114            let _ = attr.parse_nested_meta(|meta| {
1115                if meta.path.is_ident("container") {
1116                    is_container = true;
1117                }
1118                Ok(())
1119            });
1120            if is_container {
1121                return true;
1122            }
1123        }
1124    }
1125    false
1126}
1127
1128fn extract_widget_name(input: &DeriveInput) -> syn::Result<String> {
1129    for attr in &input.attrs {
1130        if attr.path().is_ident("widget") {
1131            let mut name = None;
1132            attr.parse_nested_meta(|meta| {
1133                if meta.path.is_ident("name") {
1134                    let value = meta.value()?;
1135                    let lit: Lit = value.parse()?;
1136                    if let Lit::Str(s) = lit {
1137                        name = Some(s.value());
1138                        Ok(())
1139                    } else {
1140                        Err(meta.error("expected string literal for widget name"))
1141                    }
1142                } else {
1143                    Err(meta.error("unknown widget attribute, expected `name`"))
1144                }
1145            })?;
1146            return name.ok_or_else(|| {
1147                syn::Error::new_spanned(attr, "widget attribute requires name = \"...\"")
1148            });
1149        }
1150    }
1151    Err(syn::Error::new_spanned(
1152        &input.ident,
1153        "WidgetProps requires #[widget(name = \"...\")] attribute",
1154    ))
1155}
1156
1157// ---------------------------------------------------------------------------
1158// widget! function-like macro
1159// ---------------------------------------------------------------------------
1160
1161/// Declare a custom Plushie widget in one shot.
1162///
1163/// Generates the widget struct, builder methods for each field, a
1164/// [`From<Widget> for TreeNode`] conversion, and a build-time
1165/// `PLUSHIE_WIDGET_METADATA` constant that `cargo plushie build`
1166/// reads during native-widget discovery.
1167///
1168/// # Cargo.toml metadata
1169///
1170/// Widget crates declare themselves via their own `Cargo.toml`. The
1171/// factory constructor the custom renderer calls at startup lives
1172/// there, making `Cargo.toml` the single source of truth; the
1173/// `widget!` attribute carries only the wire-protocol type name.
1174///
1175/// ```toml
1176/// [package.metadata.plushie.widget]
1177/// type_name = "my_gauge"
1178/// constructor = "my_gauge::factory::MyGaugeFactory::new()"
1179/// ```
1180///
1181/// The build tool discovers native widgets by scanning the full
1182/// `cargo metadata` graph for this table. App crates may also carry a
1183/// complementary table:
1184///
1185/// ```toml
1186/// [package.metadata.plushie]
1187/// binary_name = "my-app-renderer"        # optional override
1188/// source_path = "../plushie-rust"        # optional, honored from env too
1189/// native_widgets = ["my-gauge"]          # optional explicit list
1190/// ```
1191///
1192/// # Example
1193///
1194/// ```ignore
1195/// use plushie_core::widget;
1196///
1197/// widget! {
1198///     /// Circular gauge widget.
1199///     #[widget(type_name = "my_gauge", crate = "my-gauge")]
1200///     pub struct Gauge {
1201///         pub value: f32,
1202///         pub max: f32,
1203///         pub color: plushie_core::types::Color,
1204///     }
1205///
1206///     events {
1207///         ValueChanged(f32),
1208///     }
1209/// }
1210/// ```
1211///
1212/// The macro emits:
1213///
1214/// - `pub struct Gauge { value: Option<f32>, ... }` with `new(id)` and
1215///   fluent builder methods (`.value(v)`, `.max(v)`, `.color(c)`).
1216/// - `impl From<Gauge> for plushie_core::protocol::TreeNode`.
1217/// - `pub const PLUSHIE_WIDGET_METADATA: &str = "...";` with a JSON
1218///   snippet describing the widget for the build tool.
1219/// - An `events { ... }` section expands to a sibling enum with the
1220///   `WidgetEvent` derive applied.
1221#[proc_macro]
1222pub fn widget(input: TokenStream) -> TokenStream {
1223    let input2: proc_macro2::TokenStream = input.into();
1224    match widget_impl(input2) {
1225        Ok(tokens) => tokens.into(),
1226        Err(err) => err.to_compile_error().into(),
1227    }
1228}
1229
1230/// Parsed form of `widget! { ... }` input.
1231struct WidgetInput {
1232    attrs: Vec<syn::Attribute>,
1233    meta: WidgetMeta,
1234    vis: syn::Visibility,
1235    ident: syn::Ident,
1236    fields: syn::FieldsNamed,
1237    events: Option<WidgetEventsBlock>,
1238}
1239
1240/// Fields parsed out of the `#[widget(...)]` attribute.
1241struct WidgetMeta {
1242    type_name: String,
1243    crate_name: Option<String>,
1244}
1245
1246/// Parsed `events { ... }` block.
1247struct WidgetEventsBlock {
1248    ident: syn::Ident,
1249    variants: syn::punctuated::Punctuated<syn::Variant, syn::Token![,]>,
1250}
1251
1252impl syn::parse::Parse for WidgetInput {
1253    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1254        // Parse any outer #[doc = "..."] / #[widget(...)] attributes that
1255        // precede the struct declaration.
1256        let attrs = input.call(syn::Attribute::parse_outer)?;
1257        let vis: syn::Visibility = input.parse()?;
1258        let _struct_token: syn::Token![struct] = input.parse()?;
1259        let ident: syn::Ident = input.parse()?;
1260        let fields: syn::FieldsNamed = input.parse()?;
1261
1262        // Optional trailing `events { ... }` block.
1263        let events = if input.peek(syn::Ident) {
1264            let lookahead: syn::Ident = input.fork().parse()?;
1265            if lookahead == "events" {
1266                let _events_kw: syn::Ident = input.parse()?;
1267                let content;
1268                syn::braced!(content in input);
1269                let variants = content.parse_terminated(syn::Variant::parse, syn::Token![,])?;
1270                Some(WidgetEventsBlock {
1271                    ident: format_ident!("{}Event", ident),
1272                    variants,
1273                })
1274            } else {
1275                None
1276            }
1277        } else {
1278            None
1279        };
1280
1281        let meta = parse_widget_meta(&attrs, &ident)?;
1282
1283        Ok(WidgetInput {
1284            attrs,
1285            meta,
1286            vis,
1287            ident,
1288            fields,
1289            events,
1290        })
1291    }
1292}
1293
1294fn parse_widget_meta(attrs: &[syn::Attribute], ident: &syn::Ident) -> syn::Result<WidgetMeta> {
1295    let mut type_name: Option<String> = None;
1296    let mut crate_name: Option<String> = None;
1297
1298    for attr in attrs {
1299        if !attr.path().is_ident("widget") {
1300            continue;
1301        }
1302        attr.parse_nested_meta(|meta| {
1303            if meta.path.is_ident("type_name") {
1304                let value = meta.value()?;
1305                let lit: Lit = value.parse()?;
1306                if let Lit::Str(s) = lit {
1307                    type_name = Some(s.value());
1308                    Ok(())
1309                } else {
1310                    Err(meta.error("type_name must be a string literal"))
1311                }
1312            } else if meta.path.is_ident("crate") {
1313                let value = meta.value()?;
1314                let lit: Lit = value.parse()?;
1315                if let Lit::Str(s) = lit {
1316                    crate_name = Some(s.value());
1317                    Ok(())
1318                } else {
1319                    Err(meta.error("crate must be a string literal"))
1320                }
1321            } else if meta.path.is_ident("constructor") {
1322                // Cargo.toml's `[package.metadata.plushie.widget].constructor`
1323                // is the single source of truth. Keeping a second copy in
1324                // the macro attribute invited drift, so the attribute is
1325                // rejected rather than silently accepted.
1326                Err(meta.error(
1327                    "`constructor` is no longer accepted in `#[widget(...)]`; \
1328                     declare it once in `[package.metadata.plushie.widget]` in \
1329                     the crate's Cargo.toml",
1330                ))
1331            } else {
1332                Err(meta.error("unknown widget attribute (expected `type_name` or `crate`)"))
1333            }
1334        })?;
1335    }
1336
1337    let type_name = type_name.ok_or_else(|| {
1338        syn::Error::new_spanned(
1339            ident,
1340            "widget! requires #[widget(type_name = \"...\")] above the struct",
1341        )
1342    })?;
1343
1344    Ok(WidgetMeta {
1345        type_name,
1346        crate_name,
1347    })
1348}
1349
1350fn widget_impl(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2::TokenStream> {
1351    let parsed: WidgetInput = syn::parse2(input)?;
1352
1353    let WidgetInput {
1354        attrs,
1355        meta,
1356        vis,
1357        ident,
1358        fields,
1359        events,
1360    } = parsed;
1361
1362    // Drop `#[widget(...)]` attrs from the forwarded attribute list;
1363    // pass through everything else (doc comments, user attributes).
1364    let pass_attrs: Vec<&syn::Attribute> = attrs
1365        .iter()
1366        .filter(|a| !a.path().is_ident("widget"))
1367        .collect();
1368
1369    let type_name = &meta.type_name;
1370    let struct_fields: Vec<(&syn::Ident, &syn::Type, Vec<&syn::Attribute>)> = fields
1371        .named
1372        .iter()
1373        .map(|f| {
1374            let fname = f.ident.as_ref().expect("named field");
1375            let ty = &f.ty;
1376            let docs: Vec<&syn::Attribute> = f
1377                .attrs
1378                .iter()
1379                .filter(|a| a.path().is_ident("doc"))
1380                .collect();
1381            (fname, ty, docs)
1382        })
1383        .collect();
1384
1385    // Struct declaration: each declared field becomes Option<T> so
1386    // builder methods set them one at a time.
1387    let decl_fields = struct_fields.iter().map(|(fname, ty, docs)| {
1388        quote! {
1389            #(#docs)*
1390            pub #fname: ::core::option::Option<#ty>
1391        }
1392    });
1393
1394    // Default::default() for zero-arg construction (used by `new`).
1395    let default_inits = struct_fields.iter().map(|(fname, _, _)| {
1396        quote! { #fname: ::core::option::Option::None }
1397    });
1398
1399    // Builder methods. Each typed setter uses `PlushieType::wire_encode`
1400    // under the hood for `From<Widget> for TreeNode`; here we just store
1401    // the typed value.
1402    let builder_methods = struct_fields.iter().map(|(fname, ty, docs)| {
1403        quote! {
1404            #(#docs)*
1405            pub fn #fname(mut self, value: #ty) -> Self {
1406                self.#fname = ::core::option::Option::Some(value);
1407                self
1408            }
1409        }
1410    });
1411
1412    // From<Widget> for TreeNode: encode each Some() field via PlushieType.
1413    let to_props_inserts = struct_fields.iter().map(|(fname, _, _)| {
1414        let key = fname.to_string();
1415        quote! {
1416            if let ::core::option::Option::Some(v) = widget.#fname {
1417                props.insert(
1418                    #key,
1419                    ::plushie_core::types::PlushieType::wire_encode(&v),
1420                );
1421            }
1422        }
1423    });
1424
1425    // JSON metadata for the build tool. Kept plain so the build tool
1426    // can parse it as a serde_json::Value without extra deps. The
1427    // `constructor` field is not emitted here: it lives in the crate's
1428    // Cargo.toml `[package.metadata.plushie.widget]` table, which the
1429    // build tool reads directly.
1430    let crate_name_json = match &meta.crate_name {
1431        Some(c) => format!(",\"crate\":\"{}\"", escape_json(c)),
1432        None => String::new(),
1433    };
1434    let metadata_str = format!(
1435        "{{\"type_name\":\"{}\",\"struct\":\"{}\"{}}}",
1436        escape_json(type_name),
1437        ident,
1438        crate_name_json,
1439    );
1440
1441    // Events block: feed variants through the existing WidgetEvent
1442    // derive by emitting an enum with the derive attached.
1443    let events_decl = events.as_ref().map(|e| {
1444        let ename = &e.ident;
1445        let variants = e.variants.iter();
1446        quote! {
1447            #[derive(::core::fmt::Debug, ::core::clone::Clone, ::plushie_core::WidgetEvent)]
1448            pub enum #ename {
1449                #(#variants),*
1450            }
1451        }
1452    });
1453
1454    // `new(id)` constructor: struct { id, ..defaults }.
1455    let new_doc = format!("Create a new `{}` widget builder with the given ID.", ident);
1456    let struct_doc = format!(
1457        "`{}` widget. Type name: `\"{}\"`. Built by the `widget!` macro.",
1458        ident, type_name
1459    );
1460    let metadata_doc = format!(
1461        "Build-time metadata for the `{}` widget (consumed by `cargo plushie build`).",
1462        ident
1463    );
1464
1465    Ok(quote! {
1466        #(#pass_attrs)*
1467        #[doc = #struct_doc]
1468        #vis struct #ident {
1469            /// Widget instance ID (unique within the view tree).
1470            pub id: ::std::string::String,
1471            #(#decl_fields,)*
1472        }
1473
1474        impl #ident {
1475            #[doc = #new_doc]
1476            pub fn new(id: impl ::core::convert::Into<::std::string::String>) -> Self {
1477                Self {
1478                    id: id.into(),
1479                    #(#default_inits,)*
1480                }
1481            }
1482
1483            /// The wire protocol type name this widget maps to.
1484            pub const fn type_name() -> &'static str {
1485                #type_name
1486            }
1487
1488            #(#builder_methods)*
1489        }
1490
1491        impl ::core::convert::From<#ident> for ::plushie_core::protocol::TreeNode {
1492            fn from(widget: #ident) -> Self {
1493                let mut props = ::plushie_core::protocol::PropMap::new();
1494                #(#to_props_inserts)*
1495                ::plushie_core::protocol::TreeNode {
1496                    id: widget.id,
1497                    type_name: #type_name.to_string(),
1498                    props: ::plushie_core::protocol::Props::from(props),
1499                    children: ::std::vec::Vec::new(),
1500                }
1501            }
1502        }
1503
1504        #[doc = #metadata_doc]
1505        pub const PLUSHIE_WIDGET_METADATA: &::core::primitive::str = #metadata_str;
1506
1507        #events_decl
1508    })
1509}
1510
1511/// Minimal JSON string escaper: quotes, backslashes, and control chars.
1512fn escape_json(s: &str) -> String {
1513    let mut out = String::with_capacity(s.len());
1514    for c in s.chars() {
1515        match c {
1516            '"' => out.push_str("\\\""),
1517            '\\' => out.push_str("\\\\"),
1518            '\n' => out.push_str("\\n"),
1519            '\r' => out.push_str("\\r"),
1520            '\t' => out.push_str("\\t"),
1521            c if (c as u32) < 0x20 => {
1522                out.push_str(&format!("\\u{:04x}", c as u32));
1523            }
1524            c => out.push(c),
1525        }
1526    }
1527    out
1528}
1529
1530// ---------------------------------------------------------------------------
1531// Tests
1532// ---------------------------------------------------------------------------
1533
1534#[cfg(test)]
1535mod tests {
1536    use super::*;
1537    use syn::{DeriveInput, parse_quote};
1538
1539    // -- PlushieEnum tests --
1540
1541    #[test]
1542    fn pascal_to_snake_simple() {
1543        assert_eq!(pascal_to_snake("None"), "none");
1544        assert_eq!(pascal_to_snake("Word"), "word");
1545        assert_eq!(pascal_to_snake("WordOrGlyph"), "word_or_glyph");
1546        assert_eq!(pascal_to_snake("AlwaysOnTop"), "always_on_top");
1547        assert_eq!(pascal_to_snake("ScaleDown"), "scale_down");
1548    }
1549
1550    #[test]
1551    fn pascal_to_snake_consecutive_upper() {
1552        assert_eq!(pascal_to_snake("URL"), "url");
1553        assert_eq!(pascal_to_snake("HTMLParser"), "html_parser");
1554        assert_eq!(
1555            pascal_to_snake("ResizingDiagonallyUp"),
1556            "resizing_diagonally_up"
1557        );
1558    }
1559
1560    #[test]
1561    fn pascal_to_snake_digits_and_existing_underscores() {
1562        assert_eq!(pascal_to_snake("GL11Version"), "gl11_version");
1563        assert_eq!(pascal_to_snake("Version2D"), "version_2d");
1564        assert_eq!(pascal_to_snake("HTTP2Connection"), "http2_connection");
1565        assert_eq!(pascal_to_snake("XML_HTTP_Request"), "xml_http_request");
1566    }
1567
1568    #[test]
1569    fn pascal_to_snake_single_char() {
1570        assert_eq!(pascal_to_snake("X"), "x");
1571        assert_eq!(pascal_to_snake("Y"), "y");
1572    }
1573
1574    #[test]
1575    fn extract_plushie_type_name_works() {
1576        let input: DeriveInput = parse_quote! {
1577            #[plushie_type(name = "direction")]
1578            enum Direction {
1579                Horizontal,
1580                Vertical,
1581            }
1582        };
1583        assert_eq!(extract_plushie_type_name(&input).unwrap(), "direction");
1584    }
1585
1586    #[test]
1587    fn rejects_missing_plushie_type() {
1588        let input: DeriveInput = parse_quote! {
1589            enum NoAttr {
1590                A,
1591            }
1592        };
1593        assert!(extract_plushie_type_name(&input).is_err());
1594    }
1595
1596    #[test]
1597    fn variant_meta_default_wire_name() {
1598        let input: DeriveInput = parse_quote! {
1599            #[plushie_type(name = "test")]
1600            enum Test {
1601                WordOrGlyph,
1602            }
1603        };
1604        if let Data::Enum(data) = &input.data {
1605            let meta = extract_variant_meta(&data.variants[0]).unwrap();
1606            assert_eq!(meta.wire_name, "word_or_glyph");
1607            assert!(meta.aliases.is_empty());
1608        }
1609    }
1610
1611    #[test]
1612    fn variant_meta_custom_wire_and_aliases() {
1613        let input: DeriveInput = parse_quote! {
1614            #[plushie_type(name = "test")]
1615            enum Test {
1616                #[plushie(wire = "table_row", aliases = ["row"])]
1617                Row,
1618            }
1619        };
1620        if let Data::Enum(data) = &input.data {
1621            let meta = extract_variant_meta(&data.variants[0]).unwrap();
1622            assert_eq!(meta.wire_name, "table_row");
1623            assert_eq!(meta.aliases, vec!["row"]);
1624        }
1625    }
1626
1627    #[test]
1628    fn derive_enum_impl_produces_output() {
1629        let input: DeriveInput = parse_quote! {
1630            #[plushie_type(name = "direction")]
1631            enum Direction {
1632                Horizontal,
1633                Vertical,
1634                Both,
1635            }
1636        };
1637        let output = derive_enum_impl(&input).unwrap();
1638        let output_str = output.to_string();
1639
1640        assert!(output_str.contains("PlushieType"));
1641        assert!(output_str.contains("wire_decode"));
1642        assert!(output_str.contains("wire_encode"));
1643        assert!(output_str.contains("\"horizontal\""));
1644        assert!(output_str.contains("\"direction\""));
1645    }
1646
1647    #[test]
1648    fn rejects_struct_for_enum_derive() {
1649        let input: DeriveInput = parse_quote! {
1650            #[plushie_type(name = "bad")]
1651            struct NotAnEnum {
1652                x: f32,
1653            }
1654        };
1655        assert!(derive_enum_impl(&input).is_err());
1656    }
1657
1658    #[test]
1659    fn rejects_tuple_variant() {
1660        let input: DeriveInput = parse_quote! {
1661            #[plushie_type(name = "bad")]
1662            enum HasData {
1663                A(i32),
1664            }
1665        };
1666        assert!(derive_enum_impl(&input).is_err());
1667    }
1668
1669    // -- WidgetEvent tests --
1670
1671    #[test]
1672    fn widget_event_unit_variant() {
1673        let input: DeriveInput = parse_quote! {
1674            enum TestEvent {
1675                Cleared,
1676            }
1677        };
1678        let output = derive_widget_event_impl(&input).unwrap();
1679        let output_str = output.to_string();
1680
1681        assert!(output_str.contains("WidgetEventEncode"));
1682        assert!(output_str.contains("to_wire"));
1683        assert!(output_str.contains("\"cleared\""));
1684        assert!(output_str.contains("PropValue :: Null"));
1685    }
1686
1687    #[test]
1688    fn widget_event_tuple_variant() {
1689        let input: DeriveInput = parse_quote! {
1690            enum TestEvent {
1691                Select(u64),
1692                HoverChanged(bool),
1693            }
1694        };
1695        let output = derive_widget_event_impl(&input).unwrap();
1696        let output_str = output.to_string();
1697
1698        assert!(output_str.contains("\"select\""));
1699        assert!(output_str.contains("\"hover_changed\""));
1700        assert!(output_str.contains("wire_encode"));
1701    }
1702
1703    #[test]
1704    fn widget_event_struct_variant() {
1705        let input: DeriveInput = parse_quote! {
1706            enum TestEvent {
1707                Change { x: f32, y: f32 },
1708            }
1709        };
1710        let output = derive_widget_event_impl(&input).unwrap();
1711        let output_str = output.to_string();
1712
1713        assert!(output_str.contains("\"change\""));
1714        assert!(output_str.contains("PropMap"));
1715        assert!(output_str.contains("\"x\""));
1716        assert!(output_str.contains("\"y\""));
1717    }
1718
1719    #[test]
1720    fn widget_event_mixed_variants() {
1721        let input: DeriveInput = parse_quote! {
1722            enum TestEvent {
1723                Select(u64),
1724                Change { x: f32, y: f32 },
1725                Cleared,
1726            }
1727        };
1728        let output = derive_widget_event_impl(&input).unwrap();
1729        let output_str = output.to_string();
1730
1731        assert!(output_str.contains("\"select\""));
1732        assert!(output_str.contains("\"change\""));
1733        assert!(output_str.contains("\"cleared\""));
1734    }
1735
1736    #[test]
1737    fn widget_event_rejects_struct() {
1738        let input: DeriveInput = parse_quote! {
1739            struct NotAnEnum {
1740                x: f32,
1741            }
1742        };
1743        assert!(derive_widget_event_impl(&input).is_err());
1744    }
1745
1746    #[test]
1747    fn widget_event_rejects_multi_field_tuple() {
1748        let input: DeriveInput = parse_quote! {
1749            enum BadEvent {
1750                Change(f32, f32),
1751            }
1752        };
1753        assert!(derive_widget_event_impl(&input).is_err());
1754    }
1755
1756    #[test]
1757    fn widget_event_specs_map_qualified_string_types() {
1758        let input: DeriveInput = parse_quote! {
1759            enum TestEvent {
1760                Owned(String),
1761                Std(std::string::String),
1762                Alloc(alloc::string::String),
1763            }
1764        };
1765        let output = derive_widget_event_impl(&input).unwrap();
1766        let output_str = output.to_string();
1767
1768        assert!(output_str.contains("\"owned\""));
1769        assert!(output_str.contains("\"std\""));
1770        assert!(output_str.contains("\"alloc\""));
1771        assert_eq!(output_str.matches("ValueType :: String").count(), 3);
1772        assert!(!output_str.contains("ValueType :: Any"));
1773    }
1774
1775    #[test]
1776    fn widget_event_specs_reject_unsupported_payload_type() {
1777        let input: DeriveInput = parse_quote! {
1778            enum BadEvent {
1779                Count(u8),
1780            }
1781        };
1782        let err = derive_widget_event_impl(&input).unwrap_err();
1783        assert!(
1784            err.to_string()
1785                .contains("unsupported WidgetEvent payload type")
1786        );
1787    }
1788
1789    #[test]
1790    fn widget_command_specs_reject_unsupported_field_type() {
1791        let input: DeriveInput = parse_quote! {
1792            enum BadCommand {
1793                Set { count: usize },
1794            }
1795        };
1796        let err = derive_widget_command_impl(&input).unwrap_err();
1797        assert!(
1798            err.to_string()
1799                .contains("unsupported WidgetCommand payload type")
1800        );
1801    }
1802
1803    // -- WidgetProps tests --
1804
1805    #[test]
1806    fn extracts_widget_name() {
1807        let input: DeriveInput = parse_quote! {
1808            #[widget(name = "my_widget")]
1809            struct MyWidget {
1810                label: String,
1811            }
1812        };
1813        assert_eq!(extract_widget_name(&input).unwrap(), "my_widget");
1814    }
1815
1816    #[test]
1817    fn rejects_missing_widget_attr() {
1818        let input: DeriveInput = parse_quote! {
1819            struct NoAttr {
1820                label: String,
1821            }
1822        };
1823        assert!(extract_widget_name(&input).is_err());
1824    }
1825
1826    #[test]
1827    fn rejects_widget_attr_without_name() {
1828        let input: DeriveInput = parse_quote! {
1829            #[widget()]
1830            struct EmptyAttr {
1831                label: String,
1832            }
1833        };
1834        assert!(extract_widget_name(&input).is_err());
1835    }
1836
1837    #[test]
1838    fn derive_widget_impl_produces_output() {
1839        let input: DeriveInput = parse_quote! {
1840            #[widget(name = "gauge")]
1841            struct Gauge {
1842                /// The current value.
1843                value: f32,
1844                label: String,
1845                enabled: bool,
1846            }
1847        };
1848        let output = derive_widget_impl(&input).unwrap();
1849        let output_str = output.to_string();
1850
1851        // Props struct generated
1852        assert!(output_str.contains("GaugeProps"));
1853        // from_node inherent method generated
1854        assert!(output_str.contains("from_node"));
1855        // FromNode trait impl generated
1856        assert!(output_str.contains("FromNode"));
1857        // type_name method generated
1858        assert!(output_str.contains("\"gauge\""));
1859        // Field extractions use PlushieType
1860        assert!(output_str.contains("PlushieType"));
1861
1862        // Builder struct generated
1863        assert!(output_str.contains("GaugeBuilder"));
1864        // Builder wraps WidgetBuilder
1865        assert!(output_str.contains("WidgetBuilder"));
1866        // Typed setter methods generated for each field
1867        assert!(output_str.contains("fn value"));
1868        assert!(output_str.contains("fn label"));
1869        assert!(output_str.contains("fn enabled"));
1870        // builder() static method on the original struct
1871        assert!(output_str.contains("fn builder"));
1872        // Untyped fallback prop method
1873        assert!(output_str.contains("fn prop"));
1874    }
1875
1876    #[test]
1877    fn derive_widget_builder_uses_wire_encode() {
1878        let input: DeriveInput = parse_quote! {
1879            #[widget(name = "slider")]
1880            struct Slider {
1881                min: f32,
1882                max: f32,
1883            }
1884        };
1885        let output = derive_widget_impl(&input).unwrap();
1886        let output_str = output.to_string();
1887
1888        // Setters encode via PlushieType::wire_encode
1889        assert!(output_str.contains("wire_encode"));
1890        // Setters use the field name as the prop key
1891        assert!(output_str.contains("\"min\""));
1892        assert!(output_str.contains("\"max\""));
1893    }
1894
1895    #[test]
1896    fn derive_widget_builder_new_uses_widget_name() {
1897        let input: DeriveInput = parse_quote! {
1898            #[widget(name = "progress_bar")]
1899            struct ProgressBar {
1900                value: f32,
1901            }
1902        };
1903        let output = derive_widget_impl(&input).unwrap();
1904        let output_str = output.to_string();
1905
1906        // Builder::new passes the widget name to WidgetBuilder::new
1907        assert!(output_str.contains("\"progress_bar\""));
1908        assert!(output_str.contains("ProgressBarBuilder"));
1909    }
1910
1911    #[test]
1912    fn rejects_enum_for_widget() {
1913        let input: DeriveInput = parse_quote! {
1914            #[widget(name = "bad")]
1915            enum NotAStruct {
1916                A,
1917                B,
1918            }
1919        };
1920        assert!(derive_widget_impl(&input).is_err());
1921    }
1922
1923    #[test]
1924    fn rejects_tuple_struct_for_widget() {
1925        let input: DeriveInput = parse_quote! {
1926            #[widget(name = "bad")]
1927            struct TupleStruct(String, f32);
1928        };
1929        assert!(derive_widget_impl(&input).is_err());
1930    }
1931
1932    // -- widget! macro tests --
1933
1934    #[test]
1935    fn widget_macro_expands() {
1936        let input: proc_macro2::TokenStream = quote! {
1937            #[widget(type_name = "my_gauge", crate = "my-gauge")]
1938            pub struct Gauge {
1939                pub value: f32,
1940                pub max: f32,
1941            }
1942        };
1943        let output = widget_impl(input).expect("widget! should expand");
1944        let s = output.to_string();
1945
1946        // Struct + ID field.
1947        assert!(s.contains("pub struct Gauge"));
1948        assert!(s.contains("pub id :"));
1949        // Builder methods for declared fields.
1950        assert!(s.contains("fn value"));
1951        assert!(s.contains("fn max"));
1952        // From<Widget> for TreeNode.
1953        assert!(s.contains("TreeNode"));
1954        assert!(s.contains("\"my_gauge\""));
1955        // Metadata const.
1956        assert!(s.contains("PLUSHIE_WIDGET_METADATA"));
1957        assert!(s.contains("\\\"type_name\\\""));
1958        assert!(s.contains("\\\"my_gauge\\\""));
1959        assert!(s.contains("\\\"crate\\\""));
1960        // `constructor` lives only in Cargo.toml; never in the macro
1961        // output.
1962        assert!(!s.contains("\\\"constructor\\\""));
1963    }
1964
1965    #[test]
1966    fn widget_macro_metadata_is_valid_json() {
1967        // Drive the JSON assembly directly so the test doesn't have to
1968        // disentangle escaped string literals from the emitted token
1969        // stream.
1970        let type_name = "my_gauge";
1971        let crate_name_json = format!(",\"crate\":\"{}\"", escape_json("my-gauge"));
1972        let metadata_str = format!(
1973            "{{\"type_name\":\"{}\",\"struct\":\"{}\"{}}}",
1974            escape_json(type_name),
1975            "Gauge",
1976            crate_name_json,
1977        );
1978
1979        let value: serde_json::Value =
1980            serde_json::from_str(&metadata_str).expect("metadata parses as JSON");
1981        assert_eq!(value["type_name"], "my_gauge");
1982        assert_eq!(value["crate"], "my-gauge");
1983        assert_eq!(value["struct"], "Gauge");
1984        assert!(value.get("constructor").is_none());
1985    }
1986
1987    #[test]
1988    fn widget_macro_metadata_without_optional_fields() {
1989        // Minimal invocation (type_name only) still produces valid JSON.
1990        let type_name = "bare_widget";
1991        let metadata_str = format!(
1992            "{{\"type_name\":\"{}\",\"struct\":\"{}\"{}}}",
1993            escape_json(type_name),
1994            "Bare",
1995            String::new(),
1996        );
1997        let value: serde_json::Value =
1998            serde_json::from_str(&metadata_str).expect("minimal metadata parses as JSON");
1999        assert_eq!(value["type_name"], "bare_widget");
2000        assert_eq!(value["struct"], "Bare");
2001        assert!(value.get("crate").is_none());
2002        assert!(value.get("constructor").is_none());
2003    }
2004
2005    #[test]
2006    fn widget_macro_rejects_constructor_attribute() {
2007        let input: proc_macro2::TokenStream = quote! {
2008            #[widget(type_name = "my_gauge", constructor = "x::y::new()")]
2009            pub struct Gauge {
2010                pub value: f32,
2011            }
2012        };
2013        let err = widget_impl(input).expect_err("constructor attribute should be rejected");
2014        assert!(
2015            err.to_string().contains("Cargo.toml"),
2016            "error should point at Cargo.toml: {err}",
2017        );
2018    }
2019
2020    #[test]
2021    fn escape_json_handles_specials() {
2022        assert_eq!(escape_json("a\"b"), "a\\\"b");
2023        assert_eq!(escape_json("a\\b"), "a\\\\b");
2024        assert_eq!(escape_json("a\nb"), "a\\nb");
2025        assert_eq!(escape_json("a\tb"), "a\\tb");
2026        assert_eq!(escape_json("normal_text"), "normal_text");
2027    }
2028
2029    #[test]
2030    fn widget_macro_requires_type_name() {
2031        let input: proc_macro2::TokenStream = quote! {
2032            pub struct NoAttr {
2033                pub value: f32,
2034            }
2035        };
2036        assert!(widget_impl(input).is_err());
2037    }
2038
2039    #[test]
2040    fn widget_macro_with_events_block() {
2041        let input: proc_macro2::TokenStream = quote! {
2042            #[widget(type_name = "my_gauge")]
2043            pub struct Gauge {
2044                pub value: f32,
2045            }
2046
2047            events {
2048                ValueChanged(f32),
2049                Cleared,
2050            }
2051        };
2052        let output = widget_impl(input).unwrap().to_string();
2053        assert!(output.contains("GaugeEvent"));
2054        assert!(output.contains("WidgetEvent"));
2055        assert!(output.contains("ValueChanged"));
2056        assert!(output.contains("Cleared"));
2057    }
2058}