Skip to main content

vercel_rpc_cli/codegen/
typescript.rs

1use std::fmt::Write;
2
3use crate::config::FieldNaming;
4use crate::model::{
5    EnumDef, EnumVariant, FieldDef, Manifest, Procedure, ProcedureKind, RenameRule, RustType,
6    StructDef, VariantKind,
7};
8
9// Header comment included at the top of every generated file.
10const GENERATED_HEADER: &str = "\
11// This file is auto-generated by vercel-rpc-cli. Do not edit manually.
12// Re-run `rpc generate` or use `rpc watch` to regenerate.
13";
14
15/// Converts a `RustType` into its TypeScript equivalent.
16///
17/// Mapping rules:
18/// - Rust primitives (`String`, `str`, `char`) → `string`
19/// - Numeric types (`i8`..`i128`, `u8`..`u128`, `f32`, `f64`, `isize`, `usize`) → `number`
20/// - `bool` → `boolean`
21/// - `()` → `void`
22/// - `Vec<T>`, `Array<T>`, `HashSet<T>`, `BTreeSet<T>` → `T[]`
23/// - `Option<T>` → `T | null`
24/// - `HashMap<K, V>`, `BTreeMap<K, V>` → `Record<K, V>`
25/// - `Box<T>`, `Arc<T>`, `Rc<T>`, `Cow<T>` → `T` (transparent wrappers)
26/// - `tuple(A, B, ...)` → `[A, B, ...]`
27/// - Everything else (user-defined structs) → kept as-is
28pub fn rust_type_to_ts(ty: &RustType) -> String {
29    match ty.name.as_str() {
30        // Unit type
31        "()" => "void".to_string(),
32
33        // String types
34        "String" | "str" | "char" | "&str" => "string".to_string(),
35
36        // Boolean
37        "bool" => "boolean".to_string(),
38
39        // Numeric types
40        "i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "u128" | "f32"
41        | "f64" | "isize" | "usize" => "number".to_string(),
42
43        // Vec / Array / Set types → T[]
44        "Vec" | "Array" | "HashSet" | "BTreeSet" => {
45            let inner = ty
46                .generics
47                .first()
48                .map(rust_type_to_ts)
49                .unwrap_or_else(|| "unknown".to_string());
50            // Wrap union types in parens for correct precedence: `(A | B)[]`
51            if inner.contains(" | ") {
52                format!("({inner})[]")
53            } else {
54                format!("{inner}[]")
55            }
56        }
57
58        // Option<T> → T | null
59        "Option" => {
60            let inner = ty
61                .generics
62                .first()
63                .map(rust_type_to_ts)
64                .unwrap_or_else(|| "unknown".to_string());
65            format!("{inner} | null")
66        }
67
68        // HashMap / BTreeMap → Record<K, V>
69        "HashMap" | "BTreeMap" => {
70            let key = ty
71                .generics
72                .first()
73                .map(rust_type_to_ts)
74                .unwrap_or_else(|| "string".to_string());
75            let value = ty
76                .generics
77                .get(1)
78                .map(rust_type_to_ts)
79                .unwrap_or_else(|| "unknown".to_string());
80            format!("Record<{key}, {value}>")
81        }
82
83        // Smart pointers / wrappers → unwrap to inner type
84        "Box" | "Arc" | "Rc" | "Cow" => ty
85            .generics
86            .first()
87            .map(rust_type_to_ts)
88            .unwrap_or_else(|| "unknown".to_string()),
89
90        // Tuple → [A, B, ...]
91        "tuple" => {
92            let elems: Vec<String> = ty.generics.iter().map(rust_type_to_ts).collect();
93            format!("[{}]", elems.join(", "))
94        }
95
96        // User-defined types or unknown — pass through as-is
97        other => other.to_string(),
98    }
99}
100
101/// Emits a JSDoc comment block from a doc string.
102pub fn emit_jsdoc(doc: &str, indent: &str, out: &mut String) {
103    if !doc.contains('\n') {
104        let _ = writeln!(out, "{indent}/** {doc} */");
105    } else {
106        let _ = writeln!(out, "{indent}/**");
107        for line in doc.lines() {
108            let _ = writeln!(out, "{indent} * {line}");
109        }
110        let _ = writeln!(out, "{indent} */");
111    }
112}
113
114/// Converts a snake_case string to camelCase.
115pub fn to_camel_case(s: &str) -> String {
116    let mut segments = s.split('_');
117    // split() always yields at least one element
118    let mut result = segments.next().unwrap().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    let _ = writeln!(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            let _ = writeln!(out, "  {field_name}?: {ts_type} | null;");
197            continue;
198        }
199        let ts_type = rust_type_to_ts(&field.ty);
200        let _ = writeln!(out, "  {field_name}: {ts_type};");
201    }
202    let _ = writeln!(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            let _ = writeln!(out, "export type {} = never;", e.name);
240        } else {
241            let _ = writeln!(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        let _ = writeln!(
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    let _ = writeln!(out, "export type Procedures = {{");
298
299    // Queries
300    let _ = writeln!(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        let _ = writeln!(
316            out,
317            "    {}: {{ input: {input}; output: {output} }};",
318            proc.name
319        );
320    }
321    let _ = writeln!(out, "  }};");
322
323    // Mutations
324    let _ = writeln!(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        let _ = writeln!(
340            out,
341            "    {}: {{ input: {input}; output: {output} }};",
342            proc.name
343        );
344    }
345    let _ = writeln!(out, "  }};");
346
347    let _ = writeln!(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}