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