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.
50///
51/// Canonicalizes the path to resolve `..` components, enabling paths like
52/// `"../../../../frontend/src/rpc/bindings.ts"` when frontend is outside the Rust workspace.
53fn read_metadata_client_path() -> Option<String> {
54    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
55    let cargo_toml_path = std::path::Path::new(&manifest_dir).join("Cargo.toml");
56    let content = std::fs::read_to_string(cargo_toml_path).ok()?;
57    let manifest: toml::Value = toml::from_str(&content).ok()?;
58
59    let client_path = manifest
60        .get("package")?
61        .get("metadata")?
62        .get("rorpc")?
63        .get("client_path")?
64        .as_str()?;
65
66    // Resolve relative to CARGO_MANIFEST_DIR, then canonicalize to resolve .. components
67    let resolved = std::path::Path::new(&manifest_dir).join(client_path);
68    
69    // Canonicalize to absolute path, resolving all .. and . components
70    // Falls back to joined path if canonicalize fails (e.g., path doesn't exist yet)
71    let absolute = resolved
72        .canonicalize()
73        .unwrap_or(resolved)
74        .to_string_lossy()
75        .into_owned();
76
77    Some(absolute)
78}
79
80/// Expand `#[contract(...)] fn main() { ... }` into:
81///
82/// ```ignore
83/// fn main() {
84///     #[cfg(debug_assertions)]
85///     {
86///         rorpc::generate_contract()
87///             .output(path)
88///             .expect("contract generation failed");
89///     }
90///     // original body
91/// }
92/// ```
93pub fn expand_contract(args: ContractArgs, func: ItemFn) -> TokenStream {
94    let ItemFn {
95        attrs,
96        vis,
97        sig,
98        block,
99        ..
100    } = func;
101
102    let original_body = &block.stmts;
103
104    // Resolution order:
105    // 1. Explicit argument passed to the macro
106    // 2. [package.metadata.rorpc] client_path in Cargo.toml (read at compile time)
107    // 3. env!("RORPC_CLIENT_PATH") fallback
108    let path_tokens: TokenStream = if let Some(expr) = args.path_expr {
109        quote! { #expr }
110    } else if let Some(path) = read_metadata_client_path() {
111        // Bake the resolved absolute path in as a string literal
112        quote! { #path }
113    } else {
114        quote! { env!("RORPC_CLIENT_PATH") }
115    };
116
117    quote! {
118        #(#attrs)*
119        #vis #sig {
120            #[cfg(debug_assertions)]
121            {
122                ::rorpc::generate_contract()
123                    .output(#path_tokens)
124                    .expect("contract generation failed");
125            }
126
127            #(#original_body)*
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use quote::quote;
136
137    #[test]
138    fn parse_empty_args() {
139        let args: ContractArgs = syn::parse2(quote! {}).expect("parse failed");
140        assert!(args.path_expr.is_none());
141    }
142
143    #[test]
144    fn parse_string_literal() {
145        let args: ContractArgs = syn::parse2(quote! { "../client/bindings.ts" })
146            .expect("parse failed");
147        assert!(args.path_expr.is_some());
148    }
149
150    #[test]
151    fn parse_env_macro() {
152        let args: ContractArgs = syn::parse2(quote! { env!("RORPC_CLIENT_PATH") })
153            .expect("parse failed");
154        assert!(args.path_expr.is_some());
155    }
156
157    #[test]
158    fn parse_concat_macro() {
159        let args: ContractArgs = syn::parse2(quote! {
160            concat!(env!("CARGO_MANIFEST_DIR"), "/../client/src/rpc/bindings.ts")
161        })
162        .expect("parse failed");
163        assert!(args.path_expr.is_some());
164    }
165
166    #[test]
167    fn parse_constant() {
168        let args: ContractArgs = syn::parse2(quote! { CLIENT_PATH }).expect("parse failed");
169        assert!(args.path_expr.is_some());
170    }
171
172    #[test]
173    fn expand_with_string_literal() {
174        let func: ItemFn = syn::parse2(quote! {
175            fn main() { println!("Hello"); }
176        })
177        .expect("parse failed");
178
179        let args: ContractArgs = syn::parse2(quote! { "../client/bindings.ts" })
180            .expect("parse failed");
181        let expanded = expand_contract(args, func);
182        let s = expanded.to_string();
183
184        assert!(s.contains("\"../client/bindings.ts\""));
185        assert!(s.contains("rorpc :: generate_contract"));
186        // quote! adds spaces between tokens, so check for "# [cfg"
187        assert!(s.contains("# [cfg (debug_assertions)]") || s.contains("#[cfg(debug_assertions)]"));
188    }
189
190    #[test]
191    fn expand_preserves_attributes() {
192        let func: ItemFn = syn::parse2(quote! {
193            #[tokio::main]
194            async fn main() { println!("Hello"); }
195        })
196        .expect("parse failed");
197
198        let args: ContractArgs = syn::parse2(quote! { "../client/bindings.ts" })
199            .expect("parse failed");
200        let expanded = expand_contract(args, func);
201        let s = expanded.to_string();
202
203        // quote! adds spaces, so check for "# [tokio"
204        assert!(s.contains("# [tokio :: main]") || s.contains("#[tokio::main]"));
205        assert!(s.contains("async fn main"));
206    }
207}