Skip to main content

vercel_rpc_cli/codegen/
typescript.rs

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