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}