Skip to main content

harn_builtin_macros/
lib.rs

1//! `#[harn_builtin]` proc-macro.
2//!
3//! Annotates a Rust function that implements one builtin and emits both a
4//! runtime registration entry and a parser `BuiltinSignature` from a single
5//! declaration. This is the only supported way to register stdlib builtins —
6//! see `CONTRIBUTING.md` ("Adding a stdlib builtin") for the wire-up
7//! checklist and `crates/harn-vm/src/stdlib/bytes.rs`, `runtime_scope.rs`,
8//! and `strings.rs` for sync, async, and `aliases = [...]` examples
9//! respectively. The macro contributes each emitted `VmBuiltinDef` to the
10//! workspace-global `ALL_BUILTIN_DEFS` linkme distributed slice, so simply
11//! annotating a fn (in a module already pulled into `harn-vm`) is enough to
12//! make it land in the registry — no per-module aggregation edits required.
13
14extern crate proc_macro;
15
16use proc_macro::TokenStream;
17use proc_macro2::TokenStream as TokenStream2;
18use quote::{format_ident, quote};
19use syn::parse::{Parse, ParseStream};
20use syn::punctuated::Punctuated;
21use syn::spanned::Spanned;
22use syn::{parse_macro_input, Expr, ExprLit, Ident, ItemFn, Lit, LitBool, LitStr, Meta, Token};
23
24mod sig_parser;
25
26/// Marks a Rust function as the runtime handler for a Harn builtin. Emits a
27/// sibling `static <NAME>_DEF: harn_vm::stdlib::macros::VmBuiltinDef = ...`
28/// containing the signature, aliases, handler pointer, and metadata.
29///
30/// # Attribute keys
31///
32/// - `sig = "name(a: dict, b: dict) -> dict"` — Harn-style signature parsed
33///   into a `BuiltinSignature`. Mutually exclusive with `sig_expr`.
34/// - `sig_expr = <Rust expr returning BuiltinSignature>` — full struct
35///   literal used verbatim. Escape hatch for shapes, complex generics, etc.
36/// - `aliases = ["__foo"]` — additional names sharing this impl + signature.
37/// - `exposure = "pure" | "runtime_internal" | "privileged_wire" |
38///   "harness.<capability>.<method>"` — closed source-visibility contract.
39/// - `effects = ["fs.read@arg0", "fs.write@arg0+arg1", ...]` — typed effect
40///   rows. Selectors are `argN`, `argN.field.path`, `eachN`, `const=VALUE`,
41///   or `dynamic`. `effects = []` is an explicit purity declaration.
42/// - `category = "collections"` — observability label (optional).
43/// - `kind = "sync" | "async"` — defaults to `sync`. `async` wraps the user
44///   fn into `Pin<Box<dyn Future<...>>>`.
45/// - `parser_only = true` — emit only the signature; no runtime registration.
46/// - `runtime_only = true` — emit only the runtime entry; signature suppressed.
47/// - `doc = "..."` — override doc string (defaults to the fn's `///` block).
48#[proc_macro_attribute]
49pub fn harn_builtin(attr: TokenStream, item: TokenStream) -> TokenStream {
50    let attrs = parse_macro_input!(attr as BuiltinAttrs);
51    let item_fn = parse_macro_input!(item as ItemFn);
52    match expand(attrs, item_fn) {
53        Ok(ts) => ts.into(),
54        Err(e) => e.to_compile_error().into(),
55    }
56}
57
58/// Declare one method-dispatched capability surface without manufacturing a
59/// runtime handler. The declaration contributes the same `BuiltinDef` shape
60/// as `#[harn_builtin]`, so every consumer reads one manifest.
61#[proc_macro]
62pub fn harn_capability_method(input: TokenStream) -> TokenStream {
63    let input = parse_macro_input!(input as CapabilityMethodInput);
64    match expand_capability_method(input) {
65        Ok(tokens) => tokens.into(),
66        Err(error) => error.to_compile_error().into(),
67    }
68}
69
70/// Declare a capability method in the dependency-leaf contract crate.
71#[proc_macro]
72pub fn harn_capability_contract(input: TokenStream) -> TokenStream {
73    let input = parse_macro_input!(input as CapabilityMethodInput);
74    match expand_leaf_capability_contract(input) {
75        Ok(tokens) => tokens.into(),
76        Err(error) => error.to_compile_error().into(),
77    }
78}
79
80struct CapabilityMethodInput {
81    rust_name: Ident,
82    exposure: LitStr,
83    effects: Vec<LitStr>,
84    signature: Expr,
85    doc: LitStr,
86}
87
88impl Parse for CapabilityMethodInput {
89    fn parse(input: ParseStream) -> syn::Result<Self> {
90        let rust_name = input.parse()?;
91        input.parse::<Token![,]>()?;
92        let exposure = input.parse()?;
93        input.parse::<Token![,]>()?;
94        let effects_expr: Expr = input.parse()?;
95        let effects = parse_str_array(&effects_expr)?;
96        input.parse::<Token![,]>()?;
97        let signature = input.parse()?;
98        input.parse::<Token![,]>()?;
99        let doc = input.parse()?;
100        if !input.is_empty() {
101            return Err(input.error("unexpected capability method tokens"));
102        }
103        Ok(Self {
104            rust_name,
105            exposure,
106            effects,
107            signature,
108            doc,
109        })
110    }
111}
112
113fn expand_capability_method(input: CapabilityMethodInput) -> syn::Result<TokenStream2> {
114    let support = quote!(crate::stdlib::macros);
115    let (sig_expr, signature_text, signature_attr) = match &input.signature {
116        Expr::Lit(ExprLit {
117            lit: Lit::Str(signature),
118            ..
119        }) => (
120            sig_parser::parse_sig(&signature.value(), signature.span(), &support)?,
121            Some(signature.value()),
122            Some(signature.clone()),
123        ),
124        expression => (quote!(#expression), None, None),
125    };
126    let attrs = BuiltinAttrs {
127        sig: signature_attr,
128        exposure: Some(input.exposure),
129        effects: input.effects,
130        effects_declared: true,
131        parser_only: true,
132        ..BuiltinAttrs::default()
133    };
134    let contract = contract_expr(&attrs, &support)?;
135    let upper = input.rust_name.to_string().to_uppercase();
136    let def_ident = format_ident!("{upper}_DEF");
137    let link_ident = format_ident!("__{upper}_LINKME");
138    let doc = input.doc.value();
139    let signature_text_expr = match signature_text {
140        Some(signature) => quote!(::core::option::Option::Some(#signature)),
141        None => quote!(::core::option::Option::None),
142    };
143    Ok(quote! {
144        #[doc(hidden)]
145        #[allow(non_upper_case_globals)]
146        pub static #def_ident: #support::VmBuiltinDef = #support::VmBuiltinDef {
147            sig: #sig_expr,
148            contract: #contract,
149            aliases: &[],
150            handler: #support::VmBuiltinHandler::None,
151            category: ::core::option::Option::Some("capability"),
152            doc: ::core::option::Option::Some(#doc),
153            signature_text: #signature_text_expr,
154            parser_only: true,
155            runtime_only: false,
156        };
157
158        #[doc(hidden)]
159        #[allow(non_upper_case_globals)]
160        #[#support::distributed_slice(#support::ALL_BUILTIN_DEFS)]
161        static #link_ident: &'static #support::VmBuiltinDef = &#def_ident;
162    })
163}
164
165fn expand_leaf_capability_contract(input: CapabilityMethodInput) -> syn::Result<TokenStream2> {
166    let support = quote!(crate::support);
167    let (sig_expr, signature_text, signature_attr) = match &input.signature {
168        Expr::Lit(ExprLit {
169            lit: Lit::Str(signature),
170            ..
171        }) => (
172            sig_parser::parse_sig(&signature.value(), signature.span(), &support)?,
173            Some(signature.value()),
174            Some(signature.clone()),
175        ),
176        expression => (quote!(#expression), None, None),
177    };
178    let attrs = BuiltinAttrs {
179        sig: signature_attr,
180        exposure: Some(input.exposure),
181        effects: input.effects,
182        effects_declared: true,
183        parser_only: true,
184        ..BuiltinAttrs::default()
185    };
186    let contract = contract_expr(&attrs, &support)?;
187    let upper = input.rust_name.to_string().to_uppercase();
188    let def_ident = format_ident!("{upper}_DEF");
189    let link_ident = format_ident!("__{upper}_LINKME");
190    let doc = input.doc.value();
191    let signature_text_expr = match signature_text {
192        Some(signature) => quote!(::core::option::Option::Some(#signature)),
193        None => quote!(::core::option::Option::None),
194    };
195    Ok(quote! {
196        #[doc(hidden)]
197        #[allow(non_upper_case_globals)]
198        pub static #def_ident: #support::CapabilityMethodDef = #support::CapabilityMethodDef {
199            signature: #sig_expr,
200            contract: #contract,
201            doc: #doc,
202            signature_text: #signature_text_expr,
203        };
204
205        #[doc(hidden)]
206        #[allow(non_upper_case_globals)]
207        #[#support::distributed_slice(#support::ALL_CAPABILITY_METHOD_DEFS)]
208        static #link_ident: &'static #support::CapabilityMethodDef = &#def_ident;
209    })
210}
211
212#[derive(Debug, Default)]
213struct BuiltinAttrs {
214    sig: Option<LitStr>,
215    sig_expr: Option<Expr>,
216    aliases: Vec<LitStr>,
217    exposure: Option<LitStr>,
218    effects: Vec<LitStr>,
219    effects_declared: bool,
220    category: Option<LitStr>,
221    kind: BuiltinKind,
222    parser_only: bool,
223    runtime_only: bool,
224    doc: Option<LitStr>,
225}
226
227#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
228enum BuiltinKind {
229    #[default]
230    Sync,
231    Async,
232}
233
234impl Parse for BuiltinAttrs {
235    fn parse(input: ParseStream) -> syn::Result<Self> {
236        let mut out = BuiltinAttrs::default();
237        let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
238        for meta in metas {
239            match &meta {
240                Meta::NameValue(nv) => {
241                    let key = nv
242                        .path
243                        .get_ident()
244                        .ok_or_else(|| syn::Error::new(nv.path.span(), "expected identifier key"))?
245                        .to_string();
246                    match key.as_str() {
247                        "sig" => out.sig = Some(parse_lit_str(&nv.value)?),
248                        "sig_expr" => out.sig_expr = Some(nv.value.clone()),
249                        "category" => out.category = Some(parse_lit_str(&nv.value)?),
250                        "doc" => out.doc = Some(parse_lit_str(&nv.value)?),
251                        "kind" => {
252                            let s = parse_lit_str(&nv.value)?;
253                            out.kind = match s.value().as_str() {
254                                "sync" => BuiltinKind::Sync,
255                                "async" => BuiltinKind::Async,
256                                other => {
257                                    return Err(syn::Error::new(
258                                        s.span(),
259                                        format!(
260                                            "unknown kind {other:?}, expected \"sync\" or \"async\""
261                                        ),
262                                    ));
263                                }
264                            };
265                        }
266                        "parser_only" => out.parser_only = parse_lit_bool(&nv.value)?,
267                        "runtime_only" => out.runtime_only = parse_lit_bool(&nv.value)?,
268                        "aliases" => out.aliases = parse_str_array(&nv.value)?,
269                        "exposure" => out.exposure = Some(parse_lit_str(&nv.value)?),
270                        "effects" => {
271                            out.effects = parse_str_array(&nv.value)?;
272                            out.effects_declared = true;
273                        }
274                        other => {
275                            return Err(syn::Error::new(
276                                nv.path.span(),
277                                format!("unknown #[harn_builtin] key: {other}"),
278                            ));
279                        }
280                    }
281                }
282                other => {
283                    return Err(syn::Error::new(
284                        other.span(),
285                        "expected key = value attributes",
286                    ))
287                }
288            }
289        }
290        if let (Some(sig_lit), Some(_)) = (out.sig.as_ref(), out.sig_expr.as_ref()) {
291            return Err(syn::Error::new(
292                sig_lit.span(),
293                "specify either `sig` (Harn-style string) or `sig_expr` (raw Rust expression), not both",
294            ));
295        }
296        if out.sig.is_none() && out.sig_expr.is_none() && !out.runtime_only {
297            return Err(syn::Error::new(
298                proc_macro2::Span::call_site(),
299                "#[harn_builtin] requires `sig = \"...\"`, `sig_expr = ...`, or `runtime_only = true`",
300            ));
301        }
302        if out.exposure.is_some() != out.effects_declared {
303            return Err(syn::Error::new(
304                proc_macro2::Span::call_site(),
305                "`exposure` and `effects` must be declared together",
306            ));
307        }
308        Ok(out)
309    }
310}
311
312fn parse_lit_str(expr: &Expr) -> syn::Result<LitStr> {
313    match expr {
314        Expr::Lit(syn::ExprLit {
315            lit: syn::Lit::Str(s),
316            ..
317        }) => Ok(s.clone()),
318        other => Err(syn::Error::new(other.span(), "expected string literal")),
319    }
320}
321
322fn parse_lit_bool(expr: &Expr) -> syn::Result<bool> {
323    match expr {
324        Expr::Lit(syn::ExprLit {
325            lit: syn::Lit::Bool(LitBool { value, .. }),
326            ..
327        }) => Ok(*value),
328        other => Err(syn::Error::new(other.span(), "expected boolean literal")),
329    }
330}
331
332fn parse_str_array(expr: &Expr) -> syn::Result<Vec<LitStr>> {
333    match expr {
334        Expr::Array(arr) => arr.elems.iter().map(parse_lit_str).collect(),
335        Expr::Reference(r) => parse_str_array(&r.expr),
336        other => Err(syn::Error::new(
337            other.span(),
338            "expected array of string literals, e.g. [\"alias1\", \"alias2\"]",
339        )),
340    }
341}
342
343fn expand(attrs: BuiltinAttrs, item_fn: ItemFn) -> syn::Result<TokenStream2> {
344    let fn_name = &item_fn.sig.ident;
345    let def_ident = format_ident!("{}_DEF", fn_name.to_string().to_uppercase());
346    let support = quote!(crate::stdlib::macros);
347
348    // Build the BuiltinSignature expression.
349    let sig_expr = if let Some(expr) = &attrs.sig_expr {
350        quote!(#expr)
351    } else if let Some(sig_lit) = &attrs.sig {
352        sig_parser::parse_sig(&sig_lit.value(), sig_lit.span(), &support)?
353    } else {
354        // runtime_only — emit a placeholder signature with the fn name.
355        let name_str = fn_name.to_string();
356        quote!(#support::BuiltinSignature::simple(
357            #name_str,
358            &[],
359            #support::TY_ANY,
360        ))
361    };
362
363    // Surface the human-readable sig text (e.g. `"foo(a: dict) -> dict"`)
364    // through to the runtime metadata layer so `harn explain` /
365    // `harn-vm-tools` / the alignment-test metadata check keep parity
366    // with the pre-migration DSL builder.
367    let signature_text_expr = match &attrs.sig {
368        Some(sig_lit) => {
369            let raw = sig_lit.value();
370            quote!(::core::option::Option::Some(#raw))
371        }
372        None => quote!(::core::option::Option::None),
373    };
374
375    let aliases = attrs.aliases.iter().map(|s| quote!(#s));
376    let aliases_arr = quote!(&[#(#aliases),*]);
377    let contract_expr = contract_expr(&attrs, &support)?;
378
379    let category = match &attrs.category {
380        Some(c) => {
381            let v = c.value();
382            quote!(::core::option::Option::Some(#v))
383        }
384        None => quote!(::core::option::Option::None),
385    };
386
387    // Doc: explicit override, else extract from /// comments on the fn.
388    let doc = if let Some(d) = &attrs.doc {
389        let v = d.value();
390        quote!(::core::option::Option::Some(#v))
391    } else {
392        let collected: String = item_fn
393            .attrs
394            .iter()
395            .filter_map(|a| {
396                if a.path().is_ident("doc") {
397                    if let Meta::NameValue(nv) = &a.meta {
398                        if let Expr::Lit(syn::ExprLit {
399                            lit: syn::Lit::Str(s),
400                            ..
401                        }) = &nv.value
402                        {
403                            return Some(s.value().trim().to_string());
404                        }
405                    }
406                }
407                None
408            })
409            .collect::<Vec<_>>()
410            .join("\n");
411        if collected.is_empty() {
412            quote!(::core::option::Option::None)
413        } else {
414            quote!(::core::option::Option::Some(#collected))
415        }
416    };
417
418    let parser_only = attrs.parser_only;
419    let runtime_only = attrs.runtime_only;
420
421    // Handler wiring depends on sync vs async. For `async fn` user
422    // functions we emit a sibling thunk that boxes the future to match the
423    // `AsyncHandler` signature.
424    let async_thunk_ident = format_ident!("__harn_async_wrap_{}", fn_name);
425    let (handler_expr, extra_items) = match (attrs.kind, attrs.parser_only) {
426        (_, true) => (quote!(#support::VmBuiltinHandler::None), quote!()),
427        (BuiltinKind::Sync, _) => (quote!(#support::VmBuiltinHandler::Sync(#fn_name)), quote!()),
428        (BuiltinKind::Async, _) => {
429            // Async builtins receive an explicit `AsyncBuiltinCtx` handle as
430            // their first parameter (harn#2668). The macro threads it from the
431            // dispatch loop into the user fn so handler bodies mint child VMs /
432            // forward output through the ctx they were given, never an ambient
433            // task-local.
434            let is_async_fn = item_fn.sig.asyncness.is_some();
435            if is_async_fn {
436                let thunk = quote! {
437                    #[doc(hidden)]
438                    #[allow(non_snake_case)]
439                    fn #async_thunk_ident(
440                        ctx: crate::vm::AsyncBuiltinCtx,
441                        args: ::std::vec::Vec<#support::VmValue>,
442                    ) -> #support::AsyncBuiltinFuture {
443                        ::std::boxed::Box::pin(#fn_name(ctx, args))
444                    }
445                };
446                (
447                    quote!(#support::VmBuiltinHandler::Async(#async_thunk_ident)),
448                    thunk,
449                )
450            } else {
451                (
452                    quote!(#support::VmBuiltinHandler::Async(#fn_name)),
453                    quote!(),
454                )
455            }
456        }
457    };
458
459    // Sibling linkme entry that registers `#def_ident` into the
460    // workspace-global `ALL_BUILTIN_DEFS` distributed slice — eliminates
461    // the need for per-module `MODULE_BUILTINS` arrays + a hand-maintained
462    // aggregator in `stdlib.rs`. The entry name is derived from the def
463    // identifier so two builtins in different modules never collide on
464    // the static name.
465    let link_ident = format_ident!("__{}_LINKME", fn_name.to_string().to_uppercase());
466
467    let out = quote! {
468        #item_fn
469
470        #extra_items
471
472        #[doc(hidden)]
473        #[allow(non_upper_case_globals)]
474        pub static #def_ident: #support::VmBuiltinDef = #support::VmBuiltinDef {
475            sig: #sig_expr,
476            contract: #contract_expr,
477            aliases: #aliases_arr,
478            handler: #handler_expr,
479            category: #category,
480            doc: #doc,
481            signature_text: #signature_text_expr,
482            parser_only: #parser_only,
483            runtime_only: #runtime_only,
484        };
485
486        #[doc(hidden)]
487        #[allow(non_upper_case_globals)]
488        #[#support::distributed_slice(#support::ALL_BUILTIN_DEFS)]
489        static #link_ident: &'static #support::VmBuiltinDef = &#def_ident;
490    };
491    Ok(out)
492}
493
494fn contract_expr(attrs: &BuiltinAttrs, support: &TokenStream2) -> syn::Result<TokenStream2> {
495    let Some(exposure) = attrs.exposure.as_ref() else {
496        return Ok(quote!(#support::BuiltinContract::UNDECLARED));
497    };
498
499    let effects = attrs
500        .effects
501        .iter()
502        .map(|effect| parse_effect_spec(&effect.value(), effect.span(), support))
503        .collect::<syn::Result<Vec<_>>>()?;
504    let effects = quote!(&[#(#effects),*]);
505    let raw = exposure.value();
506    match raw.as_str() {
507        "pure" => {
508            if !attrs.effects.is_empty() {
509                return Err(syn::Error::new(
510                    exposure.span(),
511                    "pure builtins must declare `effects = []`",
512                ));
513            }
514            Ok(quote!(#support::BuiltinContract::PURE))
515        }
516        "runtime_internal" => {
517            if !attrs.effects.is_empty() {
518                return Err(syn::Error::new(
519                    exposure.span(),
520                    "runtime-internal builtins cannot declare script effects",
521                ));
522            }
523            Ok(quote!(#support::BuiltinContract::RUNTIME_INTERNAL))
524        }
525        "privileged_wire" => Ok(quote!(#support::BuiltinContract::privileged_wire(#effects))),
526        _ => {
527            if let Some(index) = raw.strip_prefix("capability_arg:") {
528                let authority_argument = index.parse::<u16>().map_err(|_| {
529                    syn::Error::new(
530                        exposure.span(),
531                        "capability argument exposure must be `capability_arg:<index>`",
532                    )
533                })?;
534                if attrs.effects.is_empty() {
535                    return Err(syn::Error::new(
536                        exposure.span(),
537                        "capability argument builtins must declare at least one effect",
538                    ));
539                }
540                return Ok(quote!(
541                    #support::BuiltinContract::capability_function(
542                        #authority_argument,
543                        #effects,
544                    )
545                ));
546            }
547            let Some(rest) = raw.strip_prefix("harness.") else {
548                return Err(syn::Error::new(
549                    exposure.span(),
550                    "unknown exposure; expected `pure`, `runtime_internal`, \
551                     `privileged_wire`, `capability_arg:<index>`, or \
552                     `harness.<capability>.<method>`",
553                ));
554            };
555            let Some((capability, method)) = rest.split_once('.') else {
556                return Err(syn::Error::new(
557                    exposure.span(),
558                    "harness exposure must be `harness.<capability>.<method>`",
559                ));
560            };
561            if method.is_empty() || method.contains('.') {
562                return Err(syn::Error::new(
563                    exposure.span(),
564                    "harness method must be one non-empty identifier",
565                ));
566            }
567            let capability = capability_expr(capability, exposure.span(), support)?;
568            Ok(quote!(#support::BuiltinContract::harness(
569                #capability,
570                #method,
571                #effects,
572            )))
573        }
574    }
575}
576
577fn capability_expr(
578    name: &str,
579    span: proc_macro2::Span,
580    support: &TokenStream2,
581) -> syn::Result<TokenStream2> {
582    let variant = harn_builtin_meta::CapabilityId::from_field_name(name)
583        .map(harn_builtin_meta::CapabilityId::variant_name)
584        .ok_or_else(|| syn::Error::new(span, format!("unknown harness capability `{name}`")))?;
585    let ident = format_ident!("{variant}");
586    Ok(quote!(#support::CapabilityId::#ident))
587}
588
589fn parse_effect_spec(
590    raw: &str,
591    span: proc_macro2::Span,
592    support: &TokenStream2,
593) -> syn::Result<TokenStream2> {
594    let (head, selectors) = raw
595        .split_once('@')
596        .map_or((raw, None), |(head, selectors)| (head, Some(selectors)));
597    let Some((kind, access)) = head.split_once('.') else {
598        return Err(syn::Error::new(
599            span,
600            "effect must be `<kind>.<access>` with optional `@selectors`",
601        ));
602    };
603    let kind = match kind {
604        "stdio" => quote!(#support::EffectKind::Stdio),
605        "fs" => quote!(#support::EffectKind::Fs),
606        "env" => quote!(#support::EffectKind::Env),
607        "clock" => quote!(#support::EffectKind::Clock),
608        "random" => quote!(#support::EffectKind::Random),
609        "network" => quote!(#support::EffectKind::Network),
610        "process" => quote!(#support::EffectKind::Process),
611        "llm" => quote!(#support::EffectKind::Llm),
612        "tool" => quote!(#support::EffectKind::Tool),
613        "mcp" => quote!(#support::EffectKind::Mcp),
614        "host" => quote!(#support::EffectKind::Host),
615        "worker" => quote!(#support::EffectKind::Worker),
616        "secret" => quote!(#support::EffectKind::Secret),
617        "observability" => quote!(#support::EffectKind::Observability),
618        "channel" => quote!(#support::EffectKind::Channel),
619        "state" => quote!(#support::EffectKind::State),
620        _ => {
621            return Err(syn::Error::new(
622                span,
623                format!("unknown effect kind `{kind}`"),
624            ))
625        }
626    };
627    let access = match access {
628        "read" => quote!(#support::EffectAccess::Read),
629        "write" => quote!(#support::EffectAccess::Write),
630        "mutate" => quote!(#support::EffectAccess::Mutate),
631        "observe" => quote!(#support::EffectAccess::Observe),
632        _ => {
633            return Err(syn::Error::new(
634                span,
635                format!("unknown effect access `{access}`"),
636            ))
637        }
638    };
639    let selectors = selectors
640        .filter(|selectors| !selectors.is_empty())
641        .map(|selectors| {
642            selectors
643                .split('+')
644                .map(|selector| parse_resource_selector(selector, span, support))
645                .collect::<syn::Result<Vec<_>>>()
646        })
647        .transpose()?
648        .unwrap_or_default();
649    Ok(quote!(#support::EffectSpec::new(
650        #kind,
651        #access,
652        &[#(#selectors),*],
653    )))
654}
655
656fn parse_resource_selector(
657    raw: &str,
658    span: proc_macro2::Span,
659    support: &TokenStream2,
660) -> syn::Result<TokenStream2> {
661    if raw == "dynamic" {
662        return Ok(quote!(#support::ResourceSelector::Dynamic));
663    }
664    if let Some(value) = raw.strip_prefix("const=") {
665        if value.is_empty() {
666            return Err(syn::Error::new(span, "constant selector cannot be empty"));
667        }
668        return Ok(quote!(#support::ResourceSelector::Constant(#value)));
669    }
670    if let Some(index) = raw.strip_prefix("each") {
671        let index = parse_selector_index(index, span)?;
672        return Ok(quote!(#support::ResourceSelector::EachArgument(#index)));
673    }
674    let Some(rest) = raw.strip_prefix("arg") else {
675        return Err(syn::Error::new(
676            span,
677            format!("unknown resource selector `{raw}`"),
678        ));
679    };
680    let mut parts = rest.split('.');
681    let index = parse_selector_index(parts.next().unwrap_or_default(), span)?;
682    let path = parts.collect::<Vec<_>>();
683    if path.is_empty() {
684        Ok(quote!(#support::ResourceSelector::Argument(#index)))
685    } else if path.iter().any(|part| part.is_empty()) {
686        Err(syn::Error::new(span, "resource field path cannot be empty"))
687    } else {
688        Ok(quote!(#support::ResourceSelector::Field {
689            argument: #index,
690            path: &[#(#path),*],
691        }))
692    }
693}
694
695fn parse_selector_index(raw: &str, span: proc_macro2::Span) -> syn::Result<u16> {
696    raw.parse::<u16>()
697        .map_err(|_| syn::Error::new(span, format!("invalid argument index `{raw}`")))
698}