Skip to main content

rorpc_parse/codegen/
contract_attr.rs

1//! Code generation for the `#[contract]` attribute macro.
2//!
3//! Wraps `fn main()` to automatically call `rorpc::generate_contract().output(path)`
4//! in debug builds. Supports compile-time path expressions including `env!()`,
5//! `concat!()`, string literals, and constants.
6//!
7//! Resolution order when no argument is provided:
8//! 1. `[package.metadata.rorpc] client_path` in `Cargo.toml` (read at macro expansion time)
9//! 2. `env!("RORPC_CLIENT_PATH")` fallback
10
11use proc_macro2::TokenStream;
12use quote::quote;
13use syn::{
14    parse::{Parse, ParseStream},
15    Expr, ItemFn,
16};
17
18/// Parsed arguments for `#[contract(...)]` attribute.
19///
20/// Supports:
21/// - `#[contract]` — reads `[package.metadata.rorpc] client_path` from `Cargo.toml`,
22///                   falls back to `env!("RORPC_CLIENT_PATH")`
23/// - `#[contract("../client/bindings.ts")]` — string literal
24/// - `#[contract(env!("RORPC_CLIENT_PATH"))]` — environment variable
25/// - `#[contract(concat!(...))]` — concatenation expression
26/// - `#[contract(CLIENT_PATH)]` — constant
27pub struct ContractArgs {
28    /// The compile-time expression for the output path.
29    /// If `None`, resolved from `Cargo.toml` metadata or env var.
30    pub path_expr: Option<Expr>,
31}
32
33impl Parse for ContractArgs {
34    fn parse(input: ParseStream) -> syn::Result<Self> {
35        if input.is_empty() {
36            return Ok(ContractArgs { path_expr: None });
37        }
38        let expr: Expr = input.parse()?;
39        Ok(ContractArgs {
40            path_expr: Some(expr),
41        })
42    }
43}
44
45/// Try to read `[package.metadata.rorpc] client_path` from the crate's `Cargo.toml`.
46///
47/// Called at macro expansion time. Returns `Some(absolute_path)` if the key is
48/// present, `None` otherwise. The relative path is resolved against
49/// `CARGO_MANIFEST_DIR` so `output()` always receives an absolute path.
50fn read_metadata_client_path() -> Option<String> {
51    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
52    let cargo_toml_path = std::path::Path::new(&manifest_dir).join("Cargo.toml");
53    let content = std::fs::read_to_string(cargo_toml_path).ok()?;
54    let manifest: toml::Value = toml::from_str(&content).ok()?;
55
56    let client_path = manifest
57        .get("package")?
58        .get("metadata")?
59        .get("rorpc")?
60        .get("client_path")?
61        .as_str()?;
62
63    // Resolve relative to CARGO_MANIFEST_DIR — output() requires an absolute path
64    let absolute = std::path::Path::new(&manifest_dir)
65        .join(client_path)
66        .to_string_lossy()
67        .into_owned();
68
69    Some(absolute)
70}
71
72/// Expand `#[contract(...)] fn main() { ... }` into:
73///
74/// ```ignore
75/// fn main() {
76///     #[cfg(debug_assertions)]
77///     {
78///         rorpc::generate_contract()
79///             .output(path)
80///             .expect("contract generation failed");
81///     }
82///     // original body
83/// }
84/// ```
85pub fn expand_contract(args: ContractArgs, func: ItemFn) -> TokenStream {
86    let ItemFn {
87        attrs,
88        vis,
89        sig,
90        block,
91        ..
92    } = func;
93
94    let original_body = &block.stmts;
95
96    // Resolution order:
97    // 1. Explicit argument passed to the macro
98    // 2. [package.metadata.rorpc] client_path in Cargo.toml (read at compile time)
99    // 3. env!("RORPC_CLIENT_PATH") fallback
100    let path_tokens: TokenStream = if let Some(expr) = args.path_expr {
101        quote! { #expr }
102    } else if let Some(path) = read_metadata_client_path() {
103        // Bake the resolved absolute path in as a string literal
104        quote! { #path }
105    } else {
106        quote! { env!("RORPC_CLIENT_PATH") }
107    };
108
109    quote! {
110        #(#attrs)*
111        #vis #sig {
112            #[cfg(debug_assertions)]
113            {
114                ::rorpc::generate_contract()
115                    .output(#path_tokens)
116                    .expect("contract generation failed");
117            }
118
119            #(#original_body)*
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use quote::quote;
128
129    #[test]
130    fn parse_empty_args() {
131        let args: ContractArgs = syn::parse2(quote! {}).expect("parse failed");
132        assert!(args.path_expr.is_none());
133    }
134
135    #[test]
136    fn parse_string_literal() {
137        let args: ContractArgs = syn::parse2(quote! { "../client/bindings.ts" })
138            .expect("parse failed");
139        assert!(args.path_expr.is_some());
140    }
141
142    #[test]
143    fn parse_env_macro() {
144        let args: ContractArgs = syn::parse2(quote! { env!("RORPC_CLIENT_PATH") })
145            .expect("parse failed");
146        assert!(args.path_expr.is_some());
147    }
148
149    #[test]
150    fn parse_concat_macro() {
151        let args: ContractArgs = syn::parse2(quote! {
152            concat!(env!("CARGO_MANIFEST_DIR"), "/../client/src/rpc/bindings.ts")
153        })
154        .expect("parse failed");
155        assert!(args.path_expr.is_some());
156    }
157
158    #[test]
159    fn parse_constant() {
160        let args: ContractArgs = syn::parse2(quote! { CLIENT_PATH }).expect("parse failed");
161        assert!(args.path_expr.is_some());
162    }
163
164    #[test]
165    fn expand_with_string_literal() {
166        let func: ItemFn = syn::parse2(quote! {
167            fn main() { println!("Hello"); }
168        })
169        .expect("parse failed");
170
171        let args: ContractArgs = syn::parse2(quote! { "../client/bindings.ts" })
172            .expect("parse failed");
173        let expanded = expand_contract(args, func);
174        let s = expanded.to_string();
175
176        assert!(s.contains("\"../client/bindings.ts\""));
177        assert!(s.contains("rorpc :: generate_contract"));
178        // quote! adds spaces between tokens, so check for "# [cfg"
179        assert!(s.contains("# [cfg (debug_assertions)]") || s.contains("#[cfg(debug_assertions)]"));
180    }
181
182    #[test]
183    fn expand_preserves_attributes() {
184        let func: ItemFn = syn::parse2(quote! {
185            #[tokio::main]
186            async fn main() { println!("Hello"); }
187        })
188        .expect("parse failed");
189
190        let args: ContractArgs = syn::parse2(quote! { "../client/bindings.ts" })
191            .expect("parse failed");
192        let expanded = expand_contract(args, func);
193        let s = expanded.to_string();
194
195        // quote! adds spaces, so check for "# [tokio"
196        assert!(s.contains("# [tokio :: main]") || s.contains("#[tokio::main]"));
197        assert!(s.contains("async fn main"));
198    }
199}