Skip to main content

lattice_plugin_sdk_derive/
lib.rs

1//! `#[derive(PluginEvent)]` — the guest-side derive for plugin-defined events
2//! (PH7.8b.3). The companion proc-macro crate to `lattice-plugin-sdk` (the
3//! serde/serde_derive split: a proc-macro crate cannot also export a normal
4//! library, so the runtime trait lives in `lattice-plugin-sdk`, which re-exports
5//! this derive).
6//!
7//! The derive is **WIT-agnostic** (PH7.8b.3 approach A): it generates a
8//! `PluginEvent` impl over the opaque MessagePack wire and touches no plugin-host
9//! bindings. A plugin emits with its own generated `host-services emit-event`
10//! using the derived `NAME` + `encode()`; the derive only supplies the type-safe
11//! encode/decode + the name/doc constants.
12//!
13//! Generated for `#[derive(PluginEvent)] struct Foo { .. }`:
14//!   - `NAME` — from `#[event(name = "...")]`, else the type name kebab-cased.
15//!   - `DOC` — the struct's `///` doc-comment (the doc-comment IS the event doc,
16//!     the PI.4 "doc from doc comments" principle applied to events).
17//!   - `encode` / `decode` — MessagePack via `lattice-plugin-sdk`'s private
18//!     helpers, so the consumer deps only the SDK (not `rmp-serde` directly).
19//!
20//! The consumer must also `#[derive(serde::Serialize, serde::Deserialize)]` —
21//! the encode/decode bodies require it. Generated code references
22//! `::lattice_plugin_sdk`, so the consumer must depend on `lattice-plugin-sdk`
23//! (which uses `extern crate self as lattice_plugin_sdk;` so the paths resolve in
24//! its own tests).
25
26use proc_macro::TokenStream;
27use quote::quote;
28use syn::{Attribute, Data, DeriveInput, Fields, LitStr, Type, parse_macro_input};
29
30/// Derive `PluginEvent` for a serde-serializable struct. See the crate docs for
31/// the generated items and the `#[event(name = "...")]` / doc-comment inputs.
32#[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    // NAME: explicit `#[event(name = "...")]` wins; otherwise the type name
38    // kebab-cased (real plugins namespace via the attr, e.g. "git.hunks-changed").
39    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    // DOC: the struct's `///` doc-comment (joined + trimmed). Empty if none.
46    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
67/// Parse an optional `#[event(name = "...")]` attribute. Returns the explicit
68/// name if present, `None` if the attribute is absent, or a `syn::Error` if the
69/// attribute is malformed (unknown key / non-string value) — surfaced as a
70/// compile error at the derive site, never a silent fallback.
71fn 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
90/// Join a struct's `#[doc = "..."]` attribute lines into a single trimmed
91/// doc string. Each `///` line contributes one entry; interior blank lines are
92/// preserved, leading/trailing whitespace trimmed.
93fn 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        // Rust stores the leading space after `///` in the literal; trim one
110        // layer of surrounding whitespace per line.
111        lines.push(s.value().trim().to_string());
112    }
113    lines.join("\n").trim().to_string()
114}
115
116/// Convert a PascalCase type name to kebab-case (`MyEventType` → `my-event-type`).
117/// A boundary is any uppercase char (except the first); acronyms degrade to
118/// per-letter kebab, which is why real plugins set an explicit `#[event(name)]`.
119fn 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            // TC.4: `ConfigShape` kebab-cases FIELD names, which are
129            // `snake_case` where the type and variant names its two older
130            // callers pass are `CamelCase`. An underscore is not kebab-case by
131            // any reading, so handling it here rather than in a second helper
132            // keeps one answer to "what is this called on the wire" — and a
133            // type name containing one would have wanted this too.
134            out.push('-');
135        } else {
136            out.push(ch);
137        }
138    }
139    out
140}
141
142/// Derive `PluginOption` for a newtype struct over `bool` / `i64` / `String`
143/// (PH7.10b) — the guest-side ergonomic layer over the `config.register-option`
144/// wire. Like `PluginEvent`, it is WIT-agnostic: it generates only metadata
145/// constants + the value type; the plugin makes the `register-option` /
146/// `get-option` WIT calls itself using them.
147///
148/// `#[derive(PluginOption)] #[option(default = "8")] struct TabWidth(i64);`
149/// generates:
150///   - `NAME` — from `#[option(name = "...")]`, else the type name kebab-cased.
151///   - `DOC` — the struct's `///` doc-comment.
152///   - `DEFAULT` — the required `#[option(default = "...")]` string.
153///   - `KIND` — the `OptionKind` inferred from the field type (`bool`→`Boolean`,
154///     `i64`→`Integer`, `String`→`String`).
155///   - `type Value` — the field type (parse a `get-option` result via
156///     `lattice_plugin_sdk::parse_option`).
157#[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
201/// Extract the single field type of a newtype (one-field tuple) struct, or a
202/// `syn::Error` if the input isn't `struct Foo(T);`.
203fn 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
216/// Map the field type to an `OptionKind` token, or a `syn::Error` for an
217/// unsupported type. Matches the last path segment ident (`bool` / `i64` /
218/// `String`) — the three native `OptionType` impls.
219fn 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
239/// Parse `#[option(name = "...", default = "...")]` into `(name?, default?)`. An
240/// unknown key or non-string value is a compile error, never a silent fallback.
241fn 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// ── TC.4: `#[derive(ConfigShape)]` ──────────────────────────────────────────
266
267/// Derive [`ConfigShape`](lattice_plugin_sdk::shape::ConfigShape) for a struct
268/// with named fields, or for an enum whose variants are all unit.
269///
270/// A struct becomes a `record`; each field's schema is its type's, its doc is
271/// its `///` comment, and its `required` comes from the TYPE — an `Option<T>`
272/// field is optional and everything else is required. That is the reason there
273/// is no `#[shape(optional)]` attribute: the type already says it, and a second
274/// place to say it is a second place to disagree.
275///
276/// An all-unit enum becomes an `enum-of` over its variants kebab-cased, which is
277/// the spelling `:set` completion and `:customize` show. A data-carrying variant
278/// is rejected rather than flattened — a tagged union has no `config-schema`
279/// arm, and inventing one silently would produce a shape the host validates
280/// against wrongly.
281///
282/// Field names cross **kebab-cased**, matching the option-name convention
283/// (`todo-keywords`, not `todo_keywords`) so a TOML key reads the way every
284/// other key in the file does.
285#[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        // `Ident::to_string()` on a RAW identifier yields `r#match`, and a
321        // field named after a keyword is not exotic here — org's agenda
322        // sections have a `match` field, because that is org's own name for
323        // it. Emitting `r#match` as the wire name would have produced a schema
324        // whose field no config file could ever spell.
325        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            // An absent optional field is OMITTED, not emitted as a placeholder
341            // — "absent" and "present but empty" are different values, and the
342            // host's `required` check reads absence.
343            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                    // The valid set, inline. An enum's whole advantage over a
460                    // free string is that the answer is finite, so a rejection
461                    // that does not show it wastes the one thing it knows.
462                    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
476/// Whether `ty` is spelled `Option<...>` — how a field says it is not required.
477///
478/// Syntactic, by the last path segment, so `std::option::Option<T>` and a bare
479/// `Option<T>` both match and a user type happening to be named `Option` would
480/// too. That is the same trade `option_kind_for` makes for `bool` / `i64` /
481/// `String`, and a proc macro has no types to ask.
482fn 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}