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