macroforge_ts_macros/
lib.rs

1use convert_case::{Case, Casing};
2use proc_macro::TokenStream;
3use proc_macro2::{Span, TokenStream as TokenStream2};
4use quote::{format_ident, quote};
5use syn::{Ident, ItemFn, LitStr, Result, parse::Parser, parse_macro_input, spanned::Spanned};
6
7#[proc_macro_attribute]
8pub fn ts_macro_derive(attr: TokenStream, item: TokenStream) -> TokenStream {
9    let options = match parse_macro_options(TokenStream2::from(attr)) {
10        Ok(opts) => opts,
11        Err(err) => return err.to_compile_error().into(),
12    };
13
14    let mut function = parse_macro_input!(item as ItemFn);
15    function
16        .attrs
17        .retain(|attr| !attr.path().is_ident("ts_macro_derive"));
18
19    let fn_ident = function.sig.ident.clone();
20    let struct_ident = pascal_case_ident(&fn_ident);
21
22    // Macro name is now required as first argument, convert to LitStr
23    let macro_name = LitStr::new(&options.name.to_string(), options.name.span());
24    let description = options
25        .description
26        .clone()
27        .unwrap_or_else(|| LitStr::new("", Span::call_site()));
28
29    // Derive package from CARGO_PKG_NAME
30    let package_expr = quote! { env!("CARGO_PKG_NAME") };
31
32    // Module is always resolved dynamically at runtime based on import source
33    let module_expr = quote! { "__DYNAMIC_MODULE__" };
34
35    // Default runtime to ["native"]
36    let runtime_values = [LitStr::new("native", Span::call_site())];
37    let runtime_exprs = runtime_values.iter().map(|lit| quote! { #lit });
38
39    let kind_expr = options.kind.as_tokens();
40
41    // Generate decorators from attributes list
42    let decorator_exprs = options
43        .attributes
44        .iter()
45        .map(|attr_name| generate_decorator_descriptor(attr_name, &package_expr));
46    let decorator_stubs = options.attributes.iter().map(|attr_name| {
47        generate_decorator_stub(attr_name, &struct_ident, options.description.as_ref())
48    });
49
50    let descriptor_ident = format_ident!(
51        "__TS_MACRO_DESCRIPTOR_{}",
52        struct_ident.to_string().to_uppercase()
53    );
54    let decorator_array_ident = format_ident!(
55        "__TS_MACRO_DECORATORS_{}",
56        struct_ident.to_string().to_uppercase()
57    );
58    let ctor_ident = format_ident!(
59        "__ts_macro_ctor_{}",
60        struct_ident.to_string().trim_start_matches("r#")
61    );
62
63    let main_macro_stub_fn_ident = format_ident!(
64        "__ts_macro_runtime_stub_{}",
65        struct_ident.to_string().to_case(Case::Snake)
66    );
67    let features_args_type = features_args_type_literal();
68    let main_macro_napi_stub = quote! {
69        #[macroforge_ts::napi_derive::napi(
70            js_name = #macro_name,
71            ts_return_type = "ClassDecorator",
72            ts_args_type = #features_args_type
73        )]
74        pub fn #main_macro_stub_fn_ident() -> macroforge_ts::napi::Result<()> {
75            // This stub function does nothing at runtime; its purpose is purely for TypeScript import resolution.
76            Ok(())
77        }
78    };
79
80    // Generate the runMacro NAPI function for this specific macro
81    // Use the macro name (not the struct name) for consistent naming
82    let run_macro_fn_ident = format_ident!(
83        "__ts_macro_run_{}",
84        options.name.to_string().to_case(Case::Snake)
85    );
86    let run_macro_js_name = format!("__macroforgeRun{}", options.name);
87    let run_macro_js_name_lit = LitStr::new(&run_macro_js_name, Span::call_site());
88
89    let run_macro_napi = quote! {
90        /// Run this macro with the given context
91        /// Called by the TS plugin to execute macro expansion
92        #[macroforge_ts::napi_derive::napi(js_name = #run_macro_js_name_lit)]
93        pub fn #run_macro_fn_ident(context_json: String) -> macroforge_ts::napi::Result<String> {
94            use macroforge_ts::host::Macroforge;
95
96            // Parse the context from JSON
97            let ctx: macroforge_ts::ts_syn::MacroContextIR = macroforge_ts::serde_json::from_str(&context_json)
98                .map_err(|e| macroforge_ts::napi::Error::new(macroforge_ts::napi::Status::InvalidArg, format!("Invalid context JSON: {}", e)))?;
99
100            // Create TsStream from context
101            let input = macroforge_ts::ts_syn::TsStream::with_context(&ctx.target_source, &ctx.file_name, ctx.clone())
102                .map_err(|e| macroforge_ts::napi::Error::new(macroforge_ts::napi::Status::GenericFailure, format!("Failed to create TsStream: {:?}", e)))?;
103
104            // Run the macro
105            let macro_impl = #struct_ident;
106            let result = macro_impl.run(input);
107
108            // Serialize result to JSON
109            macroforge_ts::serde_json::to_string(&result)
110                .map_err(|e| macroforge_ts::napi::Error::new(macroforge_ts::napi::Status::GenericFailure, format!("Failed to serialize result: {}", e)))
111        }
112    };
113
114    let output = quote! {
115        #function
116
117        pub struct #struct_ident;
118
119        impl macroforge_ts::host::Macroforge for #struct_ident {
120            fn name(&self) -> &str {
121                #macro_name
122            }
123
124            fn kind(&self) -> macroforge_ts::ts_syn::MacroKind {
125                #kind_expr
126            }
127
128            fn run(&self, input: macroforge_ts::ts_syn::TsStream) -> macroforge_ts::ts_syn::MacroResult {
129                match #fn_ident(input) {
130                    Ok(stream) => macroforge_ts::ts_syn::TsStream::into_result(stream),
131                    Err(err) => err.into(),
132                }
133            }
134
135            fn description(&self) -> &str {
136                #description
137            }
138        }
139
140        #[allow(non_upper_case_globals)]
141        const #ctor_ident: fn() -> std::sync::Arc<dyn macroforge_ts::host::Macroforge> = || {
142            std::sync::Arc::new(#struct_ident)
143        };
144
145        #[allow(non_upper_case_globals)]
146        static #decorator_array_ident: &[macroforge_ts::host::derived::DecoratorDescriptor] = &[
147            #(#decorator_exprs),*
148        ];
149
150        #[allow(non_upper_case_globals)]
151        static #descriptor_ident: macroforge_ts::host::derived::DerivedMacroDescriptor =
152            macroforge_ts::host::derived::DerivedMacroDescriptor {
153                package: #package_expr,
154                module: #module_expr,
155                runtime: &[#(#runtime_exprs),*],
156                name: #macro_name,
157                kind: #kind_expr,
158                description: #description,
159                constructor: #ctor_ident,
160                decorators: #decorator_array_ident,
161            };
162
163        macroforge_ts::inventory::submit! {
164            macroforge_ts::host::derived::DerivedMacroRegistration {
165                descriptor: &#descriptor_ident
166            }
167        }
168
169        #(#decorator_stubs)*
170
171        #main_macro_napi_stub
172
173        #run_macro_napi
174    };
175
176    output.into()
177}
178
179fn pascal_case_ident(ident: &Ident) -> Ident {
180    let raw = ident.to_string();
181    let trimmed = raw.trim_start_matches("r#");
182    let pascal = trimmed.to_case(Case::Pascal);
183    format_ident!("{}", pascal)
184}
185
186fn parse_macro_options(tokens: TokenStream2) -> Result<MacroOptions> {
187    if tokens.is_empty() {
188        return Err(syn::Error::new(
189            Span::call_site(),
190            "ts_macro_derive requires a macro name as the first argument",
191        ));
192    }
193
194    let mut opts = MacroOptions::default();
195    let mut tokens_iter = tokens.into_iter().peekable();
196
197    // First argument must be an identifier (the macro name)
198    let first = tokens_iter
199        .next()
200        .ok_or_else(|| syn::Error::new(Span::call_site(), "expected macro name"))?;
201
202    opts.name = match first {
203        proc_macro2::TokenTree::Ident(ident) => ident,
204        _ => {
205            return Err(syn::Error::new(
206                first.span(),
207                "macro name must be an identifier",
208            ));
209        }
210    };
211
212    // Consume optional comma
213    if let Some(proc_macro2::TokenTree::Punct(p)) = tokens_iter.peek()
214        && p.as_char() == ','
215    {
216        tokens_iter.next();
217    }
218
219    // Collect remaining tokens for meta parsing
220    let remaining: TokenStream2 = tokens_iter.collect();
221
222    if !remaining.is_empty() {
223        let parser = syn::meta::parser(|meta| {
224            if meta.path.is_ident("description") {
225                opts.description = Some(meta.value()?.parse()?);
226            } else if meta.path.is_ident("kind") {
227                let lit: LitStr = meta.value()?.parse()?;
228                opts.kind = MacroKindOption::from_lit(&lit)?;
229            } else if meta.path.is_ident("attributes") {
230                meta.parse_nested_meta(|attr_meta| {
231                    if let Some(ident) = attr_meta.path.get_ident() {
232                        opts.attributes.push(ident.clone());
233                    } else {
234                        return Err(syn::Error::new(
235                            attr_meta.path.span(),
236                            "attribute name must be an identifier",
237                        ));
238                    }
239                    Ok(())
240                })?;
241            } else {
242                return Err(syn::Error::new(
243                    meta.path.span(),
244                    "unknown ts_macro_derive option",
245                ));
246            }
247            Ok(())
248        });
249
250        parser.parse2(remaining)?;
251    }
252
253    Ok(opts)
254}
255
256struct MacroOptions {
257    name: Ident,
258    description: Option<LitStr>,
259    kind: MacroKindOption,
260    attributes: Vec<Ident>,
261}
262
263impl Default for MacroOptions {
264    fn default() -> Self {
265        MacroOptions {
266            name: Ident::new("Unknown", Span::call_site()),
267            description: None,
268            kind: MacroKindOption::Derive,
269            attributes: Vec::new(),
270        }
271    }
272}
273
274fn features_args_type_literal() -> LitStr {
275    // Allow strings, decorators, closures, or option objects (for field-level configs)
276    LitStr::new(
277        "...features: Array<string | ClassDecorator | PropertyDecorator | ((...args:\n  any[]) => unknown) | Record<string, unknown>>",
278        Span::call_site(),
279    )
280}
281
282// Helper function to generate a decorator descriptor from an attribute identifier
283fn generate_decorator_descriptor(attr_name: &Ident, package_expr: &TokenStream2) -> TokenStream2 {
284    let attr_str = LitStr::new(&attr_name.to_string(), attr_name.span());
285    let kind = quote! { macroforge_ts::host::derived::DecoratorKind::Property };
286    let docs = LitStr::new("", Span::call_site());
287
288    quote! {
289        macroforge_ts::host::derived::DecoratorDescriptor {
290            module: #package_expr,
291            export: #attr_str,
292            kind: #kind,
293            docs: #docs,
294        }
295    }
296}
297
298// Helper function to generate a napi stub for an attribute decorator
299fn generate_decorator_stub(
300    attr_name: &Ident,
301    owner_ident: &Ident,
302    description: Option<&LitStr>,
303) -> TokenStream2 {
304    let owner_snake = owner_ident.to_string().to_case(Case::Snake);
305    let decorator_snake = attr_name.to_string().to_case(Case::Snake);
306    let fn_ident = format_ident!("__ts_macro_stub_{}_{}", owner_snake, decorator_snake);
307    let js_name = LitStr::new(&attr_name.to_string(), attr_name.span());
308
309    let doc_comment = description.map(|desc| {
310        let desc_str = desc.value();
311        quote! {
312            #[doc = #desc_str]
313        }
314    });
315
316    let decorator_args_type = features_args_type_literal();
317
318    quote! {
319        #doc_comment
320        #[macroforge_ts::napi_derive::napi(
321            js_name = #js_name,
322            ts_args_type = #decorator_args_type,
323            ts_return_type = "PropertyDecorator"
324        )]
325        pub fn #fn_ident() -> macroforge_ts::napi::Result<()> {
326            Ok(())
327        }
328    }
329}
330
331#[derive(Clone, Default)]
332enum MacroKindOption {
333    #[default]
334    Derive,
335    Attribute,
336    Call,
337}
338
339impl MacroKindOption {
340    fn as_tokens(&self) -> TokenStream2 {
341        match self {
342            MacroKindOption::Derive => quote! { macroforge_ts::ts_syn::MacroKind::Derive },
343            MacroKindOption::Attribute => quote! { macroforge_ts::ts_syn::MacroKind::Attribute },
344            MacroKindOption::Call => quote! { macroforge_ts::ts_syn::MacroKind::Call },
345        }
346    }
347
348    fn from_lit(lit: &LitStr) -> Result<Self> {
349        match lit.value().to_ascii_lowercase().as_str() {
350            "derive" => Ok(MacroKindOption::Derive),
351            "attribute" => Ok(MacroKindOption::Attribute),
352            "function" | "call" => Ok(MacroKindOption::Call),
353            _ => Err(syn::Error::new(
354                lit.span(),
355                "kind must be one of 'derive', 'attribute', or 'function'",
356            )),
357        }
358    }
359}