Skip to main content

luna_jit_derive/
lib.rs

1//! Procedural macros for the luna-jit Lua runtime (v1.3 Phase UD3).
2//!
3//! This crate ships two macros that, together, let an embedder turn a
4//! plain Rust `struct` + `impl` block into a Lua-callable userdata
5//! without typing the `luna_core::vm::UserdataMethods` builder
6//! boilerplate:
7//!
8//! 1. `#[derive(LuaUserdata)]` — applied to a struct, emits the
9//!    `luna_core::vm::LuaUserdata` trait impl with `type_name()`
10//!    (overridable via `#[lua_type_name = "Foo"]`) and an
11//!    `add_methods()` body that forwards to a hidden registry fn
12//!    populated by [`macro@lua_userdata_methods`].
13//! 2. `#[lua_userdata_methods]` — applied to an `impl T { ... }`
14//!    block, walks the inner `fn` items and, for each one tagged with
15//!    one of the helper attributes below, emits the matching
16//!    `m.add_method(...)` / `m.add_field_method_get(...)` / etc. call.
17//!
18//! ## Helper attributes
19//!
20//! Applied to `fn` items inside the `#[lua_userdata_methods]` impl
21//! block:
22//!
23//! | Attribute | Sig pattern | Lowers to |
24//! |---|---|---|
25//! | `#[lua_method("name")]` | `fn(&self, &mut Vm, A) -> Result<R, LuaError>` | `m.add_method` |
26//! | `#[lua_method_mut("name")]` | `fn(&mut self, &mut Vm, A) -> Result<R, LuaError>` | `m.add_method_mut` |
27//! | `#[lua_function("name")]` | `fn(&mut Vm, A) -> Result<R, LuaError>` (no receiver) | `m.add_function` |
28//! | `#[lua_meta_method(Add)]` | `fn(&self, &mut Vm, A) -> Result<R, LuaError>` | `m.add_meta_method` |
29//! | `#[lua_meta_method_mut(Concat)]` | `fn(&mut self, ...)` | `m.add_meta_method_mut` |
30//! | `#[lua_field_get("name")]` | `fn(&self, &mut Vm) -> Result<R, LuaError>` (no args) | `m.add_field_method_get` |
31//! | `#[lua_field_set("name")]` | `fn(&mut self, &mut Vm, A) -> Result<(), LuaError>` | `m.add_field_method_set` |
32//! | `#[lua_skip]` | (any fn) | nothing — keeps fn in impl as a Rust-only helper |
33//!
34//! Names default to the Rust fn ident when omitted.
35//!
36//! ## ZST constraint
37//!
38//! The v1.2 trampoline accepts only ZST / fn-pointer-sized closures
39//! (`luna_core::vm::userdata_trait::pack_zst_or_fnptr`). The derive
40//! therefore emits **non-capturing** forwarding closures of the form
41//! `|__vm, __this, __args| Self::name(__this, __vm, __args)` — these
42//! are ZSTs whose only state is the static fn pointer to the named
43//! associated function.
44
45use proc_macro::TokenStream;
46use proc_macro2::TokenStream as TokenStream2;
47use quote::{format_ident, quote};
48use syn::{
49    Attribute, DeriveInput, FnArg, ImplItem, ImplItemFn, Item, ItemImpl, Lit, LitStr, Meta,
50    MetaNameValue, ReturnType, Type, parse_macro_input, spanned::Spanned,
51};
52
53// ─────────────────────────────────────────────────────────────────────
54// #[derive(LuaUserdata)]
55// ─────────────────────────────────────────────────────────────────────
56
57/// Emits the `luna_core::vm::LuaUserdata` trait impl for a struct.
58///
59/// The companion attribute macro [`macro@lua_userdata_methods`] (applied
60/// to an `impl` block of the same struct) injects a hidden
61/// `__luna_userdata_register` associated fn that the derive's
62/// `add_methods()` body forwards to.
63///
64/// Accepts `#[lua_type_name = "Foo"]` at the struct level to override
65/// the default `type_name()` (which falls back to the struct's Rust
66/// ident). Embedders who don't need to override can omit the attr.
67///
68/// ## Example
69///
70/// ```ignore
71/// use luna_jit_derive::{LuaUserdata, lua_userdata_methods};
72/// use luna_core::vm::{LuaError, MetaMethod, UserdataMethods, Vm};
73///
74/// #[derive(LuaUserdata)]
75/// #[lua_type_name = "Counter"]
76/// struct Counter { value: i64 }
77///
78/// #[lua_userdata_methods]
79/// impl Counter {
80///     #[lua_method("get")]
81///     fn get(&self, _vm: &mut Vm, _: ()) -> Result<i64, LuaError> {
82///         Ok(self.value)
83///     }
84/// }
85/// ```
86#[proc_macro_derive(LuaUserdata, attributes(lua_type_name))]
87pub fn derive_lua_userdata(input: TokenStream) -> TokenStream {
88    let input = parse_macro_input!(input as DeriveInput);
89    let name = &input.ident;
90
91    // Reject derive on enum/union — userdata payloads are struct-shaped
92    // by convention (the v1.2 `UserdataPayload::Host(Box<dyn Any>)`
93    // can hold any type, but `add_methods` is implementation-defined
94    // and we want a clear error rather than runtime confusion).
95    match &input.data {
96        syn::Data::Struct(_) => {}
97        syn::Data::Enum(_) => {
98            return syn::Error::new(
99                input.ident.span(),
100                "#[derive(LuaUserdata)] only supports structs; got an enum. \
101                 Wrap the enum in a newtype struct for now (v1.3 limitation).",
102            )
103            .to_compile_error()
104            .into();
105        }
106        syn::Data::Union(_) => {
107            return syn::Error::new(
108                input.ident.span(),
109                "#[derive(LuaUserdata)] only supports structs; got a union.",
110            )
111            .to_compile_error()
112            .into();
113        }
114    }
115
116    // Optional #[lua_type_name = "..."] override.
117    let type_name_override = parse_lua_type_name(&input.attrs);
118
119    // We emit a stub `add_methods` that forwards to a hidden
120    // `__luna_userdata_register` fn. The companion attribute macro
121    // injects that hidden fn when applied to the type's impl block;
122    // if the embedder skipped that attribute (no methods), we provide
123    // a default-empty registration by relying on the trait's default
124    // `add_methods` — done by simply NOT emitting the body when the
125    // registry fn is absent. But we don't know that at derive time, so
126    // emit the forwarding call unconditionally and let the compiler
127    // bark with a clear "no method named `__luna_userdata_register`" if
128    // the embedder forgot.
129    //
130    // To make the zero-method case still work, gate the forwarding
131    // call behind a `cfg(luna_userdata_register_present)` style trick?
132    // Too fragile. Simpler: emit the registry call inside a trait
133    // method that *defaults* to a no-op via a marker trait — but that
134    // also adds complexity. Cleanest is to require pairing — document
135    // it and let rustc give a clear error.
136
137    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
138
139    let type_name_body = match type_name_override {
140        Some(lit) => quote! { #lit },
141        None => {
142            let n = name.to_string();
143            quote! { #n }
144        }
145    };
146
147    let expanded = quote! {
148        impl #impl_generics ::luna_core::vm::LuaUserdata for #name #ty_generics #where_clause {
149            fn type_name() -> &'static str { #type_name_body }
150            fn add_methods<__M: ::luna_core::vm::UserdataMethods<Self>>(__m: &mut __M) {
151                <Self>::__luna_userdata_register(__m);
152            }
153        }
154    };
155    expanded.into()
156}
157
158fn parse_lua_type_name(attrs: &[Attribute]) -> Option<LitStr> {
159    for attr in attrs {
160        if !attr.path().is_ident("lua_type_name") {
161            continue;
162        }
163        if let Meta::NameValue(MetaNameValue {
164            value: syn::Expr::Lit(syn::ExprLit {
165                lit: Lit::Str(s), ..
166            }),
167            ..
168        }) = &attr.meta
169        {
170            return Some(s.clone());
171        }
172    }
173    None
174}
175
176// ─────────────────────────────────────────────────────────────────────
177// #[lua_userdata_methods] — attribute macro on impl blocks
178// ─────────────────────────────────────────────────────────────────────
179
180/// Walks the methods of an `impl T { ... }` block and, for each one
181/// tagged with a helper attribute (`#[lua_method("name")]` etc.),
182/// emits the corresponding `UserdataMethods` builder call inside a
183/// hidden `__luna_userdata_register` associated fn.
184///
185/// The `UserdataMethods` trait lives in
186/// `::luna_core::vm::UserdataMethods` — the emitted code references it
187/// by absolute path so the derive works for pure luna-core embedders
188/// too. The intra-doc-link form is omitted because this proc-macro
189/// crate cannot see luna_core in its `cargo doc` scope.
190///
191/// The original impl block is re-emitted unchanged (minus the helper
192/// attributes themselves), so the named fns are still directly
193/// callable from Rust.
194#[proc_macro_attribute]
195pub fn lua_userdata_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
196    let mut input = parse_macro_input!(item as ItemImpl);
197    let self_ty = &*input.self_ty;
198
199    let mut registrations: Vec<TokenStream2> = Vec::new();
200    let mut errors: Vec<syn::Error> = Vec::new();
201
202    for impl_item in &mut input.items {
203        if let ImplItem::Fn(method) = impl_item {
204            match classify_method(method) {
205                Ok(MethodKind::Skip) => {
206                    strip_helper_attrs(&mut method.attrs);
207                }
208                Ok(MethodKind::Register(reg)) => {
209                    strip_helper_attrs(&mut method.attrs);
210                    registrations.push(reg.emit(self_ty));
211                }
212                Ok(MethodKind::Plain) => {
213                    // Unannotated fn — leave as plain Rust helper.
214                }
215                Err(e) => errors.push(e),
216            }
217        }
218    }
219
220    if !errors.is_empty() {
221        let combined = errors
222            .into_iter()
223            .map(|e| e.to_compile_error())
224            .collect::<TokenStream2>();
225        return combined.into();
226    }
227
228    // Hidden registry fn — invoked from the derive-emitted
229    // `add_methods` body. `#[doc(hidden)]` keeps it out of rustdoc.
230    let register_fn = quote! {
231        #[doc(hidden)]
232        #[allow(non_snake_case)]
233        pub fn __luna_userdata_register<__M: ::luna_core::vm::UserdataMethods<Self>>(__m: &mut __M) {
234            #(#registrations)*
235        }
236    };
237
238    // Append the registry fn to the impl block.
239    let register_item: ImplItem = syn::parse2(register_fn).expect("registry fn parse");
240    input.items.push(register_item);
241
242    quote! { #input }.into()
243}
244
245// ─────────────────────────────────────────────────────────────────────
246// Method classification
247// ─────────────────────────────────────────────────────────────────────
248
249enum MethodKind {
250    Plain,
251    Skip,
252    Register(Registration),
253}
254
255struct Registration {
256    builder_method: &'static str, // "add_method" / "add_method_mut" / ...
257    name: LitStr,
258    fn_ident: syn::Ident,
259    meta_variant: Option<syn::Ident>, // for add_meta_method / _mut
260    has_receiver: bool,
261}
262
263impl Registration {
264    fn emit(&self, self_ty: &Type) -> TokenStream2 {
265        let fn_ident = &self.fn_ident;
266        let lua_name = &self.name;
267        let builder = format_ident!("{}", self.builder_method);
268
269        // Non-capturing forwarding closure — required by the v1.2
270        // `pack_zst_or_fnptr` ZST/fn-pointer-only constraint. The
271        // closure references `Self::ident` (a fn item, which IS a
272        // ZST), so the closure itself stays ZST.
273        if let Some(variant) = &self.meta_variant {
274            // add_meta_method[_mut](MetaMethod::Variant, fwd)
275            if self.has_receiver {
276                quote! {
277                    __m.#builder(
278                        ::luna_core::vm::MetaMethod::#variant,
279                        |__vm, __this, __args| <#self_ty>::#fn_ident(__this, __vm, __args),
280                    );
281                }
282            } else {
283                // Meta method without receiver doesn't make semantic
284                // sense, but emit conservatively for clarity.
285                quote! {
286                    __m.#builder(
287                        ::luna_core::vm::MetaMethod::#variant,
288                        |__vm, _, __args| <#self_ty>::#fn_ident(__vm, __args),
289                    );
290                }
291            }
292        } else if self.builder_method == "add_function" {
293            // No receiver; closure shape is (&mut Vm, A).
294            quote! {
295                __m.#builder(#lua_name, |__vm, __args| <#self_ty>::#fn_ident(__vm, __args));
296            }
297        } else if self.builder_method == "add_field_method_get" {
298            // (&mut Vm, &T) — no args.
299            quote! {
300                __m.#builder(#lua_name, |__vm, __this| <#self_ty>::#fn_ident(__this, __vm));
301            }
302        } else {
303            // add_method / add_method_mut / add_field_method_set —
304            // closure shape is (&mut Vm, &T/&mut T, A).
305            quote! {
306                __m.#builder(#lua_name, |__vm, __this, __args| {
307                    <#self_ty>::#fn_ident(__this, __vm, __args)
308                });
309            }
310        }
311    }
312}
313
314fn classify_method(method: &ImplItemFn) -> Result<MethodKind, syn::Error> {
315    let mut found: Option<(&'static str, Option<LitStr>, Option<syn::Ident>)> = None;
316
317    for attr in &method.attrs {
318        let path = attr.path();
319
320        if path.is_ident("lua_skip") {
321            return Ok(MethodKind::Skip);
322        }
323
324        let mut try_simple = |bm: &'static str| -> Result<(), syn::Error> {
325            let name_lit = attr_string_arg_opt(attr)?;
326            found = Some((bm, name_lit, None));
327            Ok(())
328        };
329
330        if path.is_ident("lua_method") {
331            try_simple("add_method")?;
332        } else if path.is_ident("lua_method_mut") {
333            try_simple("add_method_mut")?;
334        } else if path.is_ident("lua_function") {
335            try_simple("add_function")?;
336        } else if path.is_ident("lua_field_get") {
337            try_simple("add_field_method_get")?;
338        } else if path.is_ident("lua_field_set") {
339            try_simple("add_field_method_set")?;
340        } else if path.is_ident("lua_meta_method") {
341            let variant = attr_ident_arg(attr)?;
342            found = Some(("add_meta_method", None, Some(variant)));
343        } else if path.is_ident("lua_meta_method_mut") {
344            let variant = attr_ident_arg(attr)?;
345            found = Some(("add_meta_method_mut", None, Some(variant)));
346        }
347    }
348
349    let (builder_method, name_lit, meta_variant) = match found {
350        Some(t) => t,
351        None => return Ok(MethodKind::Plain),
352    };
353
354    // Default to the Rust fn ident as the Lua-side name.
355    let name = name_lit
356        .unwrap_or_else(|| LitStr::new(&method.sig.ident.to_string(), method.sig.ident.span()));
357
358    // Sanity-check the receiver against the kind. We accept anything
359    // for `lua_skip` (already handled). For `lua_function` we expect
360    // NO receiver; for everything else with self, we expect one.
361    let has_receiver = matches!(method.sig.inputs.first(), Some(FnArg::Receiver(_)));
362    let expects_receiver = !matches!(builder_method, "add_function");
363    if expects_receiver && !has_receiver {
364        return Err(syn::Error::new(
365            method.sig.ident.span(),
366            format!(
367                "#[lua_*] attribute lowering to `{}` requires a `&self` or `&mut self` receiver",
368                builder_method
369            ),
370        ));
371    }
372    if !expects_receiver && has_receiver {
373        return Err(syn::Error::new(
374            method.sig.ident.span(),
375            "#[lua_function] must NOT have a `self` receiver — it lowers to a static \
376             `add_function` call. Use #[lua_method] for receiver-bearing methods.",
377        ));
378    }
379
380    // Light return-type sanity (informational; full type-check happens
381    // at the use site when the generated forwarding closure compiles).
382    if let ReturnType::Default = method.sig.output {
383        return Err(syn::Error::new(
384            method.sig.output.span(),
385            "luna userdata methods must return `Result<R, LuaError>`; got `()`",
386        ));
387    }
388
389    Ok(MethodKind::Register(Registration {
390        builder_method,
391        name,
392        fn_ident: method.sig.ident.clone(),
393        meta_variant,
394        has_receiver,
395    }))
396}
397
398/// Parse `#[lua_method("name")]` → `Some("name")`, `#[lua_method]`
399/// (no arg) → `None`.
400fn attr_string_arg_opt(attr: &Attribute) -> Result<Option<LitStr>, syn::Error> {
401    match &attr.meta {
402        Meta::Path(_) => Ok(None),
403        Meta::List(_) => {
404            // Try to parse as a single string literal inside the parens.
405            let s: LitStr = attr.parse_args().map_err(|e| {
406                syn::Error::new(
407                    attr.span(),
408                    format!(
409                        "expected a single string-literal argument, e.g. \
410                         #[lua_method(\"name\")]; got: {e}"
411                    ),
412                )
413            })?;
414            Ok(Some(s))
415        }
416        Meta::NameValue(_) => Err(syn::Error::new(
417            attr.span(),
418            "expected #[lua_method(\"name\")] or bare #[lua_method], \
419             not #[lua_method = \"...\"]",
420        )),
421    }
422}
423
424/// Parse `#[lua_meta_method(Add)]` → `Add` ident.
425fn attr_ident_arg(attr: &Attribute) -> Result<syn::Ident, syn::Error> {
426    attr.parse_args().map_err(|e| {
427        syn::Error::new(
428            attr.span(),
429            format!(
430                "expected a single MetaMethod ident, e.g. #[lua_meta_method(Add)]; \
431                 got: {e}"
432            ),
433        )
434    })
435}
436
437fn strip_helper_attrs(attrs: &mut Vec<Attribute>) {
438    attrs.retain(|a| {
439        let p = a.path();
440        !(p.is_ident("lua_method")
441            || p.is_ident("lua_method_mut")
442            || p.is_ident("lua_function")
443            || p.is_ident("lua_field_get")
444            || p.is_ident("lua_field_set")
445            || p.is_ident("lua_meta_method")
446            || p.is_ident("lua_meta_method_mut")
447            || p.is_ident("lua_skip"))
448    });
449}
450
451// Suppress dead-code warning for `Item` import; reserved for a future
452// "derive on impl block" diagnostic.
453#[allow(dead_code)]
454fn _reserved(_: Item) {}
455
456// ─────────────────────────────────────────────────────────────────────
457// v2.0 Phase 5 Track CV gap fill — direct unit tests for the helper
458// fns that the proc-macro entry points call into.
459//
460// Proc-macro crates can't be `use`d from integration tests (rustc
461// loads them at compile time, not as runtime libraries), so the
462// helper fns get coverage here. Integration coverage for the
463// `#[derive(LuaUserdata)]` + `#[lua_userdata_methods]` expansion
464// already lives in `crates/luna-jit/tests/userdata_derive.rs`; this
465// module fills the gap on the parse / classify / strip helpers.
466// ─────────────────────────────────────────────────────────────────────
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use syn::parse_quote;
472
473    /// `#[lua_type_name = "Foo"]` parses to `Some(LitStr("Foo"))`.
474    /// Bare structs return `None` (caller falls back to ident).
475    #[test]
476    fn parse_lua_type_name_extracts_override() {
477        let with_override: DeriveInput = parse_quote! {
478            #[lua_type_name = "MyType"]
479            struct Foo;
480        };
481        let got = parse_lua_type_name(&with_override.attrs);
482        let lit = got.expect("override missing");
483        assert_eq!(lit.value(), "MyType");
484
485        let no_override: DeriveInput = parse_quote! {
486            struct Bar;
487        };
488        assert!(
489            parse_lua_type_name(&no_override.attrs).is_none(),
490            "absent #[lua_type_name] must return None"
491        );
492    }
493
494    /// `parse_lua_type_name` ignores attrs whose path doesn't match.
495    #[test]
496    fn parse_lua_type_name_ignores_unrelated_attrs() {
497        let input: DeriveInput = parse_quote! {
498            #[derive(Clone)]
499            #[doc = "unrelated"]
500            struct Baz;
501        };
502        assert!(parse_lua_type_name(&input.attrs).is_none());
503    }
504
505    /// `attr_string_arg_opt` parses `#[lua_method("get")]` → `Some("get")`,
506    /// `#[lua_method]` (bare) → `None`, and rejects `=` form.
507    #[test]
508    fn attr_string_arg_opt_parses_three_forms() {
509        // Use a containing item then pull `attrs[0]` since attributes
510        // on their own aren't a free-standing syn parse target.
511        let with_arg: ImplItemFn = parse_quote! {
512            #[lua_method("get")]
513            fn dummy() {}
514        };
515        let lit = attr_string_arg_opt(&with_arg.attrs[0])
516            .expect("parse")
517            .expect("string arg present");
518        assert_eq!(lit.value(), "get");
519
520        let bare: ImplItemFn = parse_quote! {
521            #[lua_method]
522            fn dummy() {}
523        };
524        let none = attr_string_arg_opt(&bare.attrs[0]).expect("parse");
525        assert!(none.is_none(), "bare #[lua_method] must give None");
526
527        // `#[lua_method = "x"]` form should error.
528        let eq_form: ImplItemFn = parse_quote! {
529            #[lua_method = "x"]
530            fn dummy() {}
531        };
532        let err = match attr_string_arg_opt(&eq_form.attrs[0]) {
533            Err(e) => e,
534            Ok(_) => panic!("`=` form must be rejected"),
535        };
536        assert!(
537            err.to_string().contains("#[lua_method"),
538            "err msg should mention the attribute, got {err}"
539        );
540    }
541
542    /// `attr_ident_arg` parses `#[lua_meta_method(Add)]` → `Add` ident.
543    #[test]
544    fn attr_ident_arg_parses_meta_method_variant() {
545        let method: ImplItemFn = parse_quote! {
546            #[lua_meta_method(Add)]
547            fn dummy() {}
548        };
549        let id = attr_ident_arg(&method.attrs[0]).expect("parse Add");
550        assert_eq!(id.to_string(), "Add");
551
552        // No arg → must error.
553        let bare: ImplItemFn = parse_quote! {
554            #[lua_meta_method]
555            fn dummy() {}
556        };
557        assert!(
558            attr_ident_arg(&bare.attrs[0]).is_err(),
559            "missing ident arg must error"
560        );
561    }
562
563    /// `classify_method` correctly classifies `#[lua_skip]` → Skip,
564    /// plain fn → Plain, `#[lua_method(...)]` → Register with the
565    /// proper builder name.
566    #[test]
567    fn classify_method_handles_skip_plain_and_register() {
568        let skipped: ImplItemFn = parse_quote! {
569            #[lua_skip]
570            fn helper(&self) -> i64 { 0 }
571        };
572        assert!(matches!(
573            classify_method(&skipped).expect("parse"),
574            MethodKind::Skip
575        ));
576
577        let plain: ImplItemFn = parse_quote! {
578            fn helper(&self) -> i64 { 0 }
579        };
580        assert!(matches!(
581            classify_method(&plain).expect("parse"),
582            MethodKind::Plain
583        ));
584
585        let with_method: ImplItemFn = parse_quote! {
586            #[lua_method("get")]
587            fn get(&self, _vm: &mut Vm, _args: ()) -> Result<i64, LuaError> { Ok(0) }
588        };
589        match classify_method(&with_method).expect("classify") {
590            MethodKind::Register(reg) => {
591                assert_eq!(reg.builder_method, "add_method");
592                assert_eq!(reg.name.value(), "get");
593                assert!(reg.has_receiver);
594                assert!(reg.meta_variant.is_none());
595            }
596            _ => panic!("expected Register"),
597        }
598    }
599
600    /// `classify_method` rejects `#[lua_method]` on a receiver-less fn
601    /// (only `#[lua_function]` is allowed without `self`).
602    #[test]
603    fn classify_method_rejects_method_without_receiver() {
604        let bad: ImplItemFn = parse_quote! {
605            #[lua_method("oops")]
606            fn no_self(_vm: &mut Vm, _args: ()) -> Result<i64, LuaError> { Ok(0) }
607        };
608        let err = match classify_method(&bad) {
609            Err(e) => e,
610            Ok(_) => panic!("missing self must error"),
611        };
612        assert!(
613            err.to_string().contains("receiver"),
614            "err msg should mention receiver requirement, got: {err}"
615        );
616    }
617
618    /// `classify_method` rejects `#[lua_function]` on a fn with `self`
619    /// (the dual is enforced — function = static, method = bound).
620    #[test]
621    fn classify_method_rejects_function_with_receiver() {
622        let bad: ImplItemFn = parse_quote! {
623            #[lua_function("oops")]
624            fn has_self(&self, _vm: &mut Vm, _args: ()) -> Result<i64, LuaError> { Ok(0) }
625        };
626        let err = match classify_method(&bad) {
627            Err(e) => e,
628            Ok(_) => panic!("self on lua_function must error"),
629        };
630        assert!(
631            err.to_string().contains("self"),
632            "err msg should mention 'self', got: {err}"
633        );
634    }
635
636    /// `classify_method` rejects fns that return `()` (no `Result`).
637    #[test]
638    fn classify_method_rejects_unit_return() {
639        let bad: ImplItemFn = parse_quote! {
640            #[lua_method("bad")]
641            fn no_ret(&self, _vm: &mut Vm, _args: ()) {}
642        };
643        let err = match classify_method(&bad) {
644            Err(e) => e,
645            Ok(_) => panic!("unit return must error"),
646        };
647        assert!(
648            err.to_string().contains("Result"),
649            "err msg should mention 'Result', got: {err}"
650        );
651    }
652
653    /// `classify_method` defaults the Lua-side name to the Rust fn
654    /// ident when the attribute carries no string literal.
655    #[test]
656    fn classify_method_defaults_name_to_fn_ident() {
657        let m: ImplItemFn = parse_quote! {
658            #[lua_method]
659            fn width(&self, _vm: &mut Vm, _args: ()) -> Result<i64, LuaError> { Ok(0) }
660        };
661        match classify_method(&m).expect("classify") {
662            MethodKind::Register(reg) => {
663                assert_eq!(
664                    reg.name.value(),
665                    "width",
666                    "default name must match fn ident"
667                );
668            }
669            _ => panic!("expected Register"),
670        }
671    }
672
673    /// `classify_method` for `#[lua_meta_method(Add)]` captures the
674    /// variant ident and uses the `add_meta_method` builder.
675    #[test]
676    fn classify_method_meta_method_path() {
677        let m: ImplItemFn = parse_quote! {
678            #[lua_meta_method(Add)]
679            fn __add(&self, _vm: &mut Vm, _args: ()) -> Result<i64, LuaError> { Ok(0) }
680        };
681        match classify_method(&m).expect("classify") {
682            MethodKind::Register(reg) => {
683                assert_eq!(reg.builder_method, "add_meta_method");
684                assert_eq!(
685                    reg.meta_variant.as_ref().map(|i| i.to_string()).as_deref(),
686                    Some("Add")
687                );
688            }
689            _ => panic!("expected Register"),
690        }
691    }
692
693    /// `strip_helper_attrs` removes all `#[lua_*]` helper attrs in
694    /// place, leaving non-helper attrs untouched.
695    #[test]
696    fn strip_helper_attrs_removes_only_helpers() {
697        let mut m: ImplItemFn = parse_quote! {
698            #[lua_method("get")]
699            #[inline]
700            #[lua_skip]
701            #[doc = "kept"]
702            fn dummy() {}
703        };
704        assert_eq!(m.attrs.len(), 4);
705        strip_helper_attrs(&mut m.attrs);
706        // Only #[inline] and #[doc = "kept"] should remain.
707        assert_eq!(m.attrs.len(), 2);
708        let keep_paths: Vec<String> = m
709            .attrs
710            .iter()
711            .map(|a| {
712                a.path()
713                    .get_ident()
714                    .map(|i| i.to_string())
715                    .unwrap_or_default()
716            })
717            .collect();
718        assert!(
719            keep_paths.contains(&"inline".to_string()),
720            "kept attrs: {keep_paths:?}"
721        );
722        assert!(
723            keep_paths.contains(&"doc".to_string()),
724            "kept attrs: {keep_paths:?}"
725        );
726    }
727}