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