Skip to main content

lumen_widget_macros/
lib.rs

1//! `#[derive(Widget)]` - generates a [`lumen_widget::Widget`] impl plus a
2//! companion `Plugin` struct for the annotated component.
3//!
4//! ## What gets emitted
5//!
6//! Given:
7//!
8//! ```ignore
9//! use bevy_ecs::prelude::*;
10//! use lumen_widget_macros::Widget;
11//!
12//! #[derive(Component, Widget, Default)]
13//! #[widget(tag = "hello")]
14//! pub struct Hello {
15//!     #[widget(prop)] pub greeting: String,
16//!     #[widget(state)] pub shown: bool,
17//! }
18//! ```
19//!
20//! the macro emits roughly:
21//!
22//! ```ignore
23//! impl ::lumen_widget::Widget for Hello {
24//!     fn name() -> &'static str { "Hello" }
25//!     fn parser_tag() -> &'static str { "hello" }
26//!     fn spawn(
27//!         entity: ::bevy_ecs::prelude::Entity,
28//!         attrs: &::lumen_widget::Attributes,
29//!         world: &mut ::bevy_ecs::prelude::World,
30//!     ) {
31//!         let mut value: Hello = ::core::default::Default::default();
32//!         if let Some(v) = attrs.get("greeting") { value.greeting = v.into(); }
33//!         world.entity_mut(entity).insert(value);
34//!     }
35//! }
36//!
37//! impl Hello {
38//!     pub fn register() { ::lumen_widget::register_widget::<Hello>(); }
39//! }
40//!
41//! /// Plugin registering `Hello` with the App.
42//! #[derive(Default, Debug, Clone, Copy)]
43//! pub struct HelloPlugin;
44//!
45//! impl ::lumen_core::app::Plugin for HelloPlugin {
46//!     fn name(&self) -> &'static str { "HelloPlugin" }
47//!     fn build(self, _app: &mut ::lumen_core::app::App) {
48//!         Hello::register();
49//!         // Widget-specific systems remain hand-written: authors
50//!         // call `app.add_systems(TickStage::Systems, my_system)`
51//!         // alongside `app.add_plugin(HelloPlugin)`. Future
52//!         // iterations may walk `#[widget(systems = "...")]` here.
53//!     }
54//! }
55//! ```
56//!
57//! The paths above are the ones an engine crate gets, which depends on
58//! `lumen_widget`, `lumen_core` and `bevy_ecs` itself. A crate whose manifest
59//! depends on the Rust SDK (`lumenui`, under any name) gets the same items
60//! through the SDK instead, `::lumenui::lumen_widget::Widget` and so on, so an
61//! app needs no dependency of its own on any of the three.
62//!
63//! ## Attribute reference
64//!
65//! - `#[widget(tag = "name")]` on the struct (required) - the markup
66//!   tag handled by this widget.
67//! - `#[widget(name = "Hello")]` on the struct (optional) - overrides
68//!   the display name returned by [`lumen_widget::Widget::name`].
69//! - `#[widget(plugin = "MyExistingPlugin")]` on the struct (optional):
70//!   skip emitting a fresh Plugin struct; the author is providing their
71//!   own. The derive only emits the `Widget` impl in that case.
72//! - `#[widget(prop)]` on a field - parse from the attribute bag in
73//!   `Widget::spawn`, read off the attribute of the field's name (an
74//!   element written `<hello greeting="hi"/>` sets `greeting`). Field type must implement [`std::str::FromStr`]
75//!   or be a `String` (special-cased: copied verbatim).
76//! - `#[widget(state)]` on a field - leave at the struct's `Default`
77//!   value; never read from the attribute bag. Marker.
78//!
79//! ## What's not covered in v1
80//!
81//! - Per-widget system registration. Authors still call
82//!   `app.add_systems(...)` next to `app.add_plugin(WidgetPlugin)`.
83//! - Nothing here: the generated plugin calls `<Type>::register()` in
84//!   its `build`, which publishes the tag into the registry the lumenc
85//!   parser consults after its built-in tag table, so adding the
86//!   generated plugin is enough for `<my-thing>` markup to parse.
87
88#![forbid(unsafe_code)]
89
90use proc_macro::TokenStream;
91use proc_macro_crate::{FoundCrate, crate_name};
92use proc_macro2::{Span, TokenStream as TokenStream2};
93use quote::{format_ident, quote};
94use syn::{Attribute, Data, DataStruct, DeriveInput, Field, Fields, Lit, Type, parse_macro_input};
95
96/// The derive entry point. See the crate-level docs for the full
97/// surface.
98#[proc_macro_derive(Widget, attributes(widget))]
99pub fn derive_widget(input: TokenStream) -> TokenStream {
100    let input = parse_macro_input!(input as DeriveInput);
101    match expand(input) {
102        Ok(ts) => ts.into(),
103        Err(e) => e.to_compile_error().into(),
104    }
105}
106
107#[derive(Default)]
108struct StructAttrs {
109    tag: Option<String>,
110    name_override: Option<String>,
111    /// When set, skip emitting a Plugin struct; the author provides one.
112    plugin_override: Option<String>,
113}
114
115#[derive(Default)]
116struct FieldKind {
117    is_prop: bool,
118    is_state: bool,
119}
120
121fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
122    let ident = input.ident.clone();
123    let display_name = ident.to_string();
124
125    let struct_attrs = parse_struct_attrs(&input.attrs)?;
126    let tag = struct_attrs.tag.clone().ok_or_else(|| {
127        syn::Error::new_spanned(
128            &ident,
129            "#[derive(Widget)] requires #[widget(tag = \"...\")] on the struct",
130        )
131    })?;
132    let widget_name = struct_attrs
133        .name_override
134        .clone()
135        .unwrap_or(display_name.clone());
136
137    let fields = match &input.data {
138        Data::Struct(DataStruct {
139            fields: Fields::Named(named),
140            ..
141        }) => named.named.iter().collect::<Vec<_>>(),
142        Data::Struct(DataStruct {
143            fields: Fields::Unit,
144            ..
145        }) => Vec::new(),
146        _ => {
147            return Err(syn::Error::new_spanned(
148                &ident,
149                "#[derive(Widget)] only supports structs with named fields (or unit structs)",
150            ));
151        }
152    };
153
154    let mut prop_writes = Vec::new();
155    for f in &fields {
156        let kind = parse_field_attrs(&f.attrs)?;
157        if kind.is_prop && kind.is_state {
158            return Err(syn::Error::new_spanned(
159                f,
160                "field cannot be both #[widget(prop)] and #[widget(state)]",
161            ));
162        }
163        if !kind.is_prop {
164            continue;
165        }
166        prop_writes.push(emit_prop_write(f)?);
167    }
168
169    let Paths { widget, core, ecs } = paths();
170    let widget_impl = quote! {
171        impl #widget::Widget for #ident {
172            fn name() -> &'static str {
173                #widget_name
174            }
175            fn parser_tag() -> &'static str {
176                #tag
177            }
178            fn spawn(
179                entity: #ecs::prelude::Entity,
180                attrs: &#widget::Attributes,
181                world: &mut #ecs::prelude::World,
182            ) {
183                let mut value: #ident = ::core::default::Default::default();
184                #(#prop_writes)*
185                #ecs::prelude::World::entity_mut(world, entity).insert(value);
186            }
187        }
188
189        impl #ident {
190            #[doc = concat!(
191                "Register the `",
192                #tag,
193                "` markup tag with the lumenc HTML parser. Call this at app startup before `lumenc::run::run_app` so the parser accepts `<",
194                #tag,
195                " ...>` instead of rejecting it as `UnknownTag`, and every element spawned for it becomes this widget. Subsequent calls are no-ops.",
196            )]
197            pub fn register() {
198                #widget::register_widget::<#ident>();
199            }
200        }
201    };
202
203    let plugin_tokens = if struct_attrs.plugin_override.is_some() {
204        // Author owns the plugin struct - don't shadow it.
205        TokenStream2::new()
206    } else {
207        let plugin_ident = format_ident!("{}Plugin", ident);
208        let plugin_name_lit = format!("{plugin_ident}");
209        quote! {
210            #[doc = concat!(
211                "Plugin generated by `#[derive(Widget)]` for `",
212                stringify!(#ident),
213                "`. Registers the widget with the App. Widget-specific systems remain hand-written - call `app.add_systems(...)` alongside `app.add_plugin(",
214                stringify!(#plugin_ident),
215                ")`.",
216            )]
217            #[derive(Default, Debug, Clone, Copy)]
218            pub struct #plugin_ident;
219
220            impl #core::app::Plugin for #plugin_ident {
221                fn name(&self) -> &'static str {
222                    #plugin_name_lit
223                }
224                fn build(self, _app: &mut #core::app::App) {
225                    // Self-register the markup tag with the lumenc
226                    // parser registry so `<#tag ...>` markup is accepted
227                    // by parse_html. Idempotent - subsequent calls
228                    // (across multiple App rebuilds) are no-ops.
229                    <#ident>::register();
230                    // Widget-specific systems are author-supplied.
231                }
232            }
233        }
234    };
235
236    Ok(quote! {
237        #widget_impl
238        #plugin_tokens
239    })
240}
241
242/// The paths the expansion names the widget, core and ECS crates by.
243struct Paths {
244    widget: TokenStream2,
245    core: TokenStream2,
246    ecs: TokenStream2,
247}
248
249/// Where the three crates the expansion names are reached from, read off the
250/// manifest of the crate being compiled.
251///
252/// An app on the Rust SDK depends on `lumenui` alone, and must: its own
253/// dependency on any of the three would compile a second copy, whose types
254/// are not the engine's. So when the manifest names the SDK - the published
255/// crate or the engine side of it, renamed or not - the expansion goes
256/// through the SDK's re-exports. Anything else is an engine crate that
257/// depends on the three directly, and gets them by their own names.
258fn paths() -> Paths {
259    for sdk in ["lumenui", "lumenui-engine"] {
260        let root = match crate_name(sdk) {
261            Ok(FoundCrate::Name(name)) => format_ident!("{}", name),
262            // The SDK crate itself (its library says `extern crate self` under
263            // its own name, so the path works inside it too) or one of its
264            // examples, which reach it by that name.
265            Ok(FoundCrate::Itself) => syn::Ident::new(&sdk.replace('-', "_"), Span::call_site()),
266            Err(_) => continue,
267        };
268        return Paths {
269            widget: quote!(::#root::lumen_widget),
270            core: quote!(::#root::lumen_core),
271            ecs: quote!(::#root::bevy_ecs),
272        };
273    }
274    Paths {
275        widget: quote!(::lumen_widget),
276        core: quote!(::lumen_core),
277        ecs: quote!(::bevy_ecs),
278    }
279}
280
281fn parse_struct_attrs(attrs: &[Attribute]) -> syn::Result<StructAttrs> {
282    let mut out = StructAttrs::default();
283    for attr in attrs {
284        if !attr.path().is_ident("widget") {
285            continue;
286        }
287        // We accept any combination of `key = "value"` pairs inside a
288        // single #[widget(...)] attribute.
289        attr.parse_nested_meta(|meta| {
290            if meta.path.is_ident("tag") {
291                let value = meta.value()?;
292                let lit: Lit = value.parse()?;
293                if let Lit::Str(s) = lit {
294                    out.tag = Some(s.value());
295                    return Ok(());
296                }
297                return Err(meta.error("`tag` must be a string literal"));
298            }
299            if meta.path.is_ident("name") {
300                let value = meta.value()?;
301                let lit: Lit = value.parse()?;
302                if let Lit::Str(s) = lit {
303                    out.name_override = Some(s.value());
304                    return Ok(());
305                }
306                return Err(meta.error("`name` must be a string literal"));
307            }
308            if meta.path.is_ident("plugin") {
309                let value = meta.value()?;
310                let lit: Lit = value.parse()?;
311                if let Lit::Str(s) = lit {
312                    out.plugin_override = Some(s.value());
313                    return Ok(());
314                }
315                return Err(meta.error("`plugin` must be a string literal"));
316            }
317            Err(meta.error("unknown `#[widget(...)]` key on struct"))
318        })?;
319    }
320    Ok(out)
321}
322
323fn parse_field_attrs(attrs: &[Attribute]) -> syn::Result<FieldKind> {
324    let mut k = FieldKind::default();
325    for attr in attrs {
326        if !attr.path().is_ident("widget") {
327            continue;
328        }
329        attr.parse_nested_meta(|meta| {
330            if meta.path.is_ident("prop") {
331                k.is_prop = true;
332                return Ok(());
333            }
334            if meta.path.is_ident("state") {
335                k.is_state = true;
336                return Ok(());
337            }
338            Err(meta.error("unknown `#[widget(...)]` key on field - expected `prop` or `state`"))
339        })?;
340    }
341    Ok(k)
342}
343
344fn emit_prop_write(field: &Field) -> syn::Result<TokenStream2> {
345    let ident = field
346        .ident
347        .as_ref()
348        .ok_or_else(|| syn::Error::new_spanned(field, "#[widget(prop)] requires a named field"))?;
349    let key = ident.to_string();
350    if type_is_string(&field.ty) {
351        return Ok(quote! {
352            if let Some(__v) = attrs.get(#key) {
353                value.#ident = __v.to_string();
354            }
355        });
356    }
357    let ty = &field.ty;
358    Ok(quote! {
359        if let Some(__parsed) = attrs.parse::<#ty>(#key) {
360            value.#ident = __parsed;
361        }
362    })
363}
364
365fn type_is_string(ty: &Type) -> bool {
366    if let Type::Path(tp) = ty
367        && let Some(seg) = tp.path.segments.last()
368    {
369        return seg.ident == "String";
370    }
371    false
372}