nami_derive/
lib.rs

1//! This crate provides the derive macro for the `nami` crate.
2//! It includes the `Project` derive macro and the `s!` procedural macro.
3
4use proc_macro::TokenStream;
5use quote::quote;
6use syn::{
7    parse::Parse, parse_macro_input, punctuated::Punctuated, Data, DeriveInput, Expr, Fields,
8    LitStr, Token, Type,
9};
10
11/// Derive macro for implementing the `Project` trait on structs.
12///
13/// This macro automatically generates a `Project` implementation that allows
14/// decomposing a struct binding into separate bindings for each field.
15///
16/// # Examples
17///
18/// ```rust,ignore
19/// use nami::{Binding, binding};
20/// use nami_derive::Project;
21///
22/// #[derive(Project,Clone,Debug)]
23/// struct Person {
24///     name: String,
25///     age: u32,
26/// }
27///
28/// let person_binding: Binding<Person> = binding(Person {
29///     name: "Alice".to_string(),
30///     age: 30,
31/// });
32///
33/// let mut projected = person_binding.project();
34/// projected.name.set_from("Bob");
35/// projected.age.set(25);
36///
37/// let person = person_binding.get();
38/// assert_eq!(person.name, "Bob");
39/// assert_eq!(person.age, 25);
40/// ```
41#[proc_macro_derive(Project)]
42pub fn derive_project(input: TokenStream) -> TokenStream {
43    let input = parse_macro_input!(input as DeriveInput);
44
45    match &input.data {
46        Data::Struct(data_struct) => match &data_struct.fields {
47            Fields::Named(fields_named) => derive_project_struct(&input, fields_named),
48            Fields::Unnamed(fields_unnamed) => derive_project_tuple_struct(&input, fields_unnamed),
49            Fields::Unit => derive_project_unit_struct(&input),
50        },
51        Data::Enum(_) => {
52            syn::Error::new_spanned(input, "Project derive macro does not support enums")
53                .to_compile_error()
54                .into()
55        }
56        Data::Union(_) => {
57            syn::Error::new_spanned(input, "Project derive macro does not support unions")
58                .to_compile_error()
59                .into()
60        }
61    }
62}
63
64fn derive_project_struct(input: &DeriveInput, fields: &syn::FieldsNamed) -> TokenStream {
65    let struct_name = &input.ident;
66    let (_impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
67
68    // Create the projected struct type
69    let projected_struct_name =
70        syn::Ident::new(&format!("{struct_name}Projected"), struct_name.span());
71
72    // Generate fields for the projected struct
73    let projected_fields = fields.named.iter().map(|field| {
74        let field_name = &field.ident;
75        let field_type = &field.ty;
76        quote! {
77            pub #field_name: ::nami::Binding<#field_type>
78        }
79    });
80
81    // Generate the projection logic
82    let field_projections = fields.named.iter().map(|field| {
83        let field_name = &field.ident;
84        quote! {
85            #field_name: {
86                let source = source.clone();
87                ::nami::Binding::mapping(
88                    &source,
89                    |value| value.#field_name.clone(),
90                    move |binding, value| {
91                        binding.with_mut(|b| {
92                            b.#field_name = value;
93                        });
94                    },
95                )
96            }
97        }
98    });
99
100    // Add lifetime bounds to generic parameters
101    let mut generics_with_static = input.generics.clone();
102    for param in &mut generics_with_static.params {
103        if let syn::GenericParam::Type(type_param) = param {
104            type_param.bounds.push(syn::parse_quote!('static));
105        }
106    }
107    let (impl_generics_with_static, _, _) = generics_with_static.split_for_impl();
108
109    let expanded = quote! {
110        /// Projected version of #struct_name with each field wrapped in a Binding.
111        #[derive(Debug)]
112        pub struct #projected_struct_name #ty_generics #where_clause {
113            #(#projected_fields,)*
114        }
115
116        impl #impl_generics_with_static ::nami::project::Project for #struct_name #ty_generics #where_clause {
117            type Projected = #projected_struct_name #ty_generics;
118
119            fn project(source: &::nami::Binding<Self>) -> Self::Projected {
120                #projected_struct_name {
121                    #(#field_projections,)*
122                }
123            }
124        }
125    };
126
127    TokenStream::from(expanded)
128}
129
130fn derive_project_tuple_struct(input: &DeriveInput, fields: &syn::FieldsUnnamed) -> TokenStream {
131    let struct_name = &input.ident;
132    let (_impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
133
134    // Generate tuple type for projection
135    let field_types: Vec<&Type> = fields.unnamed.iter().map(|field| &field.ty).collect();
136    let projected_tuple = if field_types.len() == 1 {
137        quote! { (::nami::Binding<#(#field_types)*>,) }
138    } else {
139        quote! { (#(::nami::Binding<#field_types>),*) }
140    };
141
142    // Generate field projections using index access
143    let field_projections = fields.unnamed.iter().enumerate().map(|(index, _)| {
144        let idx = syn::Index::from(index);
145        quote! {
146            {
147                let source = source.clone();
148                ::nami::Binding::mapping(
149                    &source,
150                    |value| value.#idx.clone(),
151                    move |binding, value| {
152                        binding.with_mut(|b| {
153                            b.#idx = value;
154                        });
155                    },
156                )
157            }
158        }
159    });
160
161    // Add lifetime bounds to generic parameters
162    let mut generics_with_static = input.generics.clone();
163    for param in &mut generics_with_static.params {
164        if let syn::GenericParam::Type(type_param) = param {
165            type_param.bounds.push(syn::parse_quote!('static));
166        }
167    }
168    let (impl_generics_with_static, _, _) = generics_with_static.split_for_impl();
169
170    let projection_tuple = if field_projections.len() == 1 {
171        quote! { (#(#field_projections)*,) }
172    } else {
173        quote! { (#(#field_projections),*) }
174    };
175
176    let expanded = quote! {
177        impl #impl_generics_with_static ::nami::project::Project for #struct_name #ty_generics #where_clause {
178            type Projected = #projected_tuple;
179
180            fn project(source: &::nami::Binding<Self>) -> Self::Projected {
181                #projection_tuple
182            }
183        }
184    };
185
186    TokenStream::from(expanded)
187}
188
189fn derive_project_unit_struct(input: &DeriveInput) -> TokenStream {
190    let struct_name = &input.ident;
191    let (_impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
192
193    // Add lifetime bounds to generic parameters
194    let mut generics_with_static = input.generics.clone();
195    for param in &mut generics_with_static.params {
196        if let syn::GenericParam::Type(type_param) = param {
197            type_param.bounds.push(syn::parse_quote!('static));
198        }
199    }
200    let (impl_generics_with_static, _, _) = generics_with_static.split_for_impl();
201
202    let expanded = quote! {
203        impl #impl_generics_with_static ::nami::project::Project for #struct_name #ty_generics #where_clause {
204            type Projected = ();
205
206            fn project(_source: &::nami::Binding<Self>) -> Self::Projected {
207                ()
208            }
209        }
210    };
211
212    TokenStream::from(expanded)
213}
214
215/// Input structure for the `s!` macro
216struct SInput {
217    format_str: LitStr,
218    args: Punctuated<Expr, Token![,]>,
219}
220
221impl Parse for SInput {
222    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
223        let format_str: LitStr = input.parse()?;
224        let args = if input.peek(Token![,]) {
225            input.parse::<Token![,]>()?;
226            Punctuated::parse_terminated(input)?
227        } else {
228            Punctuated::new()
229        };
230        Ok(Self { format_str, args })
231    }
232}
233
234/// Function-like procedural macro for creating formatted string signals with automatic variable capture.
235///
236/// This macro automatically detects named variables in format strings and captures them from scope.
237///
238/// # Examples
239///
240/// ```rust
241/// use nami::*;
242///
243/// let name = constant("Alice");
244/// let age = constant(25);
245///
246/// // Automatic variable capture from format string
247/// let msg = s!("Hello {name}, you are {age} years old");
248///
249/// // Positional arguments still work
250/// let msg2 = s!("Hello {}, you are {}", name, age);
251/// ```
252#[proc_macro]
253#[allow(clippy::similar_names)] // Allow arg1, arg2, etc.
254pub fn s(input: TokenStream) -> TokenStream {
255    let input = parse_macro_input!(input as SInput);
256    let format_str = input.format_str;
257    let format_value = format_str.value();
258
259    // Check for format string issues
260    let (has_positional, has_named, positional_count, named_vars) =
261        analyze_format_string(&format_value);
262
263    // If there are explicit arguments, validate and use positional approach
264    if !input.args.is_empty() {
265        // Check for mixed usage errors
266        if has_named {
267            return syn::Error::new_spanned(
268                &format_str,
269                format!(
270                    "Format string contains named arguments like {{{}}} but you provided positional arguments. \
271                    Either use positional placeholders like {{}} or remove the explicit arguments to use automatic variable capture.",
272                    named_vars.first().unwrap_or(&String::new())
273                )
274            )
275            .to_compile_error()
276            .into();
277        }
278
279        // Check argument count matches placeholders
280        if positional_count != input.args.len() {
281            return syn::Error::new_spanned(
282                &format_str,
283                format!(
284                    "Format string has {} positional placeholder(s) but {} arguments were provided",
285                    positional_count,
286                    input.args.len()
287                ),
288            )
289            .to_compile_error()
290            .into();
291        }
292        let args: Vec<_> = input.args.iter().collect();
293        return handle_s_args(&format_str, &args);
294    }
295
296    // Check for mixed placeholders when no explicit arguments
297    if has_positional && has_named {
298        return syn::Error::new_spanned(
299            &format_str,
300            "Format string mixes positional {{}} and named {{var}} placeholders. \
301            Use either all positional with explicit arguments, or all named for automatic capture.",
302        )
303        .to_compile_error()
304        .into();
305    }
306
307    // If has positional placeholders but no arguments provided
308    if has_positional && input.args.is_empty() {
309        return syn::Error::new_spanned(
310            &format_str,
311            format!(
312                "Format string has {positional_count} positional placeholder(s) {{}} but no arguments provided. \
313                Either provide arguments or use named placeholders like {{variable}} for automatic capture."
314            )
315        )
316        .to_compile_error()
317        .into();
318    }
319
320    // Parse format string to extract variable names for automatic capture
321    let var_names = named_vars;
322
323    // If no variables found, return constant
324    if var_names.is_empty() {
325        return quote! {
326            {
327                use ::nami::constant;
328                constant(nami::__format!(#format_str))
329            }
330        }
331        .into();
332    }
333
334    // Generate code for named variable capture
335    let var_idents: Vec<syn::Ident> = var_names
336        .iter()
337        .map(|name| syn::Ident::new(name, format_str.span()))
338        .collect();
339
340    handle_s_named_vars(&format_str, &var_idents)
341}
342
343#[allow(clippy::similar_names)]
344fn handle_s_args(format_str: &LitStr, args: &[&Expr]) -> TokenStream {
345    match args.len() {
346        1 => {
347            let arg = &args[0];
348            (quote! {
349                {
350                    use ::nami::SignalExt;
351                    SignalExt::map(#arg.clone(), |arg| nami::__format!(#format_str, arg))
352                }
353            })
354            .into()
355        }
356        2 => {
357            let arg1 = &args[0];
358            let arg2 = &args[1];
359            (quote! {
360                {
361                    use nami::{SignalExt, zip::zip};
362                    SignalExt::map(zip(#arg1.clone(), #arg2.clone()), |(arg1, arg2)| {
363                        nami::__format!(#format_str, arg1, arg2)
364                    })
365                }
366            })
367            .into()
368        }
369        3 => {
370            let arg1 = &args[0];
371            let arg2 = &args[1];
372            let arg3 = &args[2];
373            (quote! {
374                {
375                    use ::nami::{SignalExt, zip::zip};
376                    SignalExt::map(
377                        zip(zip(#arg1.clone(), #arg2.clone()), #arg3.clone()),
378                        |((arg1, arg2), arg3)| nami::__format!(#format_str, arg1, arg2, arg3)
379                    )
380                }
381            })
382            .into()
383        }
384        4 => {
385            let arg1 = &args[0];
386            let arg2 = &args[1];
387            let arg3 = &args[2];
388            let arg4 = &args[3];
389            (quote! {
390                {
391                    use ::nami::{SignalExt, zip::zip};
392                    SignalExt::map(
393                        zip(
394                            zip(#arg1.clone(), #arg2.clone()),
395                            zip(#arg3.clone(), #arg4.clone())
396                        ),
397                        |((arg1, arg2), (arg3, arg4))| nami::__format!(#format_str, arg1, arg2, arg3, arg4)
398                    )
399                }
400            }).into()
401        }
402        _ => syn::Error::new_spanned(format_str, "Too many arguments, maximum 4 supported")
403            .to_compile_error()
404            .into(),
405    }
406}
407
408#[allow(clippy::similar_names)]
409fn handle_s_named_vars(format_str: &LitStr, var_idents: &[syn::Ident]) -> TokenStream {
410    match var_idents.len() {
411        1 => {
412            let var = &var_idents[0];
413            (quote! {
414                {
415                    use ::nami::SignalExt;
416                    SignalExt::map(#var.clone(), |#var| {
417                        nami::__format!(#format_str)
418                    })
419                }
420            })
421            .into()
422        }
423        2 => {
424            let var1 = &var_idents[0];
425            let var2 = &var_idents[1];
426            (quote! {
427                {
428                    use ::nami::{SignalExt, zip::zip};
429                    SignalExt::map(zip(#var1.clone(), #var2.clone()), |(#var1, #var2)| {
430                        nami::__format!(#format_str)
431                    })
432                }
433            })
434            .into()
435        }
436        3 => {
437            let var1 = &var_idents[0];
438            let var2 = &var_idents[1];
439            let var3 = &var_idents[2];
440            (quote! {
441                {
442                    use ::nami::{SignalExt, zip::zip};
443                    SignalExt::map(
444                        zip(zip(#var1.clone(), #var2.clone()), #var3.clone()),
445                        |((#var1, #var2), #var3)| {
446                            ::nami::__format!(#format_str)
447                        }
448                    )
449                }
450            })
451            .into()
452        }
453        4 => {
454            let var1 = &var_idents[0];
455            let var2 = &var_idents[1];
456            let var3 = &var_idents[2];
457            let var4 = &var_idents[3];
458            (quote! {
459                {
460                    use ::nami::{SignalExt, zip::zip};
461                    SignalExt::map(
462                        zip(
463                            zip(#var1.clone(), #var2.clone()),
464                            zip(#var3.clone(), #var4.clone())
465                        ),
466                        |((#var1, #var2), (#var3, #var4))| {
467                            ::nami::__format!(#format_str)
468                        }
469                    )
470                }
471            })
472            .into()
473        }
474        _ => syn::Error::new_spanned(format_str, "Too many named variables, maximum 4 supported")
475            .to_compile_error()
476            .into(),
477    }
478}
479
480/// Analyze a format string to detect placeholder types and extract variable names
481fn analyze_format_string(format_str: &str) -> (bool, bool, usize, Vec<String>) {
482    let mut has_positional = false;
483    let mut has_named = false;
484    let mut positional_count = 0;
485    let mut named_vars = Vec::new();
486    let mut chars = format_str.chars().peekable();
487
488    while let Some(c) = chars.next() {
489        if c == '{' {
490            if chars.peek() == Some(&'{') {
491                // Skip escaped braces
492                chars.next();
493                continue;
494            }
495
496            let mut content = String::new();
497            let mut has_content = false;
498
499            while let Some(&next_char) = chars.peek() {
500                if next_char == '}' {
501                    chars.next(); // consume }
502                    break;
503                } else if next_char == ':' {
504                    // Format specifier found, we've captured the name/position part
505                    chars.next(); // consume :
506                    while let Some(&spec_char) = chars.peek() {
507                        if spec_char == '}' {
508                            chars.next(); // consume }
509                            break;
510                        }
511                        chars.next();
512                    }
513                    break;
514                }
515                content.push(chars.next().unwrap());
516                has_content = true;
517            }
518
519            // Analyze the content
520            if !has_content || content.is_empty() {
521                // Empty {} is positional
522                has_positional = true;
523                positional_count += 1;
524            } else if content.chars().all(|ch| ch.is_ascii_digit()) {
525                // Numeric like {0} or {1} is positional
526                has_positional = true;
527                positional_count += 1;
528            } else if content
529                .chars()
530                .next()
531                .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_')
532            {
533                // Starts with letter or underscore, likely a variable name
534                has_named = true;
535                if !named_vars.contains(&content) {
536                    named_vars.push(content);
537                }
538            } else {
539                // Other cases treat as positional
540                has_positional = true;
541                positional_count += 1;
542            }
543        }
544    }
545
546    (has_positional, has_named, positional_count, named_vars)
547}