Skip to main content

kanshou_derive/
lib.rs

1//! `#[derive(Introspect)]` — auto-implements `kanshou::Introspect`
2//! for a struct whose `pub` fields are each independently queryable.
3//!
4//! ## Default behavior
5//!
6//! - Every `pub` named field becomes a top-level path leaf. A query
7//!   `Query { path: ["field_name"] }` returns `serde_json::to_value(&self.field_name)`.
8//! - Tuple structs, unit structs, and enums are unsupported (compile
9//!   error). Consumers with non-struct shapes hand-write `Introspect`.
10//!
11//! ## Field attributes
12//!
13//! Per-field `#[introspect(...)]` modifies the leaf shape:
14//!
15//! - `#[introspect(skip)]` — exclude from the query surface. Useful for
16//!   internal channels, abort handles, or anything the operator
17//!   shouldn't see.
18//! - `#[introspect(load)]` — call `.load(Ordering::Relaxed)` before
19//!   serializing. For `AtomicU64`/`AtomicUsize`/etc. so the wire shape
20//!   is a number, not the atomic struct's debug print.
21//! - `#[introspect(nested)]` — the field itself implements `Introspect`;
22//!   nested path elements walk into it. Without this attribute,
23//!   `query` on `["field_name", "subfield"]` returns
24//!   `QueryError::UnknownField`. With it, the rest of the path
25//!   recurses.
26//! - `#[introspect(name = "wire_name")]` — expose the field under a
27//!   different name on the wire than the Rust identifier. Mirrors
28//!   `#[serde(rename = "...")]`. Lets consumer apps stabilize a public
29//!   API even when the internal field shape evolves.
30//!
31//! ## Example
32//!
33//! ```ignore
34//! use std::sync::atomic::AtomicU64;
35//! use kanshou::Introspect;
36//!
37//! #[derive(Introspect)]
38//! pub struct AppState {
39//!     pub sessions: Vec<String>,
40//!     #[introspect(load)]
41//!     pub frame_count: AtomicU64,
42//!     #[introspect(nested)]
43//!     pub config: Config,
44//!     #[introspect(skip)]
45//!     internal: tokio::sync::mpsc::Sender<()>,
46//! }
47//!
48//! #[derive(Introspect)]
49//! pub struct Config {
50//!     pub shell: String,
51//!     pub width: u32,
52//! }
53//! ```
54
55use proc_macro::TokenStream;
56use quote::quote;
57use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta};
58
59/// Auto-implement `kanshou::Introspect` for a struct with named
60/// `pub` fields. See module docs for attribute reference.
61#[proc_macro_derive(Introspect, attributes(introspect))]
62pub fn derive_introspect(input: TokenStream) -> TokenStream {
63    let input = parse_macro_input!(input as DeriveInput);
64    let name = &input.ident;
65    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
66
67    let Data::Struct(data) = &input.data else {
68        return TokenStream::from(quote! {
69            compile_error!("#[derive(Introspect)] only supports structs with named fields");
70        });
71    };
72    let Fields::Named(fields) = &data.fields else {
73        return TokenStream::from(quote! {
74            compile_error!("#[derive(Introspect)] only supports structs with named fields");
75        });
76    };
77
78    let mut field_arms = Vec::new();
79    let mut schema_entries = Vec::new();
80
81    for field in &fields.named {
82        let attrs = parse_field_attrs(&field.attrs);
83        if attrs.skip {
84            continue;
85        }
86        let Some(field_ident) = field.ident.as_ref() else {
87            continue;
88        };
89        let wire_name = attrs.rename.unwrap_or_else(|| field_ident.to_string());
90        schema_entries.push(quote! { #wire_name });
91
92        let read_expr = if attrs.load {
93            quote! {
94                ::serde_json::to_value(
95                    self.#field_ident.load(::std::sync::atomic::Ordering::Relaxed)
96                )
97            }
98        } else {
99            quote! { ::serde_json::to_value(&self.#field_ident) }
100        };
101
102        let arm = if attrs.nested {
103            quote! {
104                #wire_name => {
105                    if q.path.len() > 1 {
106                        let sub = ::kanshou::Query {
107                            path: q.path[1..].to_vec(),
108                            args: q.args.clone(),
109                        };
110                        ::kanshou::Introspect::query(&self.#field_ident, &sub)
111                    } else {
112                        #read_expr.map_err(|e| ::kanshou::QueryError::internal(
113                            format!("serialize {}: {}", #wire_name, e)
114                        ))
115                    }
116                }
117            }
118        } else {
119            quote! {
120                #wire_name => {
121                    if q.path.len() > 1 {
122                        Err(::kanshou::QueryError::unknown_field(q.path.join(".")))
123                    } else {
124                        #read_expr.map_err(|e| ::kanshou::QueryError::internal(
125                            format!("serialize {}: {}", #wire_name, e)
126                        ))
127                    }
128                }
129            }
130        };
131        field_arms.push(arm);
132    }
133
134    let expanded = quote! {
135        impl #impl_generics ::kanshou::Introspect for #name #ty_generics #where_clause {
136            fn query(&self, q: &::kanshou::Query) -> ::kanshou::QueryResult {
137                let Some(first) = q.path.first().map(::std::string::String::as_str) else {
138                    return Err(::kanshou::QueryError::unknown_field(
139                        ::std::string::String::new(),
140                    ));
141                };
142                match first {
143                    #(#field_arms)*
144                    other => Err(::kanshou::QueryError::unknown_field(other.to_string())),
145                }
146            }
147
148            fn schema(&self) -> &'static [&'static str] {
149                &[#(#schema_entries),*]
150            }
151        }
152    };
153
154    TokenStream::from(expanded)
155}
156
157#[derive(Default)]
158struct FieldAttrs {
159    skip: bool,
160    load: bool,
161    nested: bool,
162    rename: Option<String>,
163}
164
165fn parse_field_attrs(attrs: &[syn::Attribute]) -> FieldAttrs {
166    let mut out = FieldAttrs::default();
167    for attr in attrs {
168        if !attr.path().is_ident("introspect") {
169            continue;
170        }
171        let Meta::List(list) = &attr.meta else { continue };
172        let _ = list.parse_nested_meta(|meta| {
173            let Some(ident) = meta.path.get_ident() else {
174                return Ok(());
175            };
176            match ident.to_string().as_str() {
177                "skip" => out.skip = true,
178                "load" => out.load = true,
179                "nested" => out.nested = true,
180                "name" => {
181                    let value: Lit = meta.value()?.parse()?;
182                    if let Lit::Str(s) = value {
183                        out.rename = Some(s.value());
184                    }
185                }
186                _ => {}
187            }
188            Ok(())
189        });
190    }
191    out
192}