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