Skip to main content

vaultdb_orm_macros/
lib.rs

1//! Proc-macro support for `vaultdb-orm`.
2//!
3//! `#[derive(Note)]` reads `#[note(...)]` attributes on the struct and
4//! emits:
5//!
6//! - An `impl vaultdb_orm::Note` block (folder + optional discriminator).
7//! - One `pub fn <field>()` accessor per struct field, each returning a
8//!   `FieldRef` bound to the underlying frontmatter key. The key honours
9//!   `#[serde(rename = "...")]` so model authors can map onto virtual
10//!   fields like `_name` without writing it twice.
11//!
12//! Supported struct-level keys:
13//!
14//! - `#[note(folder = "...")]` (required) — the vault folder for this
15//!   model.
16//! - `#[note(filter = "...")]` (optional) — a where-DSL filter parsed
17//!   at runtime and applied as the discriminator.
18//!
19//! Field-level `#[note(...)]` attributes are reserved for later phases
20//! (e.g. `#[note(wikilink)]` in Phase 5) — for now, the presence of any
21//! `#[note(...)]` attribute on a field suppresses the accessor so the
22//! syntax stays free for future use.
23
24use proc_macro::TokenStream;
25use quote::{format_ident, quote};
26use syn::{Data, DeriveInput, Fields, LitStr, parse_macro_input};
27
28#[proc_macro_derive(Note, attributes(note))]
29pub fn derive_note(input: TokenStream) -> TokenStream {
30    let input = parse_macro_input!(input as DeriveInput);
31    let name = input.ident.clone();
32
33    let mut folder: Option<LitStr> = None;
34    let mut filter: Option<LitStr> = None;
35
36    for attr in &input.attrs {
37        if !attr.path().is_ident("note") {
38            continue;
39        }
40        let parsed = attr.parse_nested_meta(|meta| {
41            if meta.path.is_ident("folder") {
42                folder = Some(meta.value()?.parse()?);
43            } else if meta.path.is_ident("filter") {
44                filter = Some(meta.value()?.parse()?);
45            } else {
46                return Err(meta.error("unknown #[note(...)] key — expected `folder` or `filter`"));
47            }
48            Ok(())
49        });
50        if let Err(e) = parsed {
51            return e.to_compile_error().into();
52        }
53    }
54
55    let folder_lit = match folder {
56        Some(f) => f,
57        None => {
58            return syn::Error::new_spanned(
59                &name,
60                "missing required #[note(folder = \"...\")] on derive(Note)",
61            )
62            .to_compile_error()
63            .into();
64        }
65    };
66
67    let discriminator_impl = match filter {
68        Some(f) => quote! {
69            fn discriminator() -> ::core::option::Option<::vaultdb_orm::Expr> {
70                ::vaultdb_orm::Expr::parse(#f).ok()
71            }
72        },
73        None => quote! {},
74    };
75
76    // Field accessors: one zero-argument fn per struct field, returning
77    // a FieldRef tied to the resolved frontmatter key.
78    let accessors = match field_accessors(&input.data) {
79        Ok(toks) => toks,
80        Err(e) => return e.to_compile_error().into(),
81    };
82
83    let expanded = quote! {
84        impl ::vaultdb_orm::Note for #name {
85            const FOLDER: &'static str = #folder_lit;
86            #discriminator_impl
87        }
88
89        impl #name {
90            #accessors
91        }
92    };
93
94    expanded.into()
95}
96
97fn field_accessors(data: &Data) -> syn::Result<proc_macro2::TokenStream> {
98    let fields = match data {
99        Data::Struct(s) => match &s.fields {
100            Fields::Named(named) => &named.named,
101            Fields::Unnamed(_) => {
102                return Err(syn::Error::new_spanned(
103                    &s.fields,
104                    "derive(Note) requires a struct with named fields",
105                ));
106            }
107            Fields::Unit => return Ok(quote! {}),
108        },
109        Data::Enum(e) => {
110            return Err(syn::Error::new_spanned(
111                &e.enum_token,
112                "derive(Note) cannot be applied to an enum",
113            ));
114        }
115        Data::Union(u) => {
116            return Err(syn::Error::new_spanned(
117                &u.union_token,
118                "derive(Note) cannot be applied to a union",
119            ));
120        }
121    };
122
123    let mut out = proc_macro2::TokenStream::new();
124    for f in fields {
125        let ident = match &f.ident {
126            Some(i) => i,
127            None => continue,
128        };
129        let accessor_ident = format_ident!("{}", ident);
130
131        // Check for a relation attribute: #[note(wikilink)] / #[note(backlink)].
132        match relation_kind(f)? {
133            Some(RelationKind::Outgoing) => {
134                out.extend(quote! {
135                    pub fn #accessor_ident() -> ::vaultdb_orm::RelationRef {
136                        ::vaultdb_orm::RelationRef::outgoing()
137                    }
138                });
139                continue;
140            }
141            Some(RelationKind::Incoming) => {
142                out.extend(quote! {
143                    pub fn #accessor_ident() -> ::vaultdb_orm::RelationRef {
144                        ::vaultdb_orm::RelationRef::incoming()
145                    }
146                });
147                continue;
148            }
149            None => {}
150        }
151
152        let frontmatter_key = serde_rename(f).unwrap_or_else(|| ident.to_string());
153        out.extend(quote! {
154            pub fn #accessor_ident() -> ::vaultdb_orm::FieldRef {
155                ::vaultdb_orm::FieldRef::new(#frontmatter_key)
156            }
157        });
158    }
159
160    Ok(out)
161}
162
163#[derive(Debug, Clone, Copy)]
164enum RelationKind {
165    Outgoing,
166    Incoming,
167}
168
169/// Inspect a field's `#[note(...)]` attributes for `wikilink` or
170/// `backlink` markers. Unknown `#[note(...)]` keys on a field are an
171/// error (catches typos like `#[note(wikilik)]` early).
172fn relation_kind(field: &syn::Field) -> syn::Result<Option<RelationKind>> {
173    let mut kind: Option<RelationKind> = None;
174    for attr in &field.attrs {
175        if !attr.path().is_ident("note") {
176            continue;
177        }
178        attr.parse_nested_meta(|meta| {
179            if meta.path.is_ident("wikilink") {
180                kind = Some(RelationKind::Outgoing);
181            } else if meta.path.is_ident("backlink") {
182                kind = Some(RelationKind::Incoming);
183            } else {
184                return Err(meta.error(
185                    "unknown #[note(...)] key on a field — expected `wikilink` or `backlink`",
186                ));
187            }
188            Ok(())
189        })?;
190    }
191    Ok(kind)
192}
193
194/// Extract `#[serde(rename = "...")]` from a field's attributes, if
195/// present. Only the simple `rename = "x"` form is honoured; the
196/// asymmetric `rename(serialize = .., deserialize = ..)` form is
197/// ignored (frontmatter keys are bidirectional, so distinguishing them
198/// would be a footgun).
199fn serde_rename(field: &syn::Field) -> Option<String> {
200    for attr in &field.attrs {
201        if !attr.path().is_ident("serde") {
202            continue;
203        }
204        let mut found: Option<String> = None;
205        let _ = attr.parse_nested_meta(|meta| {
206            if meta.path.is_ident("rename") {
207                if let Ok(s) = meta.value().and_then(|v| v.parse::<LitStr>()) {
208                    found = Some(s.value());
209                }
210            } else {
211                // Don't error on unknown keys — serde has many we don't care about.
212                // Skip past their value so parse_nested_meta keeps walking.
213                if meta.input.peek(syn::Token![=]) {
214                    let _: syn::Result<syn::Expr> = meta.value().and_then(|v| v.parse());
215                } else if meta.input.peek(syn::token::Paren) {
216                    let _content;
217                    let _ = syn::parenthesized!(_content in meta.input);
218                    let _: proc_macro2::TokenStream = _content.parse().unwrap_or_default();
219                }
220            }
221            Ok(())
222        });
223        if found.is_some() {
224            return found;
225        }
226    }
227    None
228}