Skip to main content

vercel_rpc_cli/codegen/
typescript.rs

1use crate::config::FieldNaming;
2use crate::model::{
3    EnumDef, EnumVariant, FieldDef, Manifest, Procedure, ProcedureKind, RenameRule, RustType,
4    StructDef, VariantKind,
5};
6
7// Header comment included at the top of every generated file.
8const GENERATED_HEADER: &str = "\
9// This file is auto-generated by vercel-rpc-cli. Do not edit manually.
10// Re-run `rpc generate` or use `rpc watch` to regenerate.
11";
12
13/// Converts a `RustType` into its TypeScript equivalent.
14///
15/// Mapping rules:
16/// - Rust primitives (`String`, `str`, `char`) → `string`
17/// - Numeric types (`i8`..`i128`, `u8`..`u128`, `f32`, `f64`, `isize`, `usize`) → `number`
18/// - `bool` → `boolean`
19/// - `()` → `void`
20/// - `Vec<T>`, `Array<T>`, `HashSet<T>`, `BTreeSet<T>` → `T[]`
21/// - `Option<T>` → `T | null`
22/// - `HashMap<K, V>`, `BTreeMap<K, V>` → `Record<K, V>`
23/// - `Box<T>`, `Arc<T>`, `Rc<T>`, `Cow<T>` → `T` (transparent wrappers)
24/// - `tuple(A, B, ...)` → `[A, B, ...]`
25/// - Everything else (user-defined structs) → kept as-is
26pub fn rust_type_to_ts(ty: &RustType) -> String {
27    match ty.name.as_str() {
28        // Unit type
29        "()" => "void".to_string(),
30
31        // String types
32        "String" | "str" | "char" | "&str" => "string".to_string(),
33
34        // Boolean
35        "bool" => "boolean".to_string(),
36
37        // Numeric types
38        "i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "u128" | "f32"
39        | "f64" | "isize" | "usize" => "number".to_string(),
40
41        // Vec / Array / Set types → T[]
42        "Vec" | "Array" | "HashSet" | "BTreeSet" => {
43            let inner = ty
44                .generics
45                .first()
46                .map(rust_type_to_ts)
47                .unwrap_or_else(|| "unknown".to_string());
48            // Wrap union types in parens for correct precedence: `(A | B)[]`
49            if inner.contains(" | ") {
50                format!("({inner})[]")
51            } else {
52                format!("{inner}[]")
53            }
54        }
55
56        // Option<T> → T | null
57        "Option" => {
58            let inner = ty
59                .generics
60                .first()
61                .map(rust_type_to_ts)
62                .unwrap_or_else(|| "unknown".to_string());
63            format!("{inner} | null")
64        }
65
66        // HashMap / BTreeMap → Record<K, V>
67        "HashMap" | "BTreeMap" => {
68            let key = ty
69                .generics
70                .first()
71                .map(rust_type_to_ts)
72                .unwrap_or_else(|| "string".to_string());
73            let value = ty
74                .generics
75                .get(1)
76                .map(rust_type_to_ts)
77                .unwrap_or_else(|| "unknown".to_string());
78            format!("Record<{key}, {value}>")
79        }
80
81        // Smart pointers / wrappers → unwrap to inner type
82        "Box" | "Arc" | "Rc" | "Cow" => ty
83            .generics
84            .first()
85            .map(rust_type_to_ts)
86            .unwrap_or_else(|| "unknown".to_string()),
87
88        // Tuple → [A, B, ...]
89        "tuple" => {
90            let elems: Vec<String> = ty.generics.iter().map(rust_type_to_ts).collect();
91            format!("[{}]", elems.join(", "))
92        }
93
94        // User-defined types or unknown — pass through as-is
95        other => other.to_string(),
96    }
97}
98
99/// Emits a JSDoc comment block from a doc string.
100pub fn emit_jsdoc(doc: &str, indent: &str, out: &mut String) {
101    if !doc.contains('\n') {
102        emit!(out, "{indent}/** {doc} */");
103    } else {
104        emit!(out, "{indent}/**");
105        for line in doc.lines() {
106            emit!(out, "{indent} * {line}");
107        }
108        emit!(out, "{indent} */");
109    }
110}
111
112/// Converts a snake_case string to camelCase.
113pub fn to_camel_case(s: &str) -> String {
114    let mut segments = s.split('_');
115    let mut result = segments
116        .next()
117        .expect("split always yields at least one element")
118        .to_lowercase();
119    for segment in segments {
120        let mut chars = segment.chars();
121        if let Some(first) = chars.next() {
122            result.extend(first.to_uppercase());
123            result.push_str(&chars.as_str().to_lowercase());
124        }
125    }
126    result
127}
128
129/// Transforms a field name according to the naming strategy.
130fn transform_field_name(name: &str, naming: FieldNaming) -> String {
131    match naming {
132        FieldNaming::Preserve => name.to_string(),
133        FieldNaming::CamelCase => to_camel_case(name),
134    }
135}
136
137/// Resolves the final output name for a struct/variant field.
138///
139/// Priority: field `rename` > container `rename_all` > config `field_naming` > original name.
140fn resolve_field_name(
141    field: &FieldDef,
142    container_rename_all: Option<RenameRule>,
143    config_naming: FieldNaming,
144) -> String {
145    if let Some(rename) = &field.rename {
146        return rename.clone();
147    }
148    if let Some(rule) = container_rename_all {
149        return rule.apply(&field.name);
150    }
151    transform_field_name(&field.name, config_naming)
152}
153
154/// Resolves the final output name for an enum variant.
155///
156/// Priority: variant `rename` > container `rename_all` > original name.
157fn resolve_variant_name(variant: &EnumVariant, container_rename_all: Option<RenameRule>) -> String {
158    if let Some(rename) = &variant.rename {
159        return rename.clone();
160    }
161    if let Some(rule) = container_rename_all {
162        return rule.apply(&variant.name);
163    }
164    variant.name.clone()
165}
166
167/// Returns the inner type of `Option<T>`, or `None` if not an Option.
168fn option_inner_type(ty: &RustType) -> Option<&RustType> {
169    if ty.name == "Option" {
170        ty.generics.first()
171    } else {
172        None
173    }
174}
175
176/// Generates a TypeScript interface from a struct definition.
177fn generate_interface(
178    s: &StructDef,
179    preserve_docs: bool,
180    field_naming: FieldNaming,
181    out: &mut String,
182) {
183    if preserve_docs && let Some(doc) = &s.docs {
184        emit_jsdoc(doc, "", out);
185    }
186    emit!(out, "export interface {} {{", s.name);
187    for field in &s.fields {
188        if field.skip {
189            continue;
190        }
191        let field_name = resolve_field_name(field, s.rename_all, field_naming);
192        if field.has_default
193            && let Some(inner) = option_inner_type(&field.ty)
194        {
195            let ts_type = rust_type_to_ts(inner);
196            emit!(out, "  {field_name}?: {ts_type} | null;");
197            continue;
198        }
199        let ts_type = rust_type_to_ts(&field.ty);
200        emit!(out, "  {field_name}: {ts_type};");
201    }
202    emit!(out, "}}");
203}
204
205/// Generates a TypeScript type from an enum definition.
206///
207/// Supports three variant shapes following serde's default (externally tagged) representation:
208/// - Unit variants → string literal union: `"Active" | "Inactive"`
209/// - Tuple variants → `{ VariantName: T }` (single field) or `{ VariantName: [A, B] }` (multiple)
210/// - Struct variants → `{ VariantName: { field: type } }`
211///
212/// If all variants are unit, emits a simple string union.
213/// Otherwise, emits a discriminated union of object types.
214fn generate_enum_type(
215    e: &EnumDef,
216    preserve_docs: bool,
217    field_naming: FieldNaming,
218    out: &mut String,
219) {
220    if preserve_docs && let Some(doc) = &e.docs {
221        emit_jsdoc(doc, "", out);
222    }
223    let all_unit = e
224        .variants
225        .iter()
226        .all(|v| matches!(v.kind, VariantKind::Unit));
227
228    if all_unit {
229        // Simple string literal union
230        let variants: Vec<String> = e
231            .variants
232            .iter()
233            .map(|v| {
234                let name = resolve_variant_name(v, e.rename_all);
235                format!("\"{name}\"")
236            })
237            .collect();
238        if variants.is_empty() {
239            emit!(out, "export type {} = never;", e.name);
240        } else {
241            emit!(out, "export type {} = {};", e.name, variants.join(" | "));
242        }
243    } else {
244        // Tagged union (serde externally tagged default)
245        let mut variant_types: Vec<String> = Vec::new();
246
247        for v in &e.variants {
248            let variant_name = resolve_variant_name(v, e.rename_all);
249            match &v.kind {
250                VariantKind::Unit => {
251                    variant_types.push(format!("\"{variant_name}\""));
252                }
253                VariantKind::Tuple(types) => {
254                    let inner = if types.len() == 1 {
255                        rust_type_to_ts(&types[0])
256                    } else {
257                        let elems: Vec<String> = types.iter().map(rust_type_to_ts).collect();
258                        format!("[{}]", elems.join(", "))
259                    };
260                    variant_types.push(format!("{{ {variant_name}: {inner} }}"));
261                }
262                VariantKind::Struct(fields) => {
263                    // Serde applies the container-level rename_all to struct variant
264                    // fields as well, so we pass e.rename_all here intentionally.
265                    let field_strs: Vec<String> = fields
266                        .iter()
267                        .filter(|f| !f.skip)
268                        .map(|field| {
269                            let field_name = resolve_field_name(field, e.rename_all, field_naming);
270                            format!("{}: {}", field_name, rust_type_to_ts(&field.ty))
271                        })
272                        .collect();
273                    variant_types.push(format!(
274                        "{{ {variant_name}: {{ {} }} }}",
275                        field_strs.join("; ")
276                    ));
277                }
278            }
279        }
280
281        emit!(
282            out,
283            "export type {} = {};",
284            e.name,
285            variant_types.join(" | ")
286        );
287    }
288}
289
290/// Generates the `Procedures` type that maps procedure names to their input/output types,
291/// grouped by kind (queries / mutations).
292fn generate_procedures_type(procedures: &[Procedure], preserve_docs: bool, out: &mut String) {
293    let (queries, mutations): (Vec<_>, Vec<_>) = procedures
294        .iter()
295        .partition(|p| p.kind == ProcedureKind::Query);
296
297    emit!(out, "export type Procedures = {{");
298
299    // Queries
300    emit!(out, "  queries: {{");
301    for proc in &queries {
302        if preserve_docs && let Some(doc) = &proc.docs {
303            emit_jsdoc(doc, "    ", out);
304        }
305        let input = proc
306            .input
307            .as_ref()
308            .map(rust_type_to_ts)
309            .unwrap_or_else(|| "void".to_string());
310        let output = proc
311            .output
312            .as_ref()
313            .map(rust_type_to_ts)
314            .unwrap_or_else(|| "void".to_string());
315        emit!(
316            out,
317            "    {}: {{ input: {input}; output: {output} }};",
318            proc.name
319        );
320    }
321    emit!(out, "  }};");
322
323    // Mutations
324    emit!(out, "  mutations: {{");
325    for proc in &mutations {
326        if preserve_docs && let Some(doc) = &proc.docs {
327            emit_jsdoc(doc, "    ", out);
328        }
329        let input = proc
330            .input
331            .as_ref()
332            .map(rust_type_to_ts)
333            .unwrap_or_else(|| "void".to_string());
334        let output = proc
335            .output
336            .as_ref()
337            .map(rust_type_to_ts)
338            .unwrap_or_else(|| "void".to_string());
339        emit!(
340            out,
341            "    {}: {{ input: {input}; output: {output} }};",
342            proc.name
343        );
344    }
345    emit!(out, "  }};");
346
347    emit!(out, "}};");
348}
349
350/// Generates the complete `rpc-types.ts` file content from a manifest.
351///
352/// The output includes:
353/// 1. Auto-generation header
354/// 2. TypeScript interfaces for all referenced structs
355/// 3. The `Procedures` type mapping
356pub fn generate_types_file(
357    manifest: &Manifest,
358    preserve_docs: bool,
359    field_naming: FieldNaming,
360) -> String {
361    let mut out = String::with_capacity(1024);
362
363    // Header
364    out.push_str(GENERATED_HEADER);
365    out.push('\n');
366
367    // Emit all structs discovered in the scanned files.
368    for s in &manifest.structs {
369        generate_interface(s, preserve_docs, field_naming, &mut out);
370        out.push('\n');
371    }
372
373    // Emit all enums discovered in the scanned files.
374    for e in &manifest.enums {
375        generate_enum_type(e, preserve_docs, field_naming, &mut out);
376        out.push('\n');
377    }
378
379    // Generate the Procedures type
380    generate_procedures_type(&manifest.procedures, preserve_docs, &mut out);
381
382    out
383}