lattice_plugin_sdk_derive/
lib.rs1use proc_macro::TokenStream;
27use quote::quote;
28use syn::{Attribute, Data, DeriveInput, Fields, LitStr, Type, parse_macro_input};
29
30#[proc_macro_derive(PluginEvent, attributes(event))]
33pub fn derive_plugin_event(input: TokenStream) -> TokenStream {
34 let input = parse_macro_input!(input as DeriveInput);
35 let ident = &input.ident;
36
37 let name = match parse_event_name(&input.attrs) {
40 Ok(Some(explicit)) => explicit,
41 Ok(None) => to_kebab_case(&ident.to_string()),
42 Err(err) => return err.to_compile_error().into(),
43 };
44
45 let doc = doc_string(&input.attrs);
47
48 let expanded = quote! {
49 impl ::lattice_plugin_sdk::PluginEvent for #ident {
50 const NAME: &'static str = #name;
51 const DOC: &'static str = #doc;
52
53 fn encode(&self) -> ::std::vec::Vec<u8> {
54 ::lattice_plugin_sdk::__private::encode(self)
55 }
56
57 fn decode(
58 bytes: &[u8],
59 ) -> ::core::result::Result<Self, ::lattice_plugin_sdk::DecodeError> {
60 ::lattice_plugin_sdk::__private::decode(bytes)
61 }
62 }
63 };
64 expanded.into()
65}
66
67fn parse_event_name(attrs: &[Attribute]) -> Result<Option<String>, syn::Error> {
72 let mut found = None;
73 for attr in attrs {
74 if !attr.path().is_ident("event") {
75 continue;
76 }
77 attr.parse_nested_meta(|meta| {
78 if meta.path.is_ident("name") {
79 let value: LitStr = meta.value()?.parse()?;
80 found = Some(value.value());
81 Ok(())
82 } else {
83 Err(meta.error("unknown `event` attribute key (expected `name`)"))
84 }
85 })?;
86 }
87 Ok(found)
88}
89
90fn doc_string(attrs: &[Attribute]) -> String {
94 let mut lines: Vec<String> = Vec::new();
95 for attr in attrs {
96 if !attr.path().is_ident("doc") {
97 continue;
98 }
99 let syn::Meta::NameValue(nv) = &attr.meta else {
100 continue;
101 };
102 let syn::Expr::Lit(syn::ExprLit {
103 lit: syn::Lit::Str(s),
104 ..
105 }) = &nv.value
106 else {
107 continue;
108 };
109 lines.push(s.value().trim().to_string());
112 }
113 lines.join("\n").trim().to_string()
114}
115
116fn to_kebab_case(s: &str) -> String {
120 let mut out = String::with_capacity(s.len() + 4);
121 for (i, ch) in s.chars().enumerate() {
122 if ch.is_uppercase() {
123 if i != 0 {
124 out.push('-');
125 }
126 out.extend(ch.to_lowercase());
127 } else if ch == '_' {
128 out.push('-');
135 } else {
136 out.push(ch);
137 }
138 }
139 out
140}
141
142#[proc_macro_derive(PluginOption, attributes(option))]
158pub fn derive_plugin_option(input: TokenStream) -> TokenStream {
159 let input = parse_macro_input!(input as DeriveInput);
160 let ident = &input.ident;
161
162 let value_ty = match newtype_field(&input.data) {
163 Ok(ty) => ty,
164 Err(err) => return err.to_compile_error().into(),
165 };
166 let kind = match option_kind_for(&value_ty) {
167 Ok(k) => k,
168 Err(err) => return err.to_compile_error().into(),
169 };
170
171 let (explicit_name, default) = match parse_option_attr(&input.attrs) {
172 Ok(pair) => pair,
173 Err(err) => return err.to_compile_error().into(),
174 };
175 let name = explicit_name.unwrap_or_else(|| to_kebab_case(&ident.to_string()));
176 let default = match default {
177 Some(d) => d,
178 None => {
179 return syn::Error::new_spanned(
180 ident,
181 "`#[derive(PluginOption)]` requires `#[option(default = \"...\")]`",
182 )
183 .to_compile_error()
184 .into();
185 }
186 };
187 let doc = doc_string(&input.attrs);
188
189 let expanded = quote! {
190 impl ::lattice_plugin_sdk::PluginOption for #ident {
191 const NAME: &'static str = #name;
192 const DOC: &'static str = #doc;
193 const DEFAULT: &'static str = #default;
194 const KIND: ::lattice_plugin_sdk::OptionKind = #kind;
195 type Value = #value_ty;
196 }
197 };
198 expanded.into()
199}
200
201fn newtype_field(data: &Data) -> Result<Type, syn::Error> {
204 if let Data::Struct(s) = data
205 && let Fields::Unnamed(f) = &s.fields
206 && f.unnamed.len() == 1
207 {
208 return Ok(f.unnamed[0].ty.clone());
209 }
210 Err(syn::Error::new(
211 proc_macro2::Span::call_site(),
212 "`#[derive(PluginOption)]` requires a newtype struct with one field, e.g. `struct TabWidth(i64);`",
213 ))
214}
215
216fn option_kind_for(ty: &Type) -> Result<proc_macro2::TokenStream, syn::Error> {
220 if let Type::Path(p) = ty
221 && let Some(seg) = p.path.segments.last()
222 {
223 let variant = match seg.ident.to_string().as_str() {
224 "bool" => Some(quote! { ::lattice_plugin_sdk::OptionKind::Boolean }),
225 "i64" => Some(quote! { ::lattice_plugin_sdk::OptionKind::Integer }),
226 "String" => Some(quote! { ::lattice_plugin_sdk::OptionKind::String }),
227 _ => None,
228 };
229 if let Some(v) = variant {
230 return Ok(v);
231 }
232 }
233 Err(syn::Error::new_spanned(
234 ty,
235 "`#[derive(PluginOption)]` field must be `bool`, `i64`, or `String`",
236 ))
237}
238
239fn parse_option_attr(attrs: &[Attribute]) -> Result<(Option<String>, Option<String>), syn::Error> {
242 let mut name = None;
243 let mut default = None;
244 for attr in attrs {
245 if !attr.path().is_ident("option") {
246 continue;
247 }
248 attr.parse_nested_meta(|meta| {
249 if meta.path.is_ident("name") {
250 let value: LitStr = meta.value()?.parse()?;
251 name = Some(value.value());
252 Ok(())
253 } else if meta.path.is_ident("default") {
254 let value: LitStr = meta.value()?.parse()?;
255 default = Some(value.value());
256 Ok(())
257 } else {
258 Err(meta.error("unknown `option` attribute key (expected `name` or `default`)"))
259 }
260 })?;
261 }
262 Ok((name, default))
263}
264
265#[proc_macro_derive(ConfigShape, attributes(shape))]
286pub fn derive_config_shape(input: TokenStream) -> TokenStream {
287 let input = parse_macro_input!(input as DeriveInput);
288 let ident = &input.ident;
289
290 match &input.data {
291 Data::Struct(s) => match &s.fields {
292 Fields::Named(named) => config_shape_for_struct(ident, named),
293 _ => syn::Error::new_spanned(
294 ident,
295 "`#[derive(ConfigShape)]` on a struct requires named fields — a record's fields have names",
296 )
297 .to_compile_error()
298 .into(),
299 },
300 Data::Enum(e) => config_shape_for_enum(ident, e),
301 Data::Union(_) => syn::Error::new_spanned(
302 ident,
303 "`#[derive(ConfigShape)]` does not support unions",
304 )
305 .to_compile_error()
306 .into(),
307 }
308}
309
310fn config_shape_for_struct(ident: &syn::Ident, named: &syn::FieldsNamed) -> TokenStream {
311 let mut field_schema = Vec::new();
312 let mut field_to = Vec::new();
313 let mut field_from = Vec::new();
314 let mut binds = Vec::new();
315
316 for f in &named.named {
317 let Some(fident) = f.ident.as_ref() else {
318 continue;
319 };
320 let wire = to_kebab_case(fident.to_string().trim_start_matches("r#"));
326 let doc = doc_string(&f.attrs);
327 let ty = &f.ty;
328 let optional = is_option_type(ty);
329
330 field_schema.push(quote! {
331 ::lattice_plugin_sdk::shape::Field {
332 name: #wire.to_string(),
333 schema: <#ty as ::lattice_plugin_sdk::shape::ConfigShape>::schema(),
334 required: !#optional,
335 doc: #doc.to_string(),
336 }
337 });
338
339 if optional {
340 field_to.push(quote! {
344 if let ::core::option::Option::Some(v) = &self.#fident {
345 fields.push((
346 #wire.to_string(),
347 ::lattice_plugin_sdk::shape::ConfigShape::to_value(v),
348 ));
349 }
350 });
351 field_from.push(quote! {
352 let #fident = match value.field(#wire) {
353 ::core::option::Option::Some(v) => ::core::option::Option::Some(
354 ::lattice_plugin_sdk::shape::ConfigShape::from_value(v)
355 .map_err(|e| e.under(#wire))?,
356 ),
357 ::core::option::Option::None => ::core::option::Option::None,
358 };
359 });
360 } else {
361 field_to.push(quote! {
362 fields.push((
363 #wire.to_string(),
364 ::lattice_plugin_sdk::shape::ConfigShape::to_value(&self.#fident),
365 ));
366 });
367 field_from.push(quote! {
368 let #fident = {
369 let v = value.field(#wire).ok_or_else(|| {
370 ::lattice_plugin_sdk::shape::ShapeError::new("required field is missing")
371 .under(#wire)
372 })?;
373 ::lattice_plugin_sdk::shape::ConfigShape::from_value(v)
374 .map_err(|e| e.under(#wire))?
375 };
376 });
377 }
378 binds.push(quote! { #fident });
379 }
380
381 let expanded = quote! {
382 impl ::lattice_plugin_sdk::shape::ConfigShape for #ident {
383 fn schema() -> ::lattice_plugin_sdk::shape::Schema {
384 ::lattice_plugin_sdk::shape::Schema::Record(::std::vec![ #(#field_schema),* ])
385 }
386
387 fn to_value(&self) -> ::lattice_plugin_sdk::shape::Value {
388 let mut fields: ::std::vec::Vec<(::std::string::String, ::lattice_plugin_sdk::shape::Value)> =
389 ::std::vec::Vec::new();
390 #(#field_to)*
391 ::lattice_plugin_sdk::shape::Value::record(fields)
392 }
393
394 fn from_value(
395 value: &::lattice_plugin_sdk::shape::Value,
396 ) -> ::core::result::Result<Self, ::lattice_plugin_sdk::shape::ShapeError> {
397 if !::core::matches!(value, ::lattice_plugin_sdk::shape::Value::Record(_)) {
398 return ::core::result::Result::Err(
399 ::lattice_plugin_sdk::shape::ShapeError::new(::std::format!(
400 "expected record, got {}",
401 value.kind_label()
402 )),
403 );
404 }
405 #(#field_from)*
406 ::core::result::Result::Ok(Self { #(#binds),* })
407 }
408 }
409 };
410 expanded.into()
411}
412
413fn config_shape_for_enum(ident: &syn::Ident, e: &syn::DataEnum) -> TokenStream {
414 let mut forms = Vec::new();
415 let mut to_arms = Vec::new();
416 let mut from_arms = Vec::new();
417
418 for v in &e.variants {
419 if !matches!(v.fields, Fields::Unit) {
420 return syn::Error::new_spanned(
421 v,
422 "`#[derive(ConfigShape)]` on an enum requires every variant to be a unit variant \
423 — a data-carrying variant has no `config-schema` arm, and flattening one \
424 silently would describe a shape the host then validates against wrongly",
425 )
426 .to_compile_error()
427 .into();
428 }
429 let vident = &v.ident;
430 let wire = to_kebab_case(vident.to_string().trim_start_matches("r#"));
431 forms.push(quote! { #wire.to_string() });
432 to_arms.push(quote! {
433 Self::#vident => ::lattice_plugin_sdk::shape::Value::Str(#wire.to_string())
434 });
435 from_arms.push(quote! { #wire => ::core::result::Result::Ok(Self::#vident) });
436 }
437
438 let expanded = quote! {
439 impl ::lattice_plugin_sdk::shape::ConfigShape for #ident {
440 fn schema() -> ::lattice_plugin_sdk::shape::Schema {
441 ::lattice_plugin_sdk::shape::Schema::Enum(::std::vec![ #(#forms),* ])
442 }
443
444 fn to_value(&self) -> ::lattice_plugin_sdk::shape::Value {
445 match self { #(#to_arms),* }
446 }
447
448 fn from_value(
449 value: &::lattice_plugin_sdk::shape::Value,
450 ) -> ::core::result::Result<Self, ::lattice_plugin_sdk::shape::ShapeError> {
451 let got = value.as_str().ok_or_else(|| {
452 ::lattice_plugin_sdk::shape::ShapeError::new(::std::format!(
453 "expected string, got {}",
454 value.kind_label()
455 ))
456 })?;
457 match got {
458 #(#from_arms,)*
459 other => ::core::result::Result::Err(
463 ::lattice_plugin_sdk::shape::ShapeError::new(::std::format!(
464 "expected one of {}, got `{}`",
465 [ #(#forms),* ].join(" | "),
466 other
467 )),
468 ),
469 }
470 }
471 }
472 };
473 expanded.into()
474}
475
476fn is_option_type(ty: &Type) -> bool {
483 if let Type::Path(p) = ty
484 && let Some(seg) = p.path.segments.last()
485 {
486 return seg.ident == "Option"
487 && matches!(seg.arguments, syn::PathArguments::AngleBracketed(_));
488 }
489 false
490}