Skip to main content

vercel_rpc_cli/parser/
extract.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
6use syn::{Attribute, File, FnArg, Item, ItemFn, ReturnType};
7use walkdir::WalkDir;
8
9use super::types::{extract_rust_type, extract_struct_fields};
10use crate::config::InputConfig;
11use crate::model::{
12    EnumDef, EnumVariant, Manifest, Procedure, ProcedureKind, StructDef, VariantKind,
13};
14
15/// RPC attribute names recognized by the parser.
16const RPC_QUERY_ATTR: &str = "rpc_query";
17const RPC_MUTATION_ATTR: &str = "rpc_mutation";
18
19/// Builds a `GlobSet` from a list of glob pattern strings.
20fn build_glob_set(patterns: &[String]) -> Result<GlobSet> {
21    let mut builder = GlobSetBuilder::new();
22    for pattern in patterns {
23        let glob = GlobBuilder::new(pattern)
24            .literal_separator(false)
25            .build()
26            .with_context(|| format!("Invalid glob pattern: {pattern}"))?;
27        builder.add(glob);
28    }
29    builder.build().context("Failed to build glob set")
30}
31
32/// Scans `.rs` files in the configured directory and extracts RPC metadata.
33///
34/// Walks the directory recursively, applying `include`/`exclude` glob patterns
35/// from the config, then parsing each matching Rust source file for
36/// `#[rpc_query]` / `#[rpc_mutation]` annotated functions and `#[derive(Serialize)]` structs.
37pub fn scan_directory(input: &InputConfig) -> Result<Manifest> {
38    let mut manifest = Manifest::default();
39
40    let include_set = build_glob_set(&input.include)?;
41    let exclude_set = build_glob_set(&input.exclude)?;
42
43    let entries: Vec<_> = WalkDir::new(&input.dir)
44        .into_iter()
45        // Skip unreadable entries (e.g. permission denied); the scan should
46        // not abort because a single directory entry is inaccessible.
47        .filter_map(|e| e.ok())
48        .filter(|e| {
49            if e.path().extension().is_none_or(|ext| ext != "rs") {
50                return false;
51            }
52            let rel = e.path().strip_prefix(&input.dir).unwrap_or(e.path());
53            include_set.is_match(rel) && !exclude_set.is_match(rel)
54        })
55        .collect();
56
57    if entries.is_empty() {
58        anyhow::bail!("No .rs files found in {}", input.dir.display());
59    }
60
61    for entry in entries {
62        let path = entry.path();
63        let file_manifest =
64            parse_file(path).with_context(|| format!("Failed to parse {}", path.display()))?;
65
66        manifest.procedures.extend(file_manifest.procedures);
67        manifest.structs.extend(file_manifest.structs);
68        manifest.enums.extend(file_manifest.enums);
69    }
70
71    // Sort for deterministic output
72    manifest.procedures.sort_by(|a, b| a.name.cmp(&b.name));
73    manifest.structs.sort_by(|a, b| a.name.cmp(&b.name));
74    manifest.enums.sort_by(|a, b| a.name.cmp(&b.name));
75
76    Ok(manifest)
77}
78
79/// Parses a single Rust source file and extracts all RPC procedures and struct definitions.
80pub fn parse_file(path: &Path) -> Result<Manifest> {
81    let source =
82        fs::read_to_string(path).with_context(|| format!("Cannot read {}", path.display()))?;
83
84    let syntax: File =
85        syn::parse_file(&source).with_context(|| format!("Syntax error in {}", path.display()))?;
86
87    let mut manifest = Manifest::default();
88
89    for item in &syntax.items {
90        match item {
91            Item::Fn(func) => {
92                if let Some(procedure) = try_extract_procedure(func, path) {
93                    manifest.procedures.push(procedure);
94                }
95            }
96            Item::Struct(item_struct) => {
97                if has_serde_derive(&item_struct.attrs) {
98                    let fields = extract_struct_fields(&item_struct.fields);
99                    let docs = extract_docs(&item_struct.attrs);
100                    manifest.structs.push(StructDef {
101                        name: item_struct.ident.to_string(),
102                        fields,
103                        source_file: path.to_path_buf(),
104                        docs,
105                    });
106                }
107            }
108            Item::Enum(item_enum) => {
109                if has_serde_derive(&item_enum.attrs) {
110                    let variants = extract_enum_variants(item_enum);
111                    let docs = extract_docs(&item_enum.attrs);
112                    manifest.enums.push(EnumDef {
113                        name: item_enum.ident.to_string(),
114                        variants,
115                        source_file: path.to_path_buf(),
116                        docs,
117                    });
118                }
119            }
120            _ => {}
121        }
122    }
123
124    Ok(manifest)
125}
126
127/// Extracts doc comments from `#[doc = "..."]` attributes (written as `///` in source).
128///
129/// Returns `None` if no doc comments are present.
130fn extract_docs(attrs: &[Attribute]) -> Option<String> {
131    let lines: Vec<String> = attrs
132        .iter()
133        .filter_map(|attr| {
134            if !attr.path().is_ident("doc") {
135                return None;
136            }
137            if let syn::Meta::NameValue(nv) = &attr.meta
138                && let syn::Expr::Lit(syn::ExprLit {
139                    lit: syn::Lit::Str(s),
140                    ..
141                }) = &nv.value
142            {
143                let text = s.value();
144                // `///` comments produce a leading space, strip it
145                return Some(text.strip_prefix(' ').unwrap_or(&text).to_string());
146            }
147            None
148        })
149        .collect();
150
151    if lines.is_empty() {
152        None
153    } else {
154        Some(lines.join("\n"))
155    }
156}
157
158/// Attempts to extract an RPC procedure from a function item.
159/// Returns `None` if the function doesn't have an RPC attribute.
160fn try_extract_procedure(func: &ItemFn, path: &Path) -> Option<Procedure> {
161    let kind = detect_rpc_kind(&func.attrs)?;
162    let name = func.sig.ident.to_string();
163    let docs = extract_docs(&func.attrs);
164
165    let input = func.sig.inputs.iter().find_map(|arg| {
166        let FnArg::Typed(pat) = arg else { return None };
167        Some(extract_rust_type(&pat.ty))
168    });
169
170    let output = match &func.sig.output {
171        ReturnType::Default => None,
172        ReturnType::Type(_, ty) => {
173            let rust_type = extract_rust_type(ty);
174            // Unwrap Result<T, _> to just T
175            if rust_type.name == "Result" && !rust_type.generics.is_empty() {
176                Some(rust_type.generics[0].clone())
177            } else {
178                Some(rust_type)
179            }
180        }
181    };
182
183    Some(Procedure {
184        name,
185        kind,
186        input,
187        output,
188        source_file: path.to_path_buf(),
189        docs,
190    })
191}
192
193/// Checks function attributes for `#[rpc_query]` or `#[rpc_mutation]`.
194fn detect_rpc_kind(attrs: &[Attribute]) -> Option<ProcedureKind> {
195    for attr in attrs {
196        if attr.path().is_ident(RPC_QUERY_ATTR) {
197            return Some(ProcedureKind::Query);
198        }
199        if attr.path().is_ident(RPC_MUTATION_ATTR) {
200            return Some(ProcedureKind::Mutation);
201        }
202    }
203    None
204}
205
206/// Extracts variants from a Rust enum into `EnumVariant` representations.
207fn extract_enum_variants(item_enum: &syn::ItemEnum) -> Vec<EnumVariant> {
208    item_enum
209        .variants
210        .iter()
211        .map(|v| {
212            let name = v.ident.to_string();
213            let kind = match &v.fields {
214                syn::Fields::Unit => VariantKind::Unit,
215                syn::Fields::Unnamed(fields) => {
216                    let types = fields
217                        .unnamed
218                        .iter()
219                        .map(|f| extract_rust_type(&f.ty))
220                        .collect();
221                    VariantKind::Tuple(types)
222                }
223                syn::Fields::Named(fields) => {
224                    let named = fields
225                        .named
226                        .iter()
227                        .filter_map(|f| {
228                            let field_name = f.ident.as_ref()?.to_string();
229                            let ty = extract_rust_type(&f.ty);
230                            Some((field_name, ty))
231                        })
232                        .collect();
233                    VariantKind::Struct(named)
234                }
235            };
236            EnumVariant { name, kind }
237        })
238        .collect()
239}
240
241/// Checks if a struct has `#[derive(Serialize)]` or `#[derive(serde::Serialize)]`.
242fn has_serde_derive(attrs: &[Attribute]) -> bool {
243    attrs.iter().any(|attr| {
244        if !attr.path().is_ident("derive") {
245            return false;
246        }
247        attr.parse_args_with(
248            syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
249        )
250        .is_ok_and(|nested| {
251            nested.iter().any(|path| {
252                path.is_ident("Serialize")
253                    || path.segments.last().is_some_and(|s| s.ident == "Serialize")
254            })
255        })
256    })
257}