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#[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 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 let conversion = if let Some(inner_ty) = extract_option_inner(ty) {
77 match value_conversion_for_type(inner_ty, "e!(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, "e!(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 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 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 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
175fn 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
193fn 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
239fn 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#[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 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 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 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 let prefix: String = unknown.chars().take(3).collect();
501 names.find(|&v| v.starts_with(prefix.as_str()))
502}
503
504#[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 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 let bare_segments = match parsed.as_bare_slots() {
554 Some(segs) => segs,
555 None => {
556 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 let capacity = template_str.len();
569
570 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}