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    Expr, ItemFn,
15    parse::{Parse, ParseStream},
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/// Normalize a path by resolving `.` and `..` components without requiring the
46/// path to exist on disk. Unlike [`std::fs::canonicalize`], this works for
47/// paths that haven't been created yet (e.g., a TypeScript output file that
48/// will be generated for the first time).
49///
50/// Walks each component and maintains a stack:
51/// - `..` pops the last element (won't go above a prefix/root component)
52/// - `.` is skipped
53/// - Everything else is pushed
54fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
55    use std::path::Component;
56
57    let mut stack: Vec<std::ffi::OsString> = Vec::new();
58
59    for component in path.components() {
60        match component {
61            Component::Prefix(_) => {
62                // Windows drive prefix (e.g. "D:") — always first, reset stack
63                stack.clear();
64                stack.push(component.as_os_str().to_owned());
65            }
66            Component::RootDir => {
67                // Root separator — keep alongside prefix, don't wipe it
68                stack.push(component.as_os_str().to_owned());
69            }
70            Component::CurDir => {
71                // `.` — skip
72            }
73            Component::ParentDir => {
74                // `..` — pop only if the top of the stack is a Normal segment.
75                // Never pop a Prefix ("D:") or RootDir ("\") entry.
76                let last_is_normal = stack
77                    .last()
78                    .map(|s| {
79                        let p = std::path::Path::new(s);
80                        matches!(p.components().next(), Some(Component::Normal(_)))
81                    })
82                    .unwrap_or(false);
83                if last_is_normal {
84                    stack.pop();
85                }
86            }
87            Component::Normal(name) => {
88                stack.push(name.to_owned());
89            }
90        }
91    }
92
93    stack.iter().collect()
94}
95
96/// Try to read `[package.metadata.rorpc] client_path` from the crate's `Cargo.toml`.
97///
98/// Called at macro expansion time. Returns `Some(absolute_path)` if the key is
99/// present, `None` otherwise. The relative path is resolved against
100/// `CARGO_MANIFEST_DIR` so `output()` always receives an absolute path.
101///
102/// Uses [`normalize_path`] instead of `canonicalize` so that `..` components
103/// are resolved purely lexically — no filesystem access required, meaning paths
104/// that point outside the Rust workspace (e.g.
105/// `"../../../../frontend/src/rpc/bindings.ts"`) work even before the target
106/// file exists.
107fn read_metadata_client_path() -> Option<String> {
108    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
109    let cargo_toml_path = std::path::Path::new(&manifest_dir).join("Cargo.toml");
110    let content = std::fs::read_to_string(cargo_toml_path).ok()?;
111    let manifest: toml::Value = toml::from_str(&content).ok()?;
112
113    let client_path = manifest
114        .get("package")
115        .and_then(|p| p.get("metadata"))
116        .and_then(|m| m.get("rorpc"))
117        .and_then(|r| r.get("client_path"))
118        .and_then(|v| v.as_str())?;
119
120    // Join onto CARGO_MANIFEST_DIR, then normalize to resolve any .. / . components
121    // without requiring the target path to exist on disk.
122    let joined = std::path::Path::new(&manifest_dir).join(client_path);
123    let absolute = normalize_path(&joined).to_string_lossy().into_owned();
124
125    Some(absolute)
126}
127
128/// Expand `#[contract(...)] fn main() { ... }` into:
129///
130/// ```ignore
131/// fn main() {
132///     #[cfg(debug_assertions)]
133///     {
134///         rorpc::generate_contract()
135///             .output(path)
136///             .expect("contract generation failed");
137///     }
138///     // original body
139/// }
140/// ```
141pub fn expand_contract(args: ContractArgs, func: ItemFn) -> TokenStream {
142    let ItemFn {
143        attrs,
144        vis,
145        sig,
146        block,
147        ..
148    } = func;
149
150    let original_body = &block.stmts;
151
152    // Resolution order:
153    // 1. Explicit argument passed to the macro
154    // 2. [package.metadata.rorpc] client_path in Cargo.toml (read at compile time)
155    // 3. env!("RORPC_CLIENT_PATH") fallback
156    let path_tokens: TokenStream = if let Some(expr) = args.path_expr {
157        quote! { #expr }
158    } else if let Some(path) = read_metadata_client_path() {
159        // Bake the resolved absolute path in as a string literal
160        quote! { #path }
161    } else {
162        quote! { env!("RORPC_CLIENT_PATH") }
163    };
164
165    quote! {
166        #(#attrs)*
167        #vis #sig {
168            #[cfg(debug_assertions)]
169            {
170                ::rorpc::generate_contract()
171                    .output(#path_tokens)
172                    .expect("contract generation failed");
173            }
174
175            #(#original_body)*
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use quote::quote;
184
185    // ── ContractArgs parsing ────────────────────────────────────────────────
186
187    #[test]
188    fn parse_empty_args() {
189        let args: ContractArgs = syn::parse2(quote! {}).expect("parse failed");
190        assert!(args.path_expr.is_none());
191    }
192
193    #[test]
194    fn parse_string_literal() {
195        let args: ContractArgs =
196            syn::parse2(quote! { "../client/bindings.ts" }).expect("parse failed");
197        assert!(args.path_expr.is_some());
198    }
199
200    #[test]
201    fn parse_env_macro() {
202        let args: ContractArgs =
203            syn::parse2(quote! { env!("RORPC_CLIENT_PATH") }).expect("parse failed");
204        assert!(args.path_expr.is_some());
205    }
206
207    #[test]
208    fn parse_concat_macro() {
209        let args: ContractArgs = syn::parse2(quote! {
210            concat!(env!("CARGO_MANIFEST_DIR"), "/../client/src/rpc/bindings.ts")
211        })
212        .expect("parse failed");
213        assert!(args.path_expr.is_some());
214    }
215
216    #[test]
217    fn parse_constant() {
218        let args: ContractArgs = syn::parse2(quote! { CLIENT_PATH }).expect("parse failed");
219        assert!(args.path_expr.is_some());
220    }
221
222    // ── expand_contract code generation ────────────────────────────────────
223
224    #[test]
225    fn expand_with_string_literal() {
226        let func: ItemFn = syn::parse2(quote! {
227            fn main() { println!("Hello"); }
228        })
229        .expect("parse failed");
230
231        let args: ContractArgs =
232            syn::parse2(quote! { "../client/bindings.ts" }).expect("parse failed");
233        let expanded = expand_contract(args, func);
234        let s = expanded.to_string();
235
236        assert!(s.contains("\"../client/bindings.ts\""));
237        assert!(s.contains("rorpc :: generate_contract"));
238        assert!(s.contains("# [cfg (debug_assertions)]") || s.contains("#[cfg(debug_assertions)]"));
239    }
240
241    #[test]
242    fn expand_preserves_attributes() {
243        let func: ItemFn = syn::parse2(quote! {
244            #[tokio::main]
245            async fn main() { println!("Hello"); }
246        })
247        .expect("parse failed");
248
249        let args: ContractArgs =
250            syn::parse2(quote! { "../client/bindings.ts" }).expect("parse failed");
251        let expanded = expand_contract(args, func);
252        let s = expanded.to_string();
253
254        assert!(s.contains("# [tokio :: main]") || s.contains("#[tokio::main]"));
255        assert!(s.contains("async fn main"));
256    }
257
258    // ── normalize_path — Unix ───────────────────────────────────────────────
259
260    #[test]
261    fn normalize_simple_parent_traversal() {
262        // ../.. from /repo/server/crate → /repo
263        let p = std::path::Path::new("/repo/server/crate").join("../../out.ts");
264        assert_eq!(normalize_path(&p), std::path::Path::new("/repo/out.ts"));
265    }
266
267    #[test]
268    fn normalize_sibling_dir() {
269        // CARGO_MANIFEST_DIR=/repo/server, client_path="../client/src/bindings.ts"
270        let p = std::path::Path::new("/repo/server").join("../client/src/bindings.ts");
271        assert_eq!(
272            normalize_path(&p),
273            std::path::Path::new("/repo/client/src/bindings.ts")
274        );
275    }
276
277    #[test]
278    fn normalize_deep_traversal_stops_at_root() {
279        // More `..` than path segments — must not go above /
280        let p = std::path::Path::new("/a/b").join("../../../../out.ts");
281        assert_eq!(normalize_path(&p), std::path::Path::new("/out.ts"));
282    }
283
284    #[test]
285    fn normalize_curdirs_are_skipped() {
286        let p = std::path::Path::new("/repo/./server/./crate").join("./out.ts");
287        assert_eq!(
288            normalize_path(&p),
289            std::path::Path::new("/repo/server/crate/out.ts")
290        );
291    }
292
293    #[test]
294    fn normalize_already_clean_path_unchanged() {
295        let p = std::path::Path::new("/repo/client/src/bindings.ts");
296        assert_eq!(normalize_path(p), p);
297    }
298
299    // ── normalize_path — Windows ────────────────────────────────────────────
300
301    #[cfg(windows)]
302    #[test]
303    fn normalize_windows_preserves_drive_letter_shallow() {
304        // The original bug: drive letter was wiped when RootDir cleared the stack.
305        // CARGO_MANIFEST_DIR = D:\programming\Rust\rust-orpc\examples\axum-react\better-auth-integration
306        // client_path = "../client/src/rpc/bindings.ts"
307        let base = std::path::Path::new(
308            r"D:\programming\Rust\rust-orpc\examples\axum-react\better-auth-integration",
309        );
310        let p = base.join("../client/src/rpc/bindings.ts");
311        assert_eq!(
312            normalize_path(&p),
313            std::path::Path::new(
314                r"D:\programming\Rust\rust-orpc\examples\axum-react\client\src\rpc\bindings.ts"
315            ),
316        );
317    }
318
319    #[cfg(windows)]
320    #[test]
321    fn normalize_windows_deep_traversal_to_near_root() {
322        // 5 `..` from a 5-segment path lands just inside the drive root.
323        // better-auth-integration → axum-react → examples → rust-orpc → Rust → programming
324        let base = std::path::Path::new(
325            r"D:\programming\Rust\rust-orpc\examples\axum-react\better-auth-integration",
326        );
327        let p = base.join("../../../../../out.ts");
328        assert_eq!(
329            normalize_path(&p),
330            std::path::Path::new(r"D:\programming\out.ts"),
331        );
332    }
333
334    #[cfg(windows)]
335    #[test]
336    fn normalize_windows_excessive_traversal_stops_at_root() {
337        // More `..` than segments — must not eat the drive letter or root separator.
338        let base = std::path::Path::new(r"D:\a\b");
339        let p = base.join("../../../../../out.ts");
340        assert_eq!(normalize_path(&p), std::path::Path::new(r"D:\out.ts"));
341    }
342
343    #[cfg(windows)]
344    #[test]
345    fn normalize_windows_no_traversal_unchanged() {
346        let p = std::path::Path::new(r"D:\programming\Rust\client\src\bindings.ts");
347        assert_eq!(normalize_path(p), p);
348    }
349}