Skip to main content

prosaic_derive/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{
4    Data, DeriveInput, Fields, GenericArgument, Ident, LitStr, PathArguments, Token, Type,
5    parse::{Parse, ParseStream},
6    parse_macro_input,
7    punctuated::Punctuated,
8};
9
10/// Derive `IntoContext` for a struct, converting its fields into `Context` key-value pairs.
11///
12/// Field type mapping:
13/// - `String` / `&str` / `&'a str` → `Value::String` (borrowed strs are cloned)
14/// - `i8`, `i16`, `i32`, `i64`, `isize`, `u8`, `u16`, `u32` → `Value::Number`
15///   via infallible `as i64` cast
16/// - `u64`, `usize` → `Value::Number` via **saturating** conversion:
17///   values above `i64::MAX` (≈9.2 × 10¹⁸) saturate to `i64::MAX` rather
18///   than wrapping to a negative. If you need the raw bit pattern, convert
19///   explicitly in host code before constructing the context.
20/// - `Vec<String>` → `Value::List`
21/// - `Option<T>` where `T` is any of the above → inserted only when `Some(_)`
22///
23/// Unsupported field types produce a compile-time error so template slots
24/// cannot silently disappear.
25#[proc_macro_derive(IntoContext)]
26pub fn derive_into_context(input: TokenStream) -> TokenStream {
27    let input = parse_macro_input!(input as DeriveInput);
28    let name = &input.ident;
29    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
30
31    let fields = match &input.data {
32        Data::Struct(data) => match &data.fields {
33            Fields::Named(fields) => &fields.named,
34            _ => {
35                return syn::Error::new_spanned(
36                    &input.ident,
37                    "IntoContext can only be derived for structs with named fields",
38                )
39                .to_compile_error()
40                .into();
41            }
42        },
43        _ => {
44            return syn::Error::new_spanned(
45                &input.ident,
46                "IntoContext can only be derived for structs",
47            )
48            .to_compile_error()
49            .into();
50        }
51    };
52
53    let mut insertions = Vec::with_capacity(fields.len());
54    let mut schema_entries = Vec::with_capacity(fields.len());
55
56    for field in fields {
57        let field_name = match &field.ident {
58            Some(ident) => ident,
59            None => continue,
60        };
61        let key = field_name.to_string();
62        let ty = &field.ty;
63
64        // Value-type mapping (for schema) — unwraps Option<T> to T.
65        let effective_ty = extract_option_inner(ty).unwrap_or(ty);
66        let value_type_tokens = match value_type_for_rust_type(effective_ty) {
67            Some(t) => t,
68            None => {
69                let was_opt = extract_option_inner(ty).is_some();
70                return unsupported_field_error(field_name, effective_ty, was_opt);
71            }
72        };
73        schema_entries.push(quote! { (#key, #value_type_tokens) });
74
75        // IntoContext insertion (unchanged from prior implementation).
76        let conversion = if let Some(inner_ty) = extract_option_inner(ty) {
77            match value_conversion_for_type(inner_ty, &quote!(val)) {
78                Some(conv) => quote! {
79                    if let ::core::option::Option::Some(val) = self.#field_name {
80                        ctx.insert(#key, #conv);
81                    }
82                },
83                None => return unsupported_field_error(field_name, inner_ty, true),
84            }
85        } else {
86            match value_conversion_for_type(ty, &quote!(self.#field_name)) {
87                Some(conv) => quote! {
88                    ctx.insert(#key, #conv);
89                },
90                None => return unsupported_field_error(field_name, ty, false),
91            }
92        };
93
94        insertions.push(conversion);
95    }
96
97    let expanded = quote! {
98        impl #impl_generics ::prosaic_core::IntoContext for #name #ty_generics #where_clause {
99            fn into_context(self) -> ::prosaic_core::Context {
100                let mut ctx = ::prosaic_core::Context::new();
101                #(#insertions)*
102                ctx
103            }
104        }
105
106        impl #impl_generics ::prosaic_core::HasProsaicSchema for #name #ty_generics #where_clause {
107            const PROSAIC_SCHEMA: &'static [(&'static str, ::prosaic_core::ValueType)] = &[
108                #(#schema_entries),*
109            ];
110        }
111    };
112
113    TokenStream::from(expanded)
114}
115
116fn unsupported_field_error(field: &syn::Ident, ty: &Type, was_option: bool) -> TokenStream {
117    let wrapper = if was_option { "Option<…>" } else { "" };
118    let message = format!(
119        "IntoContext: field `{field}` has unsupported type {wrapper}`{ty}`. \
120         Supported types are String, &str, integer types (i8..i64/isize/u8..u32/u64/usize), \
121         bool, Vec<String>, and Option<T> wrapping any of the above.",
122        field = field,
123        wrapper = wrapper,
124        ty = quote!(#ty),
125    );
126    syn::Error::new_spanned(field, message)
127        .to_compile_error()
128        .into()
129}
130
131fn value_conversion_for_type(
132    ty: &Type,
133    accessor: &proc_macro2::TokenStream,
134) -> Option<proc_macro2::TokenStream> {
135    if is_type(ty, "String") {
136        Some(quote! { ::prosaic_core::Value::String(#accessor) })
137    } else if is_str_reference(ty) {
138        // `&str` / `&'a str` — clone into an owned String so it fits Value.
139        Some(quote! { ::prosaic_core::Value::String((#accessor).to_string()) })
140    } else if is_safe_numeric_type(ty) {
141        Some(quote! { ::prosaic_core::Value::Number(#accessor as i64) })
142    } else if is_wide_numeric_type(ty) {
143        // u64 / usize may exceed i64::MAX on 64-bit platforms — saturate
144        // to i64::MAX rather than silently wrapping to a negative number.
145        Some(quote! {
146            ::prosaic_core::Value::Number(
147                ::core::convert::TryFrom::try_from(#accessor)
148                    .unwrap_or(::core::primitive::i64::MAX)
149            )
150        })
151    } else if is_type(ty, "bool") {
152        // Match IntoValue for bool: true → 1, false → 0.
153        Some(quote! {
154            ::prosaic_core::Value::Number(if #accessor { 1_i64 } else { 0_i64 })
155        })
156    } else if is_vec_string(ty) {
157        Some(quote! { ::prosaic_core::Value::List(#accessor) })
158    } else {
159        None
160    }
161}
162
163fn is_type(ty: &Type, name: &str) -> bool {
164    if let Type::Path(type_path) = ty {
165        type_path
166            .path
167            .segments
168            .last()
169            .is_some_and(|seg| seg.ident == name)
170    } else {
171        false
172    }
173}
174
175/// Numeric types whose full range fits in `i64` via an infallible `as` cast.
176///
177/// `isize` is included because on 32-bit targets it's `i32` (fits trivially)
178/// and on 64-bit it equals `i64` (same range). `usize` and `u64` go through
179/// the wide path because on 64-bit they may exceed `i64::MAX`.
180fn is_safe_numeric_type(ty: &Type) -> bool {
181    let safe = ["i8", "i16", "i32", "i64", "isize", "u8", "u16", "u32"];
182    if let Type::Path(type_path) = ty {
183        type_path
184            .path
185            .segments
186            .last()
187            .is_some_and(|seg| safe.contains(&seg.ident.to_string().as_str()))
188    } else {
189        false
190    }
191}
192
193/// Numeric types that require a saturating `TryFrom<_, i64>` conversion
194/// because they can exceed `i64::MAX` on 64-bit platforms.
195fn is_wide_numeric_type(ty: &Type) -> bool {
196    let wide = ["u64", "usize"];
197    if let Type::Path(type_path) = ty {
198        type_path
199            .path
200            .segments
201            .last()
202            .is_some_and(|seg| wide.contains(&seg.ident.to_string().as_str()))
203    } else {
204        false
205    }
206}
207
208fn is_vec_string(ty: &Type) -> bool {
209    if let Type::Path(type_path) = ty
210        && let Some(seg) = type_path.path.segments.last()
211        && seg.ident == "Vec"
212        && let PathArguments::AngleBracketed(args) = &seg.arguments
213        && let Some(GenericArgument::Type(inner)) = args.args.first()
214    {
215        return is_type(inner, "String");
216    }
217    false
218}
219
220fn is_str_reference(ty: &Type) -> bool {
221    if let Type::Reference(r) = ty {
222        return is_type(&r.elem, "str");
223    }
224    false
225}
226
227fn extract_option_inner(ty: &Type) -> Option<&Type> {
228    if let Type::Path(type_path) = ty
229        && let Some(seg) = type_path.path.segments.last()
230        && seg.ident == "Option"
231        && let PathArguments::AngleBracketed(args) = &seg.arguments
232        && let Some(GenericArgument::Type(inner)) = args.args.first()
233    {
234        return Some(inner);
235    }
236    None
237}
238
239/// Map a Rust field type to the `ValueType` it projects into when inserted
240/// into a `Context`. Returns `None` if the type is unsupported (caller
241/// raises `unsupported_field_error`).
242fn value_type_for_rust_type(ty: &Type) -> Option<proc_macro2::TokenStream> {
243    if is_type(ty, "String") || is_str_reference(ty) {
244        Some(quote! { ::prosaic_core::ValueType::String })
245    } else if is_safe_numeric_type(ty) || is_wide_numeric_type(ty) || is_type(ty, "bool") {
246        Some(quote! { ::prosaic_core::ValueType::Number })
247    } else if is_vec_string(ty) {
248        Some(quote! { ::prosaic_core::ValueType::List })
249    } else {
250        None
251    }
252}
253
254// ── prosaic_template! ──────────────────────────────────────────────────────────
255
256/// Compile-time-validated template string.
257///
258/// Parses the template, checks every slot reference against the declared
259/// `slots` list, and checks every pipe name against the engine's known-pipe
260/// set. On success, expands to the original template string literal (`&'static str`).
261/// On mismatch, emits a compile error pointing at the `template:` argument.
262///
263/// When `context: <Type>` is provided, the macro also emits `const` assertions
264/// at compile time, verifying that each slot's required type (inferred from its
265/// pipe chain) is compatible with the corresponding field in `<Type>`'s
266/// `HasProsaicSchema` implementation. A missing slot or type mismatch is a
267/// hard compile error with a clear message identifying the slot and context type.
268///
269/// # Syntax
270///
271/// ```
272/// use prosaic_derive::prosaic_template;
273///
274/// let tpl: &'static str = prosaic_template! {
275///     template: "The {entity_type} {name|refer} was renamed to {new_name}",
276///     slots: [entity_type, name, new_name],
277/// };
278/// assert!(tpl.contains("{name|refer}"));
279/// ```
280///
281/// The `slots:` list uses bare identifiers matching the slot keys in the
282/// template. Declaring extra slots that are not used in the template is
283/// allowed. Slots used by conditional guards (`{?key}`) must also be declared.
284///
285/// # Limitations (v1)
286///
287/// - Pipe *arguments* (`truncate:3`, `verb:past`, etc.) are not validated — only the pipe name.
288/// - Slots inside partial inclusions (`{>name}`) are not validated — partials
289///   are opaque at compile time and resolved by the engine at registration time.
290/// - Compile-fail tests require an external `trybuild` harness (deferred to v2).
291#[proc_macro]
292pub fn prosaic_template(input: TokenStream) -> TokenStream {
293    let parsed = parse_macro_input!(input as ProsaicTemplateInput);
294
295    match validate_template(&parsed) {
296        Ok(assertions) => {
297            let lit = &parsed.template;
298            quote! { { #assertions #lit } }.into()
299        }
300        Err(e) => e.to_compile_error().into(),
301    }
302}
303
304struct ProsaicTemplateInput {
305    template: LitStr,
306    slots: Vec<Ident>,
307    context: Option<syn::Path>,
308}
309
310impl Parse for ProsaicTemplateInput {
311    fn parse(input: ParseStream) -> syn::Result<Self> {
312        let mut template: Option<LitStr> = None;
313        let mut slots: Option<Vec<Ident>> = None;
314        let mut context: Option<syn::Path> = None;
315
316        while !input.is_empty() {
317            let key: Ident = input.parse()?;
318            input.parse::<Token![:]>()?;
319            match key.to_string().as_str() {
320                "template" => {
321                    template = Some(input.parse::<LitStr>()?);
322                }
323                "slots" => {
324                    let content;
325                    syn::bracketed!(content in input);
326                    let parsed_idents: Punctuated<Ident, Token![,]> =
327                        Punctuated::parse_terminated(&content)?;
328                    slots = Some(parsed_idents.into_iter().collect());
329                }
330                "context" => {
331                    context = Some(input.parse::<syn::Path>()?);
332                }
333                other => {
334                    return Err(syn::Error::new(
335                        key.span(),
336                        format!(
337                            "unknown key `{other}` — expected `template`, `slots`, or `context`"
338                        ),
339                    ));
340                }
341            }
342            if !input.is_empty() {
343                input.parse::<Token![,]>()?;
344            }
345        }
346
347        let template = template
348            .ok_or_else(|| syn::Error::new(input.span(), "missing `template: \"...\"` argument"))?;
349        let slots = slots.unwrap_or_default();
350
351        Ok(ProsaicTemplateInput {
352            template,
353            slots,
354            context,
355        })
356    }
357}
358
359fn validate_template(input: &ProsaicTemplateInput) -> syn::Result<proc_macro2::TokenStream> {
360    let template_str = input.template.value();
361    let span = input.template.span();
362
363    let parsed = prosaic_core::Template::parse(&template_str)
364        .map_err(|e| syn::Error::new(span, format!("invalid template: {e}")))?;
365
366    let declared: std::collections::HashSet<String> =
367        input.slots.iter().map(|i| i.to_string()).collect();
368
369    validate_slots(&parsed, &declared, span)?;
370    validate_pipes(&parsed, span)?;
371
372    // Infer per-slot types using the shared PIPE_SPECS registry. Chain
373    // mismatches and multi-mention conflicts surface here as compile errors.
374    let inferred = parsed
375        .infer_types()
376        .map_err(|reason| syn::Error::new(span, reason))?;
377
378    let assertions = match &input.context {
379        Some(ctx_path) => emit_context_assertions(ctx_path, &inferred),
380        None => proc_macro2::TokenStream::new(),
381    };
382
383    Ok(assertions)
384}
385
386fn emit_context_assertions(
387    ctx_path: &syn::Path,
388    inferred: &[(String, prosaic_core::ValueType)],
389) -> proc_macro2::TokenStream {
390    use prosaic_core::ValueType;
391
392    let mut stmts = proc_macro2::TokenStream::new();
393    for (slot, expected) in inferred {
394        let expected_tok = match expected {
395            ValueType::String => quote! { ::prosaic_core::ValueType::String },
396            ValueType::Number => quote! { ::prosaic_core::ValueType::Number },
397            ValueType::List => quote! { ::prosaic_core::ValueType::List },
398            ValueType::Entity => quote! { ::prosaic_core::ValueType::Entity },
399            ValueType::Any => {
400                // A slot inferred as Any imposes no constraint on the context.
401                continue;
402            }
403        };
404
405        let ctx_name_str = quote!(#ctx_path).to_string();
406        let missing_msg = format!(
407            "prosaic_template: slot `{slot}` is not declared in context `{ctx_name_str}` (no matching field)"
408        );
409        let mismatch_msg = format!(
410            "prosaic_template: slot `{slot}` in context `{ctx_name_str}` has an incompatible type — required by template pipe chain"
411        );
412
413        stmts.extend(quote! {
414            const _: () = {
415                let actual = match ::prosaic_core::schema_lookup(
416                    <#ctx_path as ::prosaic_core::HasProsaicSchema>::PROSAIC_SCHEMA,
417                    #slot,
418                ) {
419                    ::core::option::Option::Some(t) => t,
420                    ::core::option::Option::None => ::core::panic!(#missing_msg),
421                };
422                if !::prosaic_core::types_compatible(actual, #expected_tok) {
423                    ::core::panic!(#mismatch_msg);
424                }
425            };
426        });
427    }
428    stmts
429}
430
431fn validate_slots(
432    template: &prosaic_core::Template,
433    declared: &std::collections::HashSet<String>,
434    span: proc_macro2::Span,
435) -> syn::Result<()> {
436    let used = template.slot_keys();
437    let mut undeclared: Vec<String> = used.into_iter().filter(|k| !declared.contains(k)).collect();
438    undeclared.sort();
439    undeclared.dedup();
440
441    if !undeclared.is_empty() {
442        let list = undeclared.join(", ");
443        let mut declared_sorted: Vec<_> = declared.iter().cloned().collect();
444        declared_sorted.sort();
445        let declared_list = declared_sorted.join(", ");
446        return Err(syn::Error::new(
447            span,
448            format!(
449                "template uses slot(s) not declared in `slots: [...]`: {list}\n  declared: [{declared_list}]",
450            ),
451        ));
452    }
453    Ok(())
454}
455
456fn validate_pipes(template: &prosaic_core::Template, span: proc_macro2::Span) -> syn::Result<()> {
457    let used = template.pipe_names();
458    let mut unknown: Vec<String> = used
459        .into_iter()
460        .filter(|p| {
461            !prosaic_core::PIPE_SPECS
462                .iter()
463                .any(|spec| spec.name == p.as_str())
464        })
465        .collect();
466    unknown.sort();
467    unknown.dedup();
468
469    if !unknown.is_empty() {
470        let list = unknown
471            .iter()
472            .map(|p| match nearest_pipe(p) {
473                Some(s) => format!("`{p}` (did you mean `{s}`?)"),
474                None => format!("`{p}`"),
475            })
476            .collect::<Vec<_>>()
477            .join(", ");
478        let known: Vec<&str> = prosaic_core::PIPE_SPECS.iter().map(|s| s.name).collect();
479        return Err(syn::Error::new(
480            span,
481            format!(
482                "template uses unknown pipe(s): {list}\n  known pipes: [{}]",
483                known.join(", ")
484            ),
485        ));
486    }
487    Ok(())
488}
489
490fn nearest_pipe(unknown: &str) -> Option<&'static str> {
491    let mut names = prosaic_core::PIPE_SPECS.iter().map(|s| s.name);
492    // Exact prefix / suffix match first (catches common truncations).
493    if let Some(valid) = names
494        .clone()
495        .find(|&v| v.starts_with(unknown) || unknown.starts_with(v))
496    {
497        return Some(valid);
498    }
499    // Fallback: any pipe sharing the first three characters.
500    let prefix: String = unknown.chars().take(3).collect();
501    names.find(|&v| v.starts_with(prefix.as_str()))
502}
503
504// ── prosaic_template_compiled! ─────────────────────────────────────────────────
505
506/// Compile-time compiled template rendering function.
507///
508/// Parses the template at compile time and emits a specialized render function
509/// that avoids the runtime parsing pipeline. Suitable for tight loops with
510/// known, simple templates.
511///
512/// Returns a `fn(&prosaic_core::Context) -> String` as a block expression.
513///
514/// # Supported syntax
515///
516/// Only bare slot references are supported: `{key}` and literal text.
517/// The following will produce a **compile error**:
518/// - Pipes: `{key|capitalize}`
519/// - Conditional sections: `{?key}...{/?}`
520/// - Partial inclusions: `{>name}`
521///
522/// For templates requiring any of the above, use the runtime engine directly.
523///
524/// # Example
525///
526/// ```
527/// use prosaic_derive::prosaic_template_compiled;
528/// use prosaic_core::{Context, Value};
529///
530/// let render = prosaic_template_compiled!("The class {name} was modified");
531/// let mut ctx = Context::new();
532/// ctx.insert("name", Value::String("Foo".into()));
533/// assert_eq!(render(&ctx), "The class Foo was modified");
534/// ```
535#[proc_macro]
536pub fn prosaic_template_compiled(input: TokenStream) -> TokenStream {
537    let template_lit = parse_macro_input!(input as LitStr);
538    let template_str = template_lit.value();
539    let span = template_lit.span();
540
541    // Parse the template using the core runtime parser.
542    let parsed = match prosaic_core::Template::parse(&template_str) {
543        Ok(t) => t,
544        Err(e) => {
545            return syn::Error::new(span, format!("invalid template: {e}"))
546                .to_compile_error()
547                .into();
548        }
549    };
550
551    // Validate: only bare slots are supported. as_bare_slots() returns None if
552    // the template contains pipes, conditionals, or partials.
553    let bare_segments = match parsed.as_bare_slots() {
554        Some(segs) => segs,
555        None => {
556            // Give a precise error: detect which unsupported feature is present.
557            let has_pipes = !parsed.pipe_names().is_empty();
558            let msg = if has_pipes {
559                "prosaic_template_compiled!: templates with pipes are not supported; use the runtime engine"
560            } else {
561                "prosaic_template_compiled!: conditional sections, partials, and advanced features are not supported; use the runtime engine"
562            };
563            return syn::Error::new(span, msg).to_compile_error().into();
564        }
565    };
566
567    // Estimate initial capacity as template length (reasonable lower bound).
568    let capacity = template_str.len();
569
570    // Generate the push_str calls for each segment.
571    let mut stmts = Vec::new();
572    for seg in &bare_segments {
573        match seg {
574            prosaic_core::BareSegment::Text(text) => {
575                stmts.push(quote! { out.push_str(#text); });
576            }
577            prosaic_core::BareSegment::Slot(key) => {
578                stmts.push(quote! {
579                    if let Some(__v) = __ctx.get(#key) {
580                        out.push_str(&__v.as_display());
581                    }
582                });
583            }
584        }
585    }
586
587    let expanded = quote! {
588        {
589            fn __prosaic_compiled_render(__ctx: &::prosaic_core::Context) -> ::std::string::String {
590                let mut out = ::std::string::String::with_capacity(#capacity);
591                #(#stmts)*
592                out
593            }
594            __prosaic_compiled_render
595        }
596    };
597
598    expanded.into()
599}