Skip to main content

vercel_rpc_cli/codegen/
typescript.rs

1use super::common::GENERATED_HEADER;
2use crate::config::FieldNaming;
3use crate::model::{
4    EnumDef, EnumTagging, EnumVariant, FieldDef, Manifest, Procedure, ProcedureKind, RenameRule,
5    RustType, StructDef, VariantKind,
6};
7
8/// Converts a `RustType` into its TypeScript equivalent.
9///
10/// Mapping rules:
11/// - Rust primitives (`String`, `str`, `char`) → `string`
12/// - Numeric types (`i8`..`i128`, `u8`..`u128`, `f32`, `f64`, `isize`, `usize`) → `number`
13/// - `bool` → `boolean`
14/// - `()` → `void`
15/// - `Vec<T>`, `Array<T>`, `HashSet<T>`, `BTreeSet<T>` → `T[]`
16/// - `Option<T>` → `T | null`
17/// - `HashMap<K, V>`, `BTreeMap<K, V>` → `Record<K, V>`
18/// - `Box<T>`, `Arc<T>`, `Rc<T>`, `Cow<T>` → `T` (transparent wrappers)
19/// - `tuple(A, B, ...)` → `[A, B, ...]`
20/// - Everything else (user-defined structs) → kept as-is
21pub fn rust_type_to_ts(ty: &RustType) -> String {
22    match ty.name.as_str() {
23        // Unit type
24        "()" => "void".to_string(),
25
26        // String types
27        "String" | "str" | "char" | "&str" => "string".to_string(),
28
29        // Boolean
30        "bool" => "boolean".to_string(),
31
32        // Numeric types
33        "i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "u128" | "f32"
34        | "f64" | "isize" | "usize" => "number".to_string(),
35
36        // Vec / Array / Set types → T[]
37        "Vec" | "Array" | "HashSet" | "BTreeSet" => {
38            let inner = ty
39                .generics
40                .first()
41                .map(rust_type_to_ts)
42                .unwrap_or_else(|| "unknown".to_string());
43            // Wrap union types in parens for correct precedence: `(A | B)[]`
44            if inner.contains(" | ") {
45                format!("({inner})[]")
46            } else {
47                format!("{inner}[]")
48            }
49        }
50
51        // Option<T> → T | null
52        "Option" => {
53            let inner = ty
54                .generics
55                .first()
56                .map(rust_type_to_ts)
57                .unwrap_or_else(|| "unknown".to_string());
58            format!("{inner} | null")
59        }
60
61        // HashMap / BTreeMap → Record<K, V>
62        "HashMap" | "BTreeMap" => {
63            let key = ty
64                .generics
65                .first()
66                .map(rust_type_to_ts)
67                .unwrap_or_else(|| "string".to_string());
68            let value = ty
69                .generics
70                .get(1)
71                .map(rust_type_to_ts)
72                .unwrap_or_else(|| "unknown".to_string());
73            format!("Record<{key}, {value}>")
74        }
75
76        // Smart pointers / wrappers → unwrap to inner type
77        "Box" | "Arc" | "Rc" | "Cow" => ty
78            .generics
79            .first()
80            .map(rust_type_to_ts)
81            .unwrap_or_else(|| "unknown".to_string()),
82
83        // Tuple → [A, B, ...]
84        "tuple" => {
85            let elems: Vec<String> = ty.generics.iter().map(rust_type_to_ts).collect();
86            format!("[{}]", elems.join(", "))
87        }
88
89        // User-defined types or unknown — pass through as-is
90        other => other.to_string(),
91    }
92}
93
94/// Emits a JSDoc comment block from a doc string.
95pub fn emit_jsdoc(doc: &str, indent: &str, out: &mut String) {
96    if !doc.contains('\n') {
97        emit!(out, "{indent}/** {doc} */");
98    } else {
99        emit!(out, "{indent}/**");
100        for line in doc.lines() {
101            emit!(out, "{indent} * {line}");
102        }
103        emit!(out, "{indent} */");
104    }
105}
106
107/// Converts a snake_case string to camelCase.
108pub fn to_camel_case(s: &str) -> String {
109    let mut segments = s.split('_');
110    let mut result = segments.next().unwrap_or_default().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/// Renders a single struct/variant field as `name: T` or `name?: T | null`.
169///
170/// When a field has `#[serde(default)]` and is `Option<T>`, it becomes optional
171/// (`name?: T | null`). Otherwise it renders as a required field (`name: T`).
172fn render_field_str(
173    field: &FieldDef,
174    container_rename_all: Option<RenameRule>,
175    config_naming: FieldNaming,
176) -> String {
177    let name = resolve_field_name(field, container_rename_all, config_naming);
178    if field.has_default
179        && let Some(inner) = option_inner_type(&field.ty)
180    {
181        format!("{}?: {} | null", name, rust_type_to_ts(inner))
182    } else {
183        format!("{}: {}", name, rust_type_to_ts(&field.ty))
184    }
185}
186
187/// Generates a TypeScript interface from a struct definition.
188fn generate_interface(
189    s: &StructDef,
190    preserve_docs: bool,
191    field_naming: FieldNaming,
192    out: &mut String,
193) {
194    if preserve_docs && let Some(doc) = &s.docs {
195        emit_jsdoc(doc, "", out);
196    }
197    emit!(out, "export interface {} {{", s.name);
198    for field in &s.fields {
199        if field.skip {
200            continue;
201        }
202        let rendered = render_field_str(field, s.rename_all, field_naming);
203        emit!(out, "  {rendered};");
204    }
205    emit!(out, "}}");
206}
207
208/// Generates a TypeScript type from an enum definition.
209///
210/// Dispatches to the appropriate strategy based on `e.tagging`:
211/// - `External` (default): serde's externally tagged representation
212/// - `Internal { tag }`: internally tagged (`#[serde(tag = "...")]`)
213/// - `Adjacent { tag, content }`: adjacently tagged (`#[serde(tag = "...", content = "...")]`)
214/// - `Untagged`: no tag wrapper (`#[serde(untagged)]`)
215fn generate_enum_type(
216    e: &EnumDef,
217    preserve_docs: bool,
218    field_naming: FieldNaming,
219    out: &mut String,
220) {
221    if preserve_docs && let Some(doc) = &e.docs {
222        emit_jsdoc(doc, "", out);
223    }
224
225    match &e.tagging {
226        EnumTagging::External => generate_enum_external(e, field_naming, out),
227        EnumTagging::Internal { tag } => generate_enum_internal(e, tag, field_naming, out),
228        EnumTagging::Adjacent { tag, content } => {
229            generate_enum_adjacent(e, tag, content, field_naming, out);
230        }
231        EnumTagging::Untagged => generate_enum_untagged(e, field_naming, out),
232    }
233}
234
235/// Externally tagged (serde default): `{ "Variant": data }` or string literal for unit variants.
236fn generate_enum_external(e: &EnumDef, field_naming: FieldNaming, out: &mut String) {
237    let all_unit = e
238        .variants
239        .iter()
240        .all(|v| matches!(v.kind, VariantKind::Unit));
241
242    if all_unit {
243        let variants: Vec<String> = e
244            .variants
245            .iter()
246            .map(|v| {
247                let name = resolve_variant_name(v, e.rename_all);
248                format!("\"{name}\"")
249            })
250            .collect();
251        if variants.is_empty() {
252            emit!(out, "export type {} = never;", e.name);
253        } else {
254            emit!(out, "export type {} = {};", e.name, variants.join(" | "));
255        }
256    } else {
257        let mut variant_types: Vec<String> = Vec::new();
258
259        for v in &e.variants {
260            let variant_name = resolve_variant_name(v, e.rename_all);
261            match &v.kind {
262                VariantKind::Unit => {
263                    variant_types.push(format!("\"{variant_name}\""));
264                }
265                VariantKind::Tuple(types) => {
266                    let inner = if types.len() == 1 {
267                        rust_type_to_ts(&types[0])
268                    } else {
269                        let elems: Vec<String> = types.iter().map(rust_type_to_ts).collect();
270                        format!("[{}]", elems.join(", "))
271                    };
272                    variant_types.push(format!("{{ {variant_name}: {inner} }}"));
273                }
274                VariantKind::Struct(fields) => {
275                    let field_strs: Vec<String> = fields
276                        .iter()
277                        .filter(|f| !f.skip)
278                        .map(|field| render_field_str(field, e.rename_all, field_naming))
279                        .collect();
280                    variant_types.push(format!(
281                        "{{ {variant_name}: {{ {} }} }}",
282                        field_strs.join("; ")
283                    ));
284                }
285            }
286        }
287
288        emit!(
289            out,
290            "export type {} = {};",
291            e.name,
292            variant_types.join(" | ")
293        );
294    }
295}
296
297/// Internally tagged: `{ "tag": "Variant", ...fields }`.
298///
299/// - Unit → `{ tag: "Name" }`
300/// - Struct → `{ tag: "Name"; field: T; ... }`
301/// - Tuple(1) → `{ tag: "Name" } & InnerType` (newtype wrapping struct)
302/// - Tuple(n>1) → skipped (serde rejects multi-field tuples in internally tagged)
303fn generate_enum_internal(e: &EnumDef, tag: &str, field_naming: FieldNaming, out: &mut String) {
304    if e.variants.is_empty() {
305        emit!(out, "export type {} = never;", e.name);
306        return;
307    }
308
309    let mut variant_types: Vec<String> = Vec::new();
310
311    for v in &e.variants {
312        let variant_name = resolve_variant_name(v, e.rename_all);
313        match &v.kind {
314            VariantKind::Unit => {
315                variant_types.push(format!("{{ {tag}: \"{variant_name}\" }}"));
316            }
317            VariantKind::Struct(fields) => {
318                let field_strs: Vec<String> = fields
319                    .iter()
320                    .filter(|f| !f.skip)
321                    .map(|field| render_field_str(field, e.rename_all, field_naming))
322                    .collect();
323                let mut parts = vec![format!("{tag}: \"{variant_name}\"")];
324                parts.extend(field_strs);
325                variant_types.push(format!("{{ {} }}", parts.join("; ")));
326            }
327            VariantKind::Tuple(types) => {
328                if types.len() == 1 {
329                    let inner = rust_type_to_ts(&types[0]);
330                    variant_types.push(format!("{{ {tag}: \"{variant_name}\" }} & {inner}"));
331                }
332                // Multi-field tuples are rejected by serde for internal tagging — skip
333            }
334        }
335    }
336
337    if variant_types.is_empty() {
338        emit!(out, "export type {} = never;", e.name);
339    } else {
340        emit!(
341            out,
342            "export type {} = {};",
343            e.name,
344            variant_types.join(" | ")
345        );
346    }
347}
348
349/// Adjacently tagged: `{ "tag": "Variant", "content": data }`.
350///
351/// - Unit → `{ tag: "Name" }` (no content key)
352/// - Tuple(1) → `{ tag: "Name"; content: T }`
353/// - Tuple(n) → `{ tag: "Name"; content: [A, B, ...] }`
354/// - Struct → `{ tag: "Name"; content: { field: T; ... } }`
355fn generate_enum_adjacent(
356    e: &EnumDef,
357    tag: &str,
358    content: &str,
359    field_naming: FieldNaming,
360    out: &mut String,
361) {
362    if e.variants.is_empty() {
363        emit!(out, "export type {} = never;", e.name);
364        return;
365    }
366
367    let mut variant_types: Vec<String> = Vec::new();
368
369    for v in &e.variants {
370        let variant_name = resolve_variant_name(v, e.rename_all);
371        match &v.kind {
372            VariantKind::Unit => {
373                variant_types.push(format!("{{ {tag}: \"{variant_name}\" }}"));
374            }
375            VariantKind::Tuple(types) => {
376                let inner = if types.len() == 1 {
377                    rust_type_to_ts(&types[0])
378                } else {
379                    let elems: Vec<String> = types.iter().map(rust_type_to_ts).collect();
380                    format!("[{}]", elems.join(", "))
381                };
382                variant_types.push(format!(
383                    "{{ {tag}: \"{variant_name}\"; {content}: {inner} }}"
384                ));
385            }
386            VariantKind::Struct(fields) => {
387                let field_strs: Vec<String> = fields
388                    .iter()
389                    .filter(|f| !f.skip)
390                    .map(|field| render_field_str(field, e.rename_all, field_naming))
391                    .collect();
392                variant_types.push(format!(
393                    "{{ {tag}: \"{variant_name}\"; {content}: {{ {} }} }}",
394                    field_strs.join("; ")
395                ));
396            }
397        }
398    }
399
400    emit!(
401        out,
402        "export type {} = {};",
403        e.name,
404        variant_types.join(" | ")
405    );
406}
407
408/// Untagged: no wrapper, just the data.
409///
410/// - Unit → `null`
411/// - Tuple(1) → `T`
412/// - Tuple(n) → `[A, B, ...]`
413/// - Struct → `{ field: T; ... }`
414/// - Empty enum → `never`
415fn generate_enum_untagged(e: &EnumDef, field_naming: FieldNaming, out: &mut String) {
416    if e.variants.is_empty() {
417        emit!(out, "export type {} = never;", e.name);
418        return;
419    }
420
421    let mut variant_types: Vec<String> = Vec::new();
422
423    for v in &e.variants {
424        match &v.kind {
425            VariantKind::Unit => {
426                variant_types.push("null".to_string());
427            }
428            VariantKind::Tuple(types) => {
429                if types.len() == 1 {
430                    variant_types.push(rust_type_to_ts(&types[0]));
431                } else {
432                    let elems: Vec<String> = types.iter().map(rust_type_to_ts).collect();
433                    variant_types.push(format!("[{}]", elems.join(", ")));
434                }
435            }
436            VariantKind::Struct(fields) => {
437                let field_strs: Vec<String> = fields
438                    .iter()
439                    .filter(|f| !f.skip)
440                    .map(|field| render_field_str(field, e.rename_all, field_naming))
441                    .collect();
442                variant_types.push(format!("{{ {} }}", field_strs.join("; ")));
443            }
444        }
445    }
446
447    emit!(
448        out,
449        "export type {} = {};",
450        e.name,
451        variant_types.join(" | ")
452    );
453}
454
455/// Generates the `Procedures` type that maps procedure names to their input/output types,
456/// grouped by kind (queries / mutations).
457fn generate_procedures_type(procedures: &[Procedure], preserve_docs: bool, out: &mut String) {
458    let (queries, mutations): (Vec<_>, Vec<_>) = procedures
459        .iter()
460        .partition(|p| p.kind == ProcedureKind::Query);
461
462    emit!(out, "export type Procedures = {{");
463
464    // Queries
465    emit!(out, "  queries: {{");
466    for proc in &queries {
467        if preserve_docs && let Some(doc) = &proc.docs {
468            emit_jsdoc(doc, "    ", out);
469        }
470        let input = proc
471            .input
472            .as_ref()
473            .map(rust_type_to_ts)
474            .unwrap_or_else(|| "void".to_string());
475        let output = proc
476            .output
477            .as_ref()
478            .map(rust_type_to_ts)
479            .unwrap_or_else(|| "void".to_string());
480        emit!(
481            out,
482            "    {}: {{ input: {input}; output: {output} }};",
483            proc.name
484        );
485    }
486    emit!(out, "  }};");
487
488    // Mutations
489    emit!(out, "  mutations: {{");
490    for proc in &mutations {
491        if preserve_docs && let Some(doc) = &proc.docs {
492            emit_jsdoc(doc, "    ", out);
493        }
494        let input = proc
495            .input
496            .as_ref()
497            .map(rust_type_to_ts)
498            .unwrap_or_else(|| "void".to_string());
499        let output = proc
500            .output
501            .as_ref()
502            .map(rust_type_to_ts)
503            .unwrap_or_else(|| "void".to_string());
504        emit!(
505            out,
506            "    {}: {{ input: {input}; output: {output} }};",
507            proc.name
508        );
509    }
510    emit!(out, "  }};");
511
512    emit!(out, "}};");
513}
514
515/// Generates the complete `rpc-types.ts` file content from a manifest.
516///
517/// The output includes:
518/// 1. Auto-generation header
519/// 2. TypeScript interfaces for all referenced structs
520/// 3. The `Procedures` type mapping
521pub fn generate_types_file(
522    manifest: &Manifest,
523    preserve_docs: bool,
524    field_naming: FieldNaming,
525) -> String {
526    let mut out = String::with_capacity(1024);
527
528    // Header
529    out.push_str(GENERATED_HEADER);
530    out.push('\n');
531
532    // Emit all structs discovered in the scanned files.
533    for s in &manifest.structs {
534        generate_interface(s, preserve_docs, field_naming, &mut out);
535        out.push('\n');
536    }
537
538    // Emit all enums discovered in the scanned files.
539    for e in &manifest.enums {
540        generate_enum_type(e, preserve_docs, field_naming, &mut out);
541        out.push('\n');
542    }
543
544    // Generate the Procedures type
545    generate_procedures_type(&manifest.procedures, preserve_docs, &mut out);
546
547    out
548}