Skip to main content

entelix_graph_derive/
lib.rs

1//! # entelix-graph-derive
2//!
3//! `#[derive(StateMerge)]` proc-macro for `StateGraph<S>` state
4//! types. The derive emits two related items:
5//!
6//! 1. A `<Name>Contribution` companion struct where every field
7//!    is `Option`-wrapped — the natural Rust shape for the
8//!    `LangGraph` `TypedDict` "node returned only these slots"
9//!    semantic.
10//! 2. The `entelix_graph::StateMerge` impl on the input struct.
11//!    `merge` combines two same-shape `S` values via per-field
12//!    reducers (`Annotated<T, R>` fields call `R::reduce`; plain
13//!    fields are replaced by `update`). `merge_contribution`
14//!    folds an `Option`-wrapped contribution into the current
15//!    state — fields the node *didn't write* leave the current
16//!    value untouched, and fields it *did* write merge through
17//!    the same per-field reducer.
18//!
19//! The companion struct also gets a builder-style `with_<field>`
20//! method per field — for `Annotated<T, R>` fields the builder
21//! takes raw `T` and wraps it with `R::default()` automatically,
22//! so node bodies write `contribution.with_log(vec!["…"])`
23//! rather than `contribution.with_log(Annotated::new(vec, Append::new()))`.
24//!
25//! ## Field detection
26//!
27//! - Field type is `Annotated<T, R>` (any path ending in
28//!   `Annotated<…>` with at least one type argument): the
29//!   contribution slot is `Option<Annotated<T, R>>`, the
30//!   `merge`/`merge_contribution` paths call the bundled
31//!   reducer, the builder accepts raw `T`.
32//! - Any other type: the contribution slot is `Option<T>`, the
33//!   `merge` path replaces, the builder accepts `T` directly.
34//!
35//! ## Generated impl (illustrative)
36//!
37//! For:
38//!
39//! ```ignore
40//! #[derive(StateMerge, Clone, Default)]
41//! struct AgentState {
42//!     log: Annotated<Vec<String>, Append<String>>,
43//!     score: Annotated<i32, Max<i32>>,
44//!     last_message: String,
45//! }
46//! ```
47//!
48//! the macro emits (roughly):
49//!
50//! ```text
51//! #[derive(Default)]
52//! pub struct AgentStateContribution {
53//!     pub log: Option<Annotated<Vec<String>, Append<String>>>,
54//!     pub score: Option<Annotated<i32, Max<i32>>>,
55//!     pub last_message: Option<String>,
56//! }
57//!
58//! impl AgentStateContribution {
59//!     pub fn with_log(mut self, v: Vec<String>) -> Self { ... }
60//!     pub fn with_score(mut self, v: i32) -> Self { ... }
61//!     pub fn with_last_message(mut self, v: String) -> Self { ... }
62//! }
63//!
64//! impl ::entelix_graph::StateMerge for AgentState {
65//!     type Contribution = AgentStateContribution;
66//!     fn merge(self, update: Self) -> Self { ... }
67//!     fn merge_contribution(self, c: Self::Contribution) -> Self { ... }
68//! }
69//! ```
70//!
71//! Tuple and unit structs are rejected at compile time.
72//! Reducers used in `Annotated<T, R>` fields must implement
73//! `Default` (the contribution builder needs to construct the
74//! `Annotated` instance from raw `T`); the four stock reducers
75//! (`Replace`, `Append`, `MergeMap`, `Max`) all qualify, as do
76//! any unit-struct user reducers. Stateful reducers requiring
77//! configuration are out of scope for the derive — operators
78//! using them implement `StateMerge` manually.
79
80#![doc(html_root_url = "https://docs.rs/entelix-graph-derive/0.5.3")]
81#![deny(missing_docs)]
82
83use proc_macro::TokenStream;
84use proc_macro2::TokenStream as TokenStream2;
85use quote::{format_ident, quote};
86use syn::{
87    Data, DataStruct, DeriveInput, Field, Fields, GenericArgument, Ident, PathArguments, Type,
88    parse_macro_input, spanned::Spanned,
89};
90
91/// Derive `entelix_graph::StateMerge` and generate the
92/// `<Name>Contribution` companion struct.
93#[proc_macro_derive(StateMerge)]
94pub fn derive_state_merge(input: TokenStream) -> TokenStream {
95    let ast = parse_macro_input!(input as DeriveInput);
96    expand(&ast)
97        .unwrap_or_else(syn::Error::into_compile_error)
98        .into()
99}
100
101fn expand(ast: &DeriveInput) -> syn::Result<TokenStream2> {
102    let Data::Struct(DataStruct { fields, .. }) = &ast.data else {
103        return Err(syn::Error::new(
104            ast.span(),
105            "#[derive(StateMerge)] only supports structs",
106        ));
107    };
108    let Fields::Named(named) = fields else {
109        return Err(syn::Error::new(
110            fields.span(),
111            "#[derive(StateMerge)] requires named fields",
112        ));
113    };
114
115    let name = &ast.ident;
116    let vis = &ast.vis;
117    let contribution_ident = format_ident!("{}Contribution", name);
118    let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
119
120    let descriptors: Vec<FieldDescriptor> = named.named.iter().map(FieldDescriptor::from).collect();
121
122    let companion_fields = descriptors.iter().map(FieldDescriptor::companion_field);
123    let companion_builders = descriptors.iter().map(FieldDescriptor::companion_builder);
124    let mergers = descriptors.iter().map(FieldDescriptor::merge_arm);
125    let contribution_mergers = descriptors
126        .iter()
127        .map(FieldDescriptor::merge_contribution_arm);
128
129    Ok(quote! {
130        #[derive(Default)]
131        #vis struct #contribution_ident #ty_generics #where_clause {
132            #(#companion_fields),*
133        }
134
135        impl #impl_generics #contribution_ident #ty_generics #where_clause {
136            #(#companion_builders)*
137        }
138
139        impl #impl_generics ::entelix_graph::StateMerge for #name #ty_generics #where_clause {
140            type Contribution = #contribution_ident #ty_generics;
141
142            fn merge(self, update: Self) -> Self {
143                Self {
144                    #(#mergers),*
145                }
146            }
147
148            fn merge_contribution(self, contribution: Self::Contribution) -> Self {
149                Self {
150                    #(#contribution_mergers),*
151                }
152            }
153        }
154    })
155}
156
157/// Per-field metadata the macro derives once and reuses across
158/// every emit site. Pre-computing the kind keeps the four
159/// `quote!` callsites readable.
160struct FieldDescriptor<'a> {
161    ident: &'a Ident,
162    ty: &'a Type,
163    annotated_inner: Option<&'a Type>,
164}
165
166impl<'a> From<&'a Field> for FieldDescriptor<'a> {
167    fn from(field: &'a Field) -> Self {
168        // The named-fields branch in `expand` already gated
169        // tuple/unit structs out — every field reaching here has
170        // an ident. `unwrap_or_else` keeps the macro
171        // panic-free if a future call path ever reaches this
172        // without that guarantee.
173        let ident = field
174            .ident
175            .as_ref()
176            .unwrap_or_else(|| unreachable!("FieldDescriptor::from called on tuple/unit field"));
177        Self {
178            ident,
179            ty: &field.ty,
180            annotated_inner: annotated_first_arg(&field.ty),
181        }
182    }
183}
184
185impl FieldDescriptor<'_> {
186    fn companion_field(&self) -> TokenStream2 {
187        let ident = self.ident;
188        let ty = self.ty;
189        quote! { pub #ident: ::core::option::Option<#ty> }
190    }
191
192    fn companion_builder(&self) -> TokenStream2 {
193        let ident = self.ident;
194        let setter = format_ident!("with_{}", ident);
195        self.annotated_inner.map_or_else(
196            || {
197                let ty = self.ty;
198                quote! {
199                    /// Set this slot in the contribution.
200                    #[must_use]
201                    pub fn #setter(mut self, value: #ty) -> Self {
202                        self.#ident = ::core::option::Option::Some(value);
203                        self
204                    }
205                }
206            },
207            |inner| {
208                quote! {
209                    /// Set this slot in the contribution. The supplied
210                    /// value is automatically wrapped in `Annotated`
211                    /// using the field's reducer type's `Default` impl.
212                    #[must_use]
213                    pub fn #setter(mut self, value: #inner) -> Self {
214                        self.#ident = ::core::option::Option::Some(
215                            ::entelix_graph::Annotated::new(
216                                value,
217                                ::core::default::Default::default(),
218                            ),
219                        );
220                        self
221                    }
222                }
223            },
224        )
225    }
226
227    /// Per-field merger for `merge(Self, Self) -> Self` —
228    /// `Annotated<T, R>` calls reducer, plain field replaces.
229    fn merge_arm(&self) -> TokenStream2 {
230        let ident = self.ident;
231        if self.annotated_inner.is_some() {
232            quote! { #ident: self.#ident.merge(update.#ident) }
233        } else {
234            quote! { #ident: update.#ident }
235        }
236    }
237
238    /// Per-field merger for `merge_contribution(Self, Contribution) -> Self`
239    /// — `None` means "node didn't write this slot, keep current
240    /// value"; `Some(v)` means "merge through reducer for
241    /// `Annotated`, replace for plain".
242    fn merge_contribution_arm(&self) -> TokenStream2 {
243        let ident = self.ident;
244        if self.annotated_inner.is_some() {
245            quote! {
246                #ident: match contribution.#ident {
247                    ::core::option::Option::Some(v) => self.#ident.merge(v),
248                    ::core::option::Option::None => self.#ident,
249                }
250            }
251        } else {
252            quote! {
253                #ident: contribution.#ident.unwrap_or(self.#ident)
254            }
255        }
256    }
257}
258
259/// `Some(inner)` when `ty` is syntactically `Annotated<inner, …>`
260/// (any path length — `Annotated<…>`,
261/// `entelix_graph::Annotated<…>`, or a user re-export like
262/// `crate::state::Annotated<…>` all match). Returns the first
263/// generic argument so the builder can take raw `T`. The macro
264/// only inspects the *last* path segment; shadowing `Annotated`
265/// elsewhere is forbidden by convention and would surface as a
266/// type-check error during compilation of the generated code.
267fn annotated_first_arg(ty: &Type) -> Option<&Type> {
268    let Type::Path(type_path) = ty else {
269        return None;
270    };
271    let last = type_path.path.segments.last()?;
272    if last.ident != Ident::new("Annotated", last.ident.span()) {
273        return None;
274    }
275    let PathArguments::AngleBracketed(ref args) = last.arguments else {
276        return None;
277    };
278    args.args.iter().find_map(|arg| match arg {
279        GenericArgument::Type(t) => Some(t),
280        _ => None,
281    })
282}