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}